public inbox for [email protected]  
help / color / mirror / Atom feed
[PATCH v29 11/11] Add documentations about Incremental View Maintenance
101+ messages / 13 participants
[nested] [flat]

* [PATCH v29 11/11] Add documentations about Incremental View Maintenance
@ 2019-12-20 01:25  Yugo Nagata <[email protected]>
  0 siblings, 0 replies; 101+ messages in thread

From: Yugo Nagata @ 2019-12-20 01:25 UTC (permalink / raw)

---
 doc/src/sgml/catalogs.sgml                    |   9 +
 .../sgml/ref/create_materialized_view.sgml    | 124 ++++-
 .../sgml/ref/refresh_materialized_view.sgml   |   8 +-
 doc/src/sgml/rules.sgml                       | 437 ++++++++++++++++++
 doc/src/sgml/system-views.sgml                |   9 +
 5 files changed, 583 insertions(+), 4 deletions(-)

diff --git a/doc/src/sgml/catalogs.sgml b/doc/src/sgml/catalogs.sgml
index d17ff51e28..3de3303cac 100644
--- a/doc/src/sgml/catalogs.sgml
+++ b/doc/src/sgml/catalogs.sgml
@@ -2224,6 +2224,15 @@ SCRAM-SHA-256$<replaceable>&lt;iteration count&gt;</replaceable>:<replaceable>&l
       </para></entry>
      </row>
 
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>relisivm</structfield> <type>bool</type>
+      </para>
+      <para>
+       True if relation is incrementally maintainable materialized view
+      </para></entry>
+     </row>
+
      <row>
       <entry role="catalog_table_entry"><para role="column_definition">
        <structfield>relrewrite</structfield> <type>oid</type>
diff --git a/doc/src/sgml/ref/create_materialized_view.sgml b/doc/src/sgml/ref/create_materialized_view.sgml
index 0d2fea2b97..8c574062db 100644
--- a/doc/src/sgml/ref/create_materialized_view.sgml
+++ b/doc/src/sgml/ref/create_materialized_view.sgml
@@ -21,7 +21,7 @@ PostgreSQL documentation
 
  <refsynopsisdiv>
 <synopsis>
-CREATE MATERIALIZED VIEW [ IF NOT EXISTS ] <replaceable>table_name</replaceable>
+CREATE [ INCREMENTAL ] MATERIALIZED VIEW [ IF NOT EXISTS ] <replaceable>table_name</replaceable>
     [ (<replaceable>column_name</replaceable> [, ...] ) ]
     [ USING <replaceable class="parameter">method</replaceable> ]
     [ WITH ( <replaceable class="parameter">storage_parameter</replaceable> [= <replaceable class="parameter">value</replaceable>] [, ... ] ) ]
@@ -60,6 +60,125 @@ CREATE MATERIALIZED VIEW [ IF NOT EXISTS ] <replaceable>table_name</replaceable>
   <title>Parameters</title>
 
   <variablelist>
+   <varlistentry>
+    <term><literal>INCREMENTAL</literal></term>
+    <listitem>
+     <para>
+      If specified, some triggers are automatically created so that the rows
+      of the materialized view are immediately updated when base tables of the
+      materialized view are updated. In general, this allows faster update of
+      the materialized view at a price of slower update of the base tables
+      because the triggers will be invoked. We call this form of materialized
+      view as "Incrementally Maintainable Materialized View" (IMMV).
+     </para>
+     <para>
+      When <acronym>IMMV</acronym> is defined without using <command>WITH NO DATA</command>,
+      a unique index is created on the view automatically if possible.  If the view
+      definition query has a GROUP BY clause, a unique index is created on the columns
+      of GROUP BY expressions.  Also, if the view has DISTINCT clause, a unique index
+      is created on all columns in the target list.  Otherwise, if the view contains all
+      primary key attritubes of its base tables in the target list, a unique index is
+      created on these attritubes.  In other cases, no index is created.
+     </para>
+     <para>
+      There are restrictions of query definitions allowed to use this
+      option. The following are supported in query definitions for IMMV:
+      <itemizedlist>
+
+       <listitem>
+        <para>
+         Inner joins (including self-joins).
+        </para>
+       </listitem>
+
+       <listitem>
+        <para>
+         Some built-in aggregate functions (count, sum, avg, min, max) without a HAVING
+         clause.
+        </para>
+        </listitem>
+      </itemizedlist>
+
+      Unsupported queries with this option include the following:
+
+      <itemizedlist>
+       <listitem>
+        <para>
+         Outer joins.
+        </para>
+       </listitem>
+
+       <listitem>
+        <para>
+         Sub-queries.
+        </para>
+       </listitem>
+
+       <listitem>
+        <para>
+         Aggregate functions other than built-in count, sum, avg, min and max.
+        </para>
+       </listitem>
+       <listitem>
+        <para>
+         Aggregate functions with a HAVING clause.
+        </para>
+       </listitem>
+       <listitem>
+        <para>
+         DISTINCT ON, WINDOW, VALUES, LIMIT and OFFSET clause.
+        </para>
+       </listitem>
+      </itemizedlist>
+
+      Other restrictions include:
+      <itemizedlist>
+
+       <listitem>
+        <para>
+         IMMVs must be based on simple base tables. It's not supported to
+         create them on top of views or materialized views.
+        </para>
+       </listitem>
+
+       <listitem>
+        <para>
+         It is not supported to include system columns in an IMMV.
+         <programlisting>
+CREATE INCREMENTAL MATERIALIZED VIEW  mv_ivm02 AS SELECT i,j FROM mv_base_a WHERE xmin = '610';
+ERROR:  system column is not supported with IVM
+         </programlisting>
+        </para>
+       </listitem>
+
+       <listitem>
+        <para>
+         Non-immutable functions are not supported.
+         <programlisting>
+CREATE INCREMENTAL MATERIALIZED VIEW  mv_ivm12 AS SELECT i,j FROM mv_base_a WHERE i = random()::int;
+ERROR:  functions in IMMV must be marked IMMUTABLE
+         </programlisting>
+        </para>
+        </listitem>
+
+       <listitem>
+        <para>
+         IMMVs do not support expressions that contains aggregates
+        </para>
+       </listitem>
+
+       <listitem>
+        <para>
+         Logical replication does not support IMMVs.
+        </para>
+       </listitem>
+
+      </itemizedlist>
+
+     </para>
+    </listitem>
+   </varlistentry>
+
    <varlistentry>
     <term><literal>IF NOT EXISTS</literal></term>
     <listitem>
@@ -155,7 +274,8 @@ CREATE MATERIALIZED VIEW [ IF NOT EXISTS ] <replaceable>table_name</replaceable>
       This clause specifies whether or not the materialized view should be
       populated at creation time.  If not, the materialized view will be
       flagged as unscannable and cannot be queried until <command>REFRESH
-      MATERIALIZED VIEW</command> is used.
+      MATERIALIZED VIEW</command> is used.  Also, if the view is IMMV,
+      triggers for maintaining the view are not created.
      </para>
     </listitem>
    </varlistentry>
diff --git a/doc/src/sgml/ref/refresh_materialized_view.sgml b/doc/src/sgml/ref/refresh_materialized_view.sgml
index 675d6090f3..c29cfc19b6 100644
--- a/doc/src/sgml/ref/refresh_materialized_view.sgml
+++ b/doc/src/sgml/ref/refresh_materialized_view.sgml
@@ -35,9 +35,13 @@ REFRESH MATERIALIZED VIEW [ CONCURRENTLY ] <replaceable class="parameter">name</
    owner of the materialized view.  The old contents are discarded.  If
    <literal>WITH DATA</literal> is specified (or defaults) the backing query
    is executed to provide the new data, and the materialized view is left in a
-   scannable state.  If <literal>WITH NO DATA</literal> is specified no new
+   scannable state.  If the view is an incrementally maintainable materialized
+   view (IMMV) and was unpopulated, triggers for maintaining the view are
+   created. Also, a unique index is created for IMMV if it is possible and the
+   view doesn't have that yet.
+   If <literal>WITH NO DATA</literal> is specified no new
    data is generated and the materialized view is left in an unscannable
-   state.
+   state.  If the view is IMMV, the triggers are dropped.
   </para>
   <para>
    <literal>CONCURRENTLY</literal> and <literal>WITH NO DATA</literal> may not
diff --git a/doc/src/sgml/rules.sgml b/doc/src/sgml/rules.sgml
index d229b94d39..22e4cad103 100644
--- a/doc/src/sgml/rules.sgml
+++ b/doc/src/sgml/rules.sgml
@@ -1096,6 +1096,443 @@ SELECT word FROM words ORDER BY word &lt;-&gt; 'caterpiler' LIMIT 10;
 
 </sect1>
 
+<sect1 id="rules-ivm">
+<title>Incremental View Maintenance</title>
+
+<indexterm zone="rules-ivm">
+ <primary>incremental view maintenance</primary>
+</indexterm>
+
+<indexterm zone="rules-ivm">
+ <primary>materialized view</primary>
+ <secondary>incremental view maintenance</secondary>
+</indexterm>
+
+<indexterm zone="rules-ivm">
+ <primary>view</primary>
+ <secondary>incremental view maintenance</secondary>
+</indexterm>
+
+<sect2 id="rules-ivm-overview">
+<title>Overview</title>
+
+<para>
+    Incremental View Maintenance (<acronym>IVM</acronym>) is a way to make
+    materialized views up-to-date in which only incremental changes are computed
+    and applied on views rather than recomputing the contents from scratch as
+    <command>REFRESH MATERIALIZED VIEW</command> does.  <acronym>IVM</acronym>
+    can update materialized views more efficiently than recomputation when only
+    small parts of the view are changed.
+</para>
+
+<para>
+    There are two approaches with regard to timing of view maintenance:
+    immediate and deferred.  In immediate maintenance, views are updated in the
+    same transaction that its base table is modified.  In deferred maintenance,
+    views are updated after the transaction is committed, for example, when the
+    view is accessed, as a response to user command like <command>REFRESH
+    MATERIALIZED VIEW</command>, or periodically in background, and so on.
+    <productname>PostgreSQL</productname> currently implements only a kind of
+    immediate maintenance, in which materialized views are updated immediately
+    in AFTER triggers when a base table is modified.
+</para>
+
+<para>
+    To create materialized views supporting <acronym>IVM</acronym>, use the
+    <command>CREATE INCREMENTAL MATERIALIZED VIEW</command>, for example:
+<programlisting>
+CREATE <emphasis>INCREMENTAL</emphasis> MATERIALIZED VIEW mymatview AS SELECT * FROM mytab;
+</programlisting>
+    When a materialized view is created with the <literal>INCREMENTAL</literal>
+    keyword, some triggers are automatically created so that the view's contents are
+    immediately updated when its base tables are modified. We call this form
+    of materialized view an Incrementally Maintainable Materialized View
+    (<acronym>IMMV</acronym>).
+<programlisting>
+postgres=# CREATE INCREMENTAL MATERIALIZED VIEW m AS SELECT * FROM t0;
+NOTICE:  could not create an index on materialized view "m" automatically
+HINT:  Create an index on the materialized view for effcient incremental maintenance.
+SELECT 3
+postgres=# SELECT * FROM m;
+ i
+---
+ 1
+ 2
+ 3
+(3 rows)
+
+postgres=# INSERT INTO t0 VALUES (4);
+INSERT 0 1
+postgres=# SELECT * FROM m; -- automatically updated
+ i
+---
+ 1
+ 2
+ 3
+ 4
+(4 rows)
+</programlisting>
+</para>
+
+<para>
+    Some <acronym>IMMV</acronym>s have hidden columns which are added
+    automatically when a materialized view is created. Their name starts
+    with <literal>__ivm_</literal> and they contain information required
+    for maintaining the <acronym>IMMV</acronym>. Such columns are not visible
+    when the <acronym>IMMV</acronym> is accessed by <literal>SELECT *</literal>
+    but are visible if the column name is explicitly specified in the target
+    list. We can also see the hidden columns in <literal>\d</literal>
+    meta-commands of <command>psql</command> commands.
+</para>
+
+<para>
+    In general, <acronym>IMMV</acronym>s allow faster updates of materialized
+    views at the price of slower updates to their base tables. Updates of
+    <acronym>IMMV</acronym> is slower because triggers will be invoked and the
+    view is updated in triggers per modification statement.
+</para>
+
+<para>
+    For example, suppose a normal materialized view defined as below:
+
+<programlisting>
+test=# CREATE INCREMENTAL MATERIALIZED VIEW mv_ivm AS
+        SELECT a.aid, b.bid, a.abalance, b.bbalance
+        FROM pgbench_accounts a JOIN pgbench_branches b USING(bid);
+SELECT 10000000
+
+</programlisting>
+
+    Updating a tuple in a base table of this materialized view is rapid but the
+   <command>REFRESH MATERIALIZED VIEW</command> command on this view takes a long time:
+
+<programlisting>
+test=# UPDATE pgbench_accounts SET abalance = 1000 WHERE aid = 1;
+UPDATE 1
+Time: 0.990 ms
+
+test=# REFRESH MATERIALIZED VIEW mv_normal ;
+REFRESH MATERIALIZED VIEW
+Time: 33533.952 ms (00:33.534)
+</programlisting>
+</para>
+
+<para>
+    On the other hand, after creating <acronym>IMMV</acronym> with the same view
+    definition as below:
+
+<programlisting>
+test=# CREATE INCREMENTAL MATERIALIZED VIEW mv_ivm AS
+        SELECT a.aid, b.bid, a.abalance, b.bbalance
+        FROM pgbench_accounts a JOIN pgbench_branches b USING(bid);
+test=# UPDATE pgbench_accounts SET abalance = 1000 WHERE aid = 1;
+NOTICE:  created index "mv_ivm_index" on materialized view "mv_ivm"
+</programlisting>
+
+    updating a tuple in a base table takes more than the normal view,
+    but its content is updated automatically and this is faster than the
+    <command>REFRESH MATERIALIZED VIEW</command> command.
+
+<programlisting>
+test=# UPDATE pgbench_accounts SET abalance = 1000 WHERE aid = 1;
+UPDATE 1
+Time: 13.068 ms
+</programlisting>
+
+</para>
+
+<para>
+    Appropriate indexes on <acronym>IMMV</acronym>s are necessary for
+    efficient <acronym>IVM</acronym> because it looks for tuples to be
+    updated in <acronym>IMMV</acronym>.  If there are no indexes, it
+    will take a long time.
+</para>
+
+<para>
+    Therefore, when <acronym>IMMV</acronym> is defined, a unique index is created on the view
+    automatically if possible.  If the view definition query has a GROUP BY clause, a unique
+    index is created on the columns of GROUP BY expressions.  Also, if the view has DISTINCT
+    clause, a unique index is created on all columns in the target list. Otherwise, if the
+    view contains all primary key attritubes of its base tables in the target list, a unique
+    index is created on these attritubes.  In other cases, no index is created.
+</para>
+
+<para>
+    In the previous example, a unique index "mv_ivm_index" is created on aid and bid
+    columns of materialized view "mv_ivm", and this enables the rapid update of the view.
+    Dropping this index make updating the view take a loger time.
+<programlisting>
+test=# DROP INDEX mv_ivm_index;
+DROP INDEX
+Time: 67.081 ms
+
+test=# UPDATE pgbench_accounts SET abalance = 1000 WHERE aid = 1;
+UPDATE 1
+Time: 16386.245 ms (00:16.386)
+</programlisting>
+
+</para>
+
+<para>
+    <acronym>IVM</acronym> is effective when we want to keep a materialized
+    view up-to-date and small fraction of a base table is modified
+    infrequently.  Due to the overhead of immediate maintenance, <acronym>IVM</acronym>
+    is not effective when a base table is modified frequently.  Also, when a
+    large part of a base table is modified or large data is inserted into a
+    base table, <acronym>IVM</acronym> is not effective and the cost of
+    maintenance can be larger than the <command>REFRESH MATERIALIZED VIEW</command>
+    command. In such situation, we can use <command>REFRESH MATERIALIZED VIEW</command>
+    and specify <literal>WITH NO DATA</literal> to disable immediate
+    maintenance before modifying a base table. After a base table modification,
+    execute the <command>REFRESH MATERIALIZED VIEW</command> (with <literal>WITH DATA</literal>)
+    command to refresh the view data and enable immediate maintenance.
+</para>
+
+</sect2>
+
+<sect2 id="rules-ivm-support">
+<title>Supported View Definitions and Restrictions</title>
+
+<para>
+    Currently, we can create <acronym>IMMV</acronym>s using inner joins, and some
+    aggregates. However, several restrictions apply to the definition of IMMV.
+</para>
+
+<sect3 id="rules-ivm-support-joins">
+<title>Joins</title>
+<para>
+    Inner joins including self-join are supported. Outer joins are not supported.
+</para>
+</sect3>
+
+<sect3 id="rules-ivm-support-aggregates">
+<title>Aggregates</title>
+<para>
+    Supported aggregate functions are <function>count</function>, <function>sum</function>,
+    <function>avg</function>, <function>min</function>, and <function>max</function>.
+    Currently, only built-in aggregate functions are supported and user defined
+    aggregates cannot be used.  When a base table is modified, the new aggregated
+    values are incrementally calculated using the old aggregated values and values
+    of related hidden columns stored in <acronym>IMMV</acronym>.
+</para>
+
+<para>
+     Note that for <function>min</function> or <function>max</function>, the new values
+     could be re-calculated from base tables with regard to the affected groups when a
+     tuple containing the current minimal or maximal values are deleted from a base table.
+     Therefore, it can takes a long time to update an <acronym>IMMV</acronym> containing
+     these functions.
+</para>
+
+<para>
+    Also note that using <function>sum</function> or <function>avg</function> on
+    <type>real</type> (<type>float4</type>) type or <type>double precision</type>
+    (<type>float8</type>) type in <acronym>IMMV</acronym> is unsafe. This is
+    because aggregated values in <acronym>IMMV</acronym> can become different from
+    results calculated from base tables due to the limited precision of these types.
+    To avoid this problem, use the <type>numeric</type> type instead.
+</para>
+
+    <sect4 id="rules-ivm-restrictions-aggregates">
+    <title>Restrictions on Aggregates</title>
+    <para>
+        There are the following restrictions:
+    <itemizedlist>
+        <listitem>
+        <para>
+            If we have a <literal>GROUP BY</literal> clause, expressions specified in
+           <literal>GROUP BY</literal> must appear in the target list.  This is
+           how tuples to be updated in the <acronym>IMMV</acronym> are identified.
+           These attributes are used as scan keys for searching tuples in the
+           <acronym>IMMV</acronym>, so indexes on them are required for efficient
+           <acronym>IVM</acronym>.
+        </para>
+        </listitem>
+
+        <listitem>
+        <para>
+            <literal>HAVING</literal> clause cannot be used.
+        </para>
+        </listitem>
+    </itemizedlist>
+    </para>
+    </sect4>
+</sect3>
+
+<sect3 id="rules-ivm-general-restricitons">
+<title>Other General Restrictions</title>
+<para>
+    There are other restrictions which generally apply to <acronym>IMMV</acronym>:
+    <itemizedlist>
+        <listitem>
+          <para>
+           Sub-queries cannot be used.
+          </para>
+        </listitem>
+
+        <listitem>
+          <para>
+           CTEs cannot be used.
+          </para>
+        </listitem>
+
+        <listitem>
+          <para>
+           Window functions cannot be used.
+          </para>
+        </listitem>
+
+        <listitem>
+          <para>
+            <acronym>IMMV</acronym>s must be based on simple base tables.  It's not
+               supported to create them on top of views, materialized views, foreign tables, inhe.
+          </para>
+        </listitem>
+
+        <listitem>
+          <para>
+            LIMIT and OFFSET clauses cannot be used.
+          </para>
+        </listitem>
+
+        <listitem>
+          <para>
+            <acronym>IMMV</acronym>s cannot contain system columns.
+          </para>
+        </listitem>
+
+        <listitem>
+          <para>
+            <acronym>IMMV</acronym>s cannot contain non-immutable functions.
+          </para>
+        </listitem>
+
+        <listitem>
+          <para>
+            UNION/INTERSECT/EXCEPT clauses cannnot be used.
+          </para>
+        </listitem>
+
+        <listitem>
+          <para>
+            DISTINCT ON clauses cannot be used.
+          </para>
+        </listitem>
+
+        <listitem>
+          <para>
+            TABLESAMPLE parameter cannot be used.
+          </para>
+        </listitem>
+
+        <listitem>
+          <para>
+            inheritance parent tables cannnot be used.
+          </para>
+        </listitem>
+
+        <listitem>
+          <para>
+            VALUES clause cannnot be used.
+          </para>
+        </listitem>
+
+        <listitem>
+          <para>
+            <literal>GROUPING SETS</literal> and <literal>FILTER</literal> clauses cannot be used.
+          </para>
+        </listitem>
+
+        <listitem>
+          <para>
+            FOR UPDATE/SHARE cannot be used.
+          </para>
+        </listitem>
+
+        <listitem>
+          <para>
+            targetlist cannot contain columns whose name start with <literal>__ivm_</literal>.
+          </para>
+        </listitem>
+
+        <listitem>
+          <para>
+            targetlist cannot contain expressions which contain an aggregate in it.
+          </para>
+        </listitem>
+
+        <listitem>
+          <para>
+              Logical replication is not supported, that is, even when a base table
+               at a publisher node is modified, <acronym>IMMV</acronym>s at subscriber
+               nodes are not updated.
+          </para>
+        </listitem>
+
+    </itemizedlist>
+</para>
+</sect3>
+
+</sect2>
+
+<sect2 id="rules-ivm-distinct">
+<title><literal>DISTINCT</literal></title>
+
+<para>
+    <productname>PostgreSQL</productname> supports <acronym>IMMV</acronym> with
+    <literal>DISTINCT</literal>.  For example, suppose a <acronym>IMMV</acronym>
+    defined with <literal>DISTINCT</literal> on a base table containing duplicate
+    tuples.  When tuples are deleted from the base table, a tuple in the view is
+    deleted if and only if the multiplicity of the tuple becomes zero.  Moreover,
+    when tuples are inserted into the base table, a tuple is inserted into the
+    view only if the same tuple doesn't already exist in it.
+</para>
+
+<para>
+    Physically, an <acronym>IMMV</acronym> defined with <literal>DISTINCT</literal>
+    contains tuples after eliminating duplicates, and the multiplicity of each tuple
+    is stored in a hidden column named <literal>__ivm_count__</literal>.
+</para>
+</sect2>
+
+<sect2 id="rules-ivm-concurrent-transactions">
+<title>Concurrent Transactions</title>
+<para>
+    Suppose an <acronym>IMMV</acronym> is defined on two base tables and each
+    table was modified in different a concurrent transaction simultaneously.
+    In the transaction which was committed first, <acronym>IMMV</acronym> can
+    be updated considering only the change which happened in this transaction.
+    On the other hand, in order to update the view correctly in the transaction
+    which was committed later, we need to know the changes occurred in
+    both transactions.  For this reason, <literal>ExclusiveLock</literal>
+    is held on an <acronym>IMMV</acronym> immediately after a base table is
+    modified in <literal>READ COMMITTED</literal> mode to make sure that
+    the <acronym>IMMV</acronym> is updated in the latter transaction after
+    the former transaction is committed.  In <literal>REPEATABLE READ</literal>
+    or <literal>SERIALIZABLE</literal> mode, an error is raised immediately
+    if lock acquisition fails because any changes which occurred in
+    other transactions are not be visible in these modes and
+    <acronym>IMMV</acronym> cannot be updated correctly in such situations.
+    However, as an exception if the view has only one base table and
+    <command>INSERT</command> is performed on the table,
+    the lock held on thew view is <literal>RowExclusiveLock</literal>.
+</para>
+</sect2>
+
+<sect2 id="rules-ivm-rls">
+<title>Row Level Security</title>
+<para>
+    If some base tables have row level security policy, rows that are not visible
+    to the materialized view's owner are excluded from the result.  In addition, such
+    rows are excluded as well when views are incrementally maintained.  However, if a
+    new policy is defined or policies are changed after the materialized view was created,
+    the new policy will not be applied to the view contents.  To apply the new policy,
+    you need to refresh materialized views.
+</para>
+</sect2>
+
+</sect1>
+
 <sect1 id="rules-update">
 <title>Rules on <command>INSERT</command>, <command>UPDATE</command>, and <command>DELETE</command></title>
 
diff --git a/doc/src/sgml/system-views.sgml b/doc/src/sgml/system-views.sgml
index 2b35c2f91b..5366f707eb 100644
--- a/doc/src/sgml/system-views.sgml
+++ b/doc/src/sgml/system-views.sgml
@@ -1787,6 +1787,15 @@ SELECT * FROM pg_locks pl LEFT JOIN pg_prepared_xacts ppx
       </para></entry>
      </row>
 
+     <row>
+      <entry role="catalog_table_entry"><para role="column_definition">
+       <structfield>isimmv</structfield> <type>bool</type>
+      </para>
+      <para>
+       True if materialized view is incrementally maintainable
+      </para></entry>
+     </row>
+
      <row>
       <entry role="catalog_table_entry"><para role="column_definition">
        <structfield>definition</structfield> <type>text</type>
-- 
2.25.1


--Multipart=_Mon__28_Aug_2023_11_52_52_+0900_hj6L5h176QaSGtg7--





^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-01-21 00:40  Jacob Champion <[email protected]>
  0 siblings, 2 replies; 101+ messages in thread

From: Jacob Champion @ 2025-01-21 00:40 UTC (permalink / raw)
  To: Daniel Gustafsson <[email protected]>; +Cc: Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

On Mon, Jan 20, 2025 at 2:10 PM Daniel Gustafsson <[email protected]> wrote:
> +  /*
> +   * The mechanism should have set up the necessary callbacks; all we
> +   * need to do is signal the caller.
> +   */
> +  *async = true;
> +  return STATUS_OK;
> Is it worth adding assertions here to ensure that everything has been set up
> properly to help when adding a new mechanism in the future?

Yeah, I think that'd be helpful.

> +   /* Done. Tear down the async implementation. */
> +   conn->cleanup_async_auth(conn);
> +   conn->cleanup_async_auth = NULL;
> +   Assert(conn->altsock == PGINVALID_SOCKET);
> In pqDropConnection() we set ->altsock to NULL

(I assume you mean PGINVALID_SOCKET?)

> just to be sure rather than
> assert that cleanup has done so.  Shouldn't we be consistent in the
> expectation and set to NULL here as well?

I'm not opposed; I just figured that the following code might be a bit
confusing:

    Assert(conn->altsock == PGINVALID_SOCKET);
    conn->altsock = PGINVALID_SOCKET;

But I can add a comment to the assignment to try to explain. I don't
know what the likelihood of landing code that trips that assertion is,
but an explicit assignment would at least stop problems from
cascading.

--Jacob






^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-01-21 00:43  Jacob Champion <[email protected]>
  parent: Jacob Champion <[email protected]>
  1 sibling, 0 replies; 101+ messages in thread

From: Jacob Champion @ 2025-01-21 00:43 UTC (permalink / raw)
  To: Daniel Gustafsson <[email protected]>; +Cc: Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

On Mon, Jan 20, 2025 at 4:40 PM Jacob Champion
<[email protected]> wrote:
> But I can add a comment to the assignment to try to explain. I don't
> know what the likelihood of landing code that trips that assertion is,
> but an explicit assignment would at least stop problems from
> cascading.

On second thought, I can just fail the connection if this happens.

--Jacob






^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-01-21 09:29  Daniel Gustafsson <[email protected]>
  parent: Jacob Champion <[email protected]>
  1 sibling, 1 reply; 101+ messages in thread

From: Daniel Gustafsson @ 2025-01-21 09:29 UTC (permalink / raw)
  To: Jacob Champion <[email protected]>; +Cc: Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

> On 21 Jan 2025, at 01:40, Jacob Champion <[email protected]> wrote:
> On Mon, Jan 20, 2025 at 2:10 PM Daniel Gustafsson <[email protected]> wrote:

>> +   /* Done. Tear down the async implementation. */
>> +   conn->cleanup_async_auth(conn);
>> +   conn->cleanup_async_auth = NULL;
>> +   Assert(conn->altsock == PGINVALID_SOCKET);
>> In pqDropConnection() we set ->altsock to NULL
> 
> (I assume you mean PGINVALID_SOCKET?)

Doh, yes.

>> just to be sure rather than
>> assert that cleanup has done so.  Shouldn't we be consistent in the
>> expectation and set to NULL here as well?
> 
> I'm not opposed; I just figured that the following code might be a bit
> confusing:
> 
>    Assert(conn->altsock == PGINVALID_SOCKET);
>    conn->altsock = PGINVALID_SOCKET;
> 
> But I can add a comment to the assignment to try to explain. I don't
> know what the likelihood of landing code that trips that assertion is,
> but an explicit assignment would at least stop problems from
> cascading.

It is weird, but stopping the escalation of a problem seems important.

> On 21 Jan 2025, at 01:43, Jacob Champion <[email protected]> wrote:
> 

> On second thought, I can just fail the connection if this happens.

Yeah, I think that's the best option here.

--
Daniel Gustafsson







^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-01-21 16:46  Jacob Champion <[email protected]>
  parent: Daniel Gustafsson <[email protected]>
  0 siblings, 1 reply; 101+ messages in thread

From: Jacob Champion @ 2025-01-21 16:46 UTC (permalink / raw)
  To: Daniel Gustafsson <[email protected]>; +Cc: Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

On Tue, Jan 21, 2025 at 1:29 AM Daniel Gustafsson <[email protected]> wrote:
> > On second thought, I can just fail the connection if this happens.
>
> Yeah, I think that's the best option here.

Done that way in v43.

--Jacob

1:  1f38ec8039b = 1:  5d474397364 Move PG_MAX_AUTH_TOKEN_LENGTH to libpq/auth.h
2:  9f87ffea1c7 = 2:  20452d21e0b require_auth: prepare for multiple SASL mechanisms
3:  bda684d19cc ! 3:  f0afefb80d6 libpq: handle asynchronous actions during SASL
    @@ src/interfaces/libpq/fe-auth.c: pg_SASL_init(PGconn *conn, int payloadlen)
     +		/*
     +		 * The mechanism should have set up the necessary callbacks; all we
     +		 * need to do is signal the caller.
    ++		 *
    ++		 * In non-assertion builds, this postcondition is enforced at time of
    ++		 * use in PQconnectPoll().
     +		 */
    ++		Assert(conn->async_auth);
    ++		Assert(conn->cleanup_async_auth);
    ++
     +		*async = true;
     +		return STATUS_OK;
     +	}
    @@ src/interfaces/libpq/fe-connect.c: keep_going:						/* We will come back to here
     +				if (!conn->async_auth || !conn->cleanup_async_auth)
     +				{
     +					/* programmer error; should not happen */
    -+					libpq_append_conn_error(conn, "async authentication has no handler");
    ++					libpq_append_conn_error(conn,
    ++											"internal error: async authentication has no handler");
     +					goto error_return;
     +				}
     +
    @@ src/interfaces/libpq/fe-connect.c: keep_going:						/* We will come back to here
     +					/* Done. Tear down the async implementation. */
     +					conn->cleanup_async_auth(conn);
     +					conn->cleanup_async_auth = NULL;
    -+					Assert(conn->altsock == PGINVALID_SOCKET);
    ++
    ++					/*
    ++					 * Cleanup must unset altsock, both as an indication that
    ++					 * it's been released, and to stop pqSocketCheck from
    ++					 * looking at the wrong socket after async auth is done.
    ++					 */
    ++					if (conn->altsock != PGINVALID_SOCKET)
    ++					{
    ++						Assert(false);
    ++						libpq_append_conn_error(conn,
    ++												"internal error: async cleanup did not release polling socket");
    ++						goto error_return;
    ++					}
     +
     +					/*
     +					 * Reenter the authentication exchange with the server. We
    @@ src/interfaces/libpq/fe-connect.c: keep_going:						/* We will come back to here
     +				 * Caller needs to poll some more. conn->async_auth() should
     +				 * have assigned an altsock to poll on.
     +				 */
    -+				Assert(conn->altsock != PGINVALID_SOCKET);
    ++				if (conn->altsock == PGINVALID_SOCKET)
    ++				{
    ++					Assert(false);
    ++					libpq_append_conn_error(conn,
    ++											"internal error: async authentication did not set a socket for polling");
    ++					goto error_return;
    ++				}
    ++
     +				return status;
     +			}
     +
4:  3dc6dd3433c = 4:  711ca3f1efc Add OAUTHBEARER SASL mechanism
5:  97e0a2aae26 = 5:  66ef3b4b687 XXX fix libcurl link error
6:  db0167009b9 = 6:  4df1bc59638 DO NOT MERGE: Add pytest suite for OAuth


Attachments:

  [text/plain] since-v42.diff.txt (2.8K, ../../CAOYmi+=K3BBRXwvJpt5D_4nK_EimCJBj7a42maACszsQC3=t9w@mail.gmail.com/2-since-v42.diff.txt)
  download | inline:
1:  1f38ec8039b = 1:  5d474397364 Move PG_MAX_AUTH_TOKEN_LENGTH to libpq/auth.h
2:  9f87ffea1c7 = 2:  20452d21e0b require_auth: prepare for multiple SASL mechanisms
3:  bda684d19cc ! 3:  f0afefb80d6 libpq: handle asynchronous actions during SASL
    @@ src/interfaces/libpq/fe-auth.c: pg_SASL_init(PGconn *conn, int payloadlen)
     +		/*
     +		 * The mechanism should have set up the necessary callbacks; all we
     +		 * need to do is signal the caller.
    ++		 *
    ++		 * In non-assertion builds, this postcondition is enforced at time of
    ++		 * use in PQconnectPoll().
     +		 */
    ++		Assert(conn->async_auth);
    ++		Assert(conn->cleanup_async_auth);
    ++
     +		*async = true;
     +		return STATUS_OK;
     +	}
    @@ src/interfaces/libpq/fe-connect.c: keep_going:						/* We will come back to here
     +				if (!conn->async_auth || !conn->cleanup_async_auth)
     +				{
     +					/* programmer error; should not happen */
    -+					libpq_append_conn_error(conn, "async authentication has no handler");
    ++					libpq_append_conn_error(conn,
    ++											"internal error: async authentication has no handler");
     +					goto error_return;
     +				}
     +
    @@ src/interfaces/libpq/fe-connect.c: keep_going:						/* We will come back to here
     +					/* Done. Tear down the async implementation. */
     +					conn->cleanup_async_auth(conn);
     +					conn->cleanup_async_auth = NULL;
    -+					Assert(conn->altsock == PGINVALID_SOCKET);
    ++
    ++					/*
    ++					 * Cleanup must unset altsock, both as an indication that
    ++					 * it's been released, and to stop pqSocketCheck from
    ++					 * looking at the wrong socket after async auth is done.
    ++					 */
    ++					if (conn->altsock != PGINVALID_SOCKET)
    ++					{
    ++						Assert(false);
    ++						libpq_append_conn_error(conn,
    ++												"internal error: async cleanup did not release polling socket");
    ++						goto error_return;
    ++					}
     +
     +					/*
     +					 * Reenter the authentication exchange with the server. We
    @@ src/interfaces/libpq/fe-connect.c: keep_going:						/* We will come back to here
     +				 * Caller needs to poll some more. conn->async_auth() should
     +				 * have assigned an altsock to poll on.
     +				 */
    -+				Assert(conn->altsock != PGINVALID_SOCKET);
    ++				if (conn->altsock == PGINVALID_SOCKET)
    ++				{
    ++					Assert(false);
    ++					libpq_append_conn_error(conn,
    ++											"internal error: async authentication did not set a socket for polling");
    ++					goto error_return;
    ++				}
    ++
     +				return status;
     +			}
     +
4:  3dc6dd3433c = 4:  711ca3f1efc Add OAUTHBEARER SASL mechanism
5:  97e0a2aae26 = 5:  66ef3b4b687 XXX fix libcurl link error
6:  db0167009b9 = 6:  4df1bc59638 DO NOT MERGE: Add pytest suite for OAuth

  [application/octet-stream] v43-0001-Move-PG_MAX_AUTH_TOKEN_LENGTH-to-libpq-auth.h.patch (3.1K, ../../CAOYmi+=K3BBRXwvJpt5D_4nK_EimCJBj7a42maACszsQC3=t9w@mail.gmail.com/3-v43-0001-Move-PG_MAX_AUTH_TOKEN_LENGTH-to-libpq-auth.h.patch)
  download | inline diff:
From 5d4743973640313f08883ad1ed88f6ed69054e56 Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Wed, 8 Jan 2025 09:30:05 -0800
Subject: [PATCH v43 1/6] Move PG_MAX_AUTH_TOKEN_LENGTH to libpq/auth.h

OAUTHBEARER would like to use this as a limit on Bearer token messages
coming from the client, so promote it to the header file.
---
 src/backend/libpq/auth.c | 16 ----------------
 src/include/libpq/auth.h | 16 ++++++++++++++++
 2 files changed, 16 insertions(+), 16 deletions(-)

diff --git a/src/backend/libpq/auth.c b/src/backend/libpq/auth.c
index 46facc275ef..d6ef32cc823 100644
--- a/src/backend/libpq/auth.c
+++ b/src/backend/libpq/auth.c
@@ -201,22 +201,6 @@ static int	CheckRADIUSAuth(Port *port);
 static int	PerformRadiusTransaction(const char *server, const char *secret, const char *portstr, const char *identifier, const char *user_name, const char *passwd);
 
 
-/*
- * Maximum accepted size of GSS and SSPI authentication tokens.
- * We also use this as a limit on ordinary password packet lengths.
- *
- * Kerberos tickets are usually quite small, but the TGTs issued by Windows
- * domain controllers include an authorization field known as the Privilege
- * Attribute Certificate (PAC), which contains the user's Windows permissions
- * (group memberships etc.). The PAC is copied into all tickets obtained on
- * the basis of this TGT (even those issued by Unix realms which the Windows
- * realm trusts), and can be several kB in size. The maximum token size
- * accepted by Windows systems is determined by the MaxAuthToken Windows
- * registry setting. Microsoft recommends that it is not set higher than
- * 65535 bytes, so that seems like a reasonable limit for us as well.
- */
-#define PG_MAX_AUTH_TOKEN_LENGTH	65535
-
 /*----------------------------------------------------------------
  * Global authentication functions
  *----------------------------------------------------------------
diff --git a/src/include/libpq/auth.h b/src/include/libpq/auth.h
index 9157dbe6092..902c5f6de32 100644
--- a/src/include/libpq/auth.h
+++ b/src/include/libpq/auth.h
@@ -16,6 +16,22 @@
 
 #include "libpq/libpq-be.h"
 
+/*
+ * Maximum accepted size of GSS and SSPI authentication tokens.
+ * We also use this as a limit on ordinary password packet lengths.
+ *
+ * Kerberos tickets are usually quite small, but the TGTs issued by Windows
+ * domain controllers include an authorization field known as the Privilege
+ * Attribute Certificate (PAC), which contains the user's Windows permissions
+ * (group memberships etc.). The PAC is copied into all tickets obtained on
+ * the basis of this TGT (even those issued by Unix realms which the Windows
+ * realm trusts), and can be several kB in size. The maximum token size
+ * accepted by Windows systems is determined by the MaxAuthToken Windows
+ * registry setting. Microsoft recommends that it is not set higher than
+ * 65535 bytes, so that seems like a reasonable limit for us as well.
+ */
+#define PG_MAX_AUTH_TOKEN_LENGTH	65535
+
 extern PGDLLIMPORT char *pg_krb_server_keyfile;
 extern PGDLLIMPORT bool pg_krb_caseins_users;
 extern PGDLLIMPORT bool pg_gss_accept_delegation;
-- 
2.34.1



  [application/octet-stream] v43-0002-require_auth-prepare-for-multiple-SASL-mechanism.patch (10.4K, ../../CAOYmi+=K3BBRXwvJpt5D_4nK_EimCJBj7a42maACszsQC3=t9w@mail.gmail.com/4-v43-0002-require_auth-prepare-for-multiple-SASL-mechanism.patch)
  download | inline diff:
From 20452d21e0bdced70b53d3d3afa753ea6bf96d84 Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Mon, 16 Dec 2024 13:57:14 -0800
Subject: [PATCH v43 2/6] require_auth: prepare for multiple SASL mechanisms

Prior to this patch, the require_auth implementation assumed that the
AuthenticationSASL protocol message was synonymous with SCRAM-SHA-256.
In preparation for the OAUTHBEARER SASL mechanism, split the
implementation into two tiers: the first checks the acceptable
AUTH_REQ_* codes, and the second checks acceptable mechanisms if
AUTH_REQ_SASL et al are permitted.

conn->allowed_sasl_mechs is the list of pointers to acceptable
mechanisms. (Since we'll support only a small number of mechanisms, this
is an array of static length to minimize bookkeeping.) pg_SASL_init()
will bail if the selected mechanism isn't contained in this array.

Since there's only one mechansism supported right now, one branch of the
second tier cannot be exercised yet (it's marked with Assert(false)).
This assertion will need to be removed when the next mechanism is added.
---
 src/interfaces/libpq/fe-auth.c            |  29 ++++
 src/interfaces/libpq/fe-connect.c         | 178 +++++++++++++++++++---
 src/interfaces/libpq/libpq-int.h          |   2 +
 src/test/authentication/t/001_password.pl |  10 ++
 4 files changed, 202 insertions(+), 17 deletions(-)

diff --git a/src/interfaces/libpq/fe-auth.c b/src/interfaces/libpq/fe-auth.c
index 7e478489b71..70753d8ec29 100644
--- a/src/interfaces/libpq/fe-auth.c
+++ b/src/interfaces/libpq/fe-auth.c
@@ -543,6 +543,35 @@ pg_SASL_init(PGconn *conn, int payloadlen)
 		goto error;
 	}
 
+	/* Make sure require_auth is satisfied. */
+	if (conn->require_auth)
+	{
+		bool		allowed = false;
+
+		for (int i = 0; i < lengthof(conn->allowed_sasl_mechs); i++)
+		{
+			if (conn->sasl == conn->allowed_sasl_mechs[i])
+			{
+				allowed = true;
+				break;
+			}
+		}
+
+		if (!allowed)
+		{
+			/*
+			 * TODO: this is dead code until a second SASL mechanism is added;
+			 * the connection can't have proceeded past check_expected_areq()
+			 * if no SASL methods are allowed.
+			 */
+			Assert(false);
+
+			libpq_append_conn_error(conn, "authentication method requirement \"%s\" failed: server requested %s authentication",
+									conn->require_auth, selected_mechanism);
+			goto error;
+		}
+	}
+
 	if (conn->channel_binding[0] == 'r' &&	/* require */
 		strcmp(selected_mechanism, SCRAM_SHA_256_PLUS_NAME) != 0)
 	{
diff --git a/src/interfaces/libpq/fe-connect.c b/src/interfaces/libpq/fe-connect.c
index 7878e2e33af..ccbcbb7acda 100644
--- a/src/interfaces/libpq/fe-connect.c
+++ b/src/interfaces/libpq/fe-connect.c
@@ -1117,6 +1117,56 @@ libpq_prng_init(PGconn *conn)
 	pg_prng_seed(&conn->prng_state, rseed);
 }
 
+/*
+ * Fills the connection's allowed_sasl_mechs list with all supported SASL
+ * mechanisms.
+ */
+static inline void
+fill_allowed_sasl_mechs(PGconn *conn)
+{
+	/*---
+	 * We only support one mechanism at the moment, so rather than deal with a
+	 * linked list, conn->allowed_sasl_mechs is an array of static length. We
+	 * rely on the compile-time assertion here to keep us honest.
+	 *
+	 * To add a new mechanism to require_auth,
+	 * - update the length of conn->allowed_sasl_mechs,
+	 * - add the new pg_fe_sasl_mech pointer to this function, and
+	 * - handle the new mechanism name in the require_auth portion of
+	 *   pqConnectOptions2(), below.
+	 */
+	StaticAssertDecl(lengthof(conn->allowed_sasl_mechs) == 1,
+					 "fill_allowed_sasl_mechs() must be updated when resizing conn->allowed_sasl_mechs[]");
+
+	conn->allowed_sasl_mechs[0] = &pg_scram_mech;
+}
+
+/*
+ * Clears the connection's allowed_sasl_mechs list.
+ */
+static inline void
+clear_allowed_sasl_mechs(PGconn *conn)
+{
+	for (int i = 0; i < lengthof(conn->allowed_sasl_mechs); i++)
+		conn->allowed_sasl_mechs[i] = NULL;
+}
+
+/*
+ * Helper routine that searches the static allowed_sasl_mechs list for a
+ * specific mechanism.
+ */
+static inline int
+index_of_allowed_sasl_mech(PGconn *conn, const pg_fe_sasl_mech *mech)
+{
+	for (int i = 0; i < lengthof(conn->allowed_sasl_mechs); i++)
+	{
+		if (conn->allowed_sasl_mechs[i] == mech)
+			return i;
+	}
+
+	return -1;
+}
+
 /*
  *		pqConnectOptions2
  *
@@ -1358,17 +1408,19 @@ pqConnectOptions2(PGconn *conn)
 		bool		negated = false;
 
 		/*
-		 * By default, start from an empty set of allowed options and add to
-		 * it.
+		 * By default, start from an empty set of allowed methods and
+		 * mechanisms, and add to it.
 		 */
 		conn->auth_required = true;
 		conn->allowed_auth_methods = 0;
+		clear_allowed_sasl_mechs(conn);
 
 		for (first = true, more = true; more; first = false)
 		{
 			char	   *method,
 					   *part;
-			uint32		bits;
+			uint32		bits = 0;
+			const pg_fe_sasl_mech *mech = NULL;
 
 			part = parse_comma_separated_list(&s, &more);
 			if (part == NULL)
@@ -1384,11 +1436,12 @@ pqConnectOptions2(PGconn *conn)
 				if (first)
 				{
 					/*
-					 * Switch to a permissive set of allowed options, and
-					 * subtract from it.
+					 * Switch to a permissive set of allowed methods and
+					 * mechanisms, and subtract from it.
 					 */
 					conn->auth_required = false;
 					conn->allowed_auth_methods = -1;
+					fill_allowed_sasl_mechs(conn);
 				}
 				else if (!negated)
 				{
@@ -1413,6 +1466,10 @@ pqConnectOptions2(PGconn *conn)
 				return false;
 			}
 
+			/*
+			 * First group: methods that can be handled solely with the
+			 * authentication request codes.
+			 */
 			if (strcmp(method, "password") == 0)
 			{
 				bits = (1 << AUTH_REQ_PASSWORD);
@@ -1431,13 +1488,22 @@ pqConnectOptions2(PGconn *conn)
 				bits = (1 << AUTH_REQ_SSPI);
 				bits |= (1 << AUTH_REQ_GSS_CONT);
 			}
+
+			/*
+			 * Next group: SASL mechanisms. All of these use the same request
+			 * codes, so the list of allowed mechanisms is tracked separately.
+			 *
+			 * fill_allowed_sasl_mechs() must be updated when adding a new
+			 * mechanism here!
+			 */
 			else if (strcmp(method, "scram-sha-256") == 0)
 			{
-				/* This currently assumes that SCRAM is the only SASL method. */
-				bits = (1 << AUTH_REQ_SASL);
-				bits |= (1 << AUTH_REQ_SASL_CONT);
-				bits |= (1 << AUTH_REQ_SASL_FIN);
+				mech = &pg_scram_mech;
 			}
+
+			/*
+			 * Final group: meta-options.
+			 */
 			else if (strcmp(method, "none") == 0)
 			{
 				/*
@@ -1473,20 +1539,68 @@ pqConnectOptions2(PGconn *conn)
 				return false;
 			}
 
-			/* Update the bitmask. */
-			if (negated)
+			if (mech)
 			{
-				if ((conn->allowed_auth_methods & bits) == 0)
-					goto duplicate;
+				/*
+				 * Update the mechanism set only. The method bitmask will be
+				 * updated for SASL further down.
+				 */
+				Assert(!bits);
+
+				if (negated)
+				{
+					/* Remove the existing mechanism from the list. */
+					i = index_of_allowed_sasl_mech(conn, mech);
+					if (i < 0)
+						goto duplicate;
 
-				conn->allowed_auth_methods &= ~bits;
+					conn->allowed_sasl_mechs[i] = NULL;
+				}
+				else
+				{
+					/*
+					 * Find a space to put the new mechanism (after making
+					 * sure it's not already there).
+					 */
+					i = index_of_allowed_sasl_mech(conn, mech);
+					if (i >= 0)
+						goto duplicate;
+
+					i = index_of_allowed_sasl_mech(conn, NULL);
+					if (i < 0)
+					{
+						/* Should not happen; the pointer list is corrupted. */
+						Assert(false);
+
+						conn->status = CONNECTION_BAD;
+						libpq_append_conn_error(conn,
+												"internal error: no space in allowed_sasl_mechs");
+						free(part);
+						return false;
+					}
+
+					conn->allowed_sasl_mechs[i] = mech;
+				}
 			}
 			else
 			{
-				if ((conn->allowed_auth_methods & bits) == bits)
-					goto duplicate;
+				/* Update the method bitmask. */
+				Assert(bits);
+
+				if (negated)
+				{
+					if ((conn->allowed_auth_methods & bits) == 0)
+						goto duplicate;
+
+					conn->allowed_auth_methods &= ~bits;
+				}
+				else
+				{
+					if ((conn->allowed_auth_methods & bits) == bits)
+						goto duplicate;
 
-				conn->allowed_auth_methods |= bits;
+					conn->allowed_auth_methods |= bits;
+				}
 			}
 
 			free(part);
@@ -1505,6 +1619,36 @@ pqConnectOptions2(PGconn *conn)
 			free(part);
 			return false;
 		}
+
+		/*
+		 * Finally, allow SASL authentication requests if (and only if) we've
+		 * allowed any mechanisms.
+		 */
+		{
+			bool		allowed = false;
+			const uint32 sasl_bits =
+				(1 << AUTH_REQ_SASL)
+				| (1 << AUTH_REQ_SASL_CONT)
+				| (1 << AUTH_REQ_SASL_FIN);
+
+			for (i = 0; i < lengthof(conn->allowed_sasl_mechs); i++)
+			{
+				if (conn->allowed_sasl_mechs[i])
+				{
+					allowed = true;
+					break;
+				}
+			}
+
+			/*
+			 * For the standard case, add the SASL bits to the (default-empty)
+			 * set if needed. For the negated case, remove them.
+			 */
+			if (!negated && allowed)
+				conn->allowed_auth_methods |= sasl_bits;
+			else if (negated && !allowed)
+				conn->allowed_auth_methods &= ~sasl_bits;
+		}
 	}
 
 	/*
diff --git a/src/interfaces/libpq/libpq-int.h b/src/interfaces/libpq/libpq-int.h
index 4be5fd7ae4f..e0d5b5fe0be 100644
--- a/src/interfaces/libpq/libpq-int.h
+++ b/src/interfaces/libpq/libpq-int.h
@@ -505,6 +505,8 @@ struct pg_conn
 								 * the server? */
 	uint32		allowed_auth_methods;	/* bitmask of acceptable AuthRequest
 										 * codes */
+	const pg_fe_sasl_mech *allowed_sasl_mechs[1];	/* and acceptable SASL
+													 * mechanisms */
 	bool		client_finished_auth;	/* have we finished our half of the
 										 * authentication exchange? */
 	char		current_auth_response;	/* used by pqTraceOutputMessage to
diff --git a/src/test/authentication/t/001_password.pl b/src/test/authentication/t/001_password.pl
index 773238b76fd..1357f806b6f 100644
--- a/src/test/authentication/t/001_password.pl
+++ b/src/test/authentication/t/001_password.pl
@@ -277,6 +277,16 @@ $node->connect_fails(
 	"require_auth methods cannot be duplicated, !none case",
 	expected_stderr =>
 	  qr/require_auth method "!none" is specified more than once/);
+$node->connect_fails(
+	"user=scram_role require_auth=scram-sha-256,scram-sha-256",
+	"require_auth methods cannot be duplicated, scram-sha-256 case",
+	expected_stderr =>
+	  qr/require_auth method "scram-sha-256" is specified more than once/);
+$node->connect_fails(
+	"user=scram_role require_auth=!scram-sha-256,!scram-sha-256",
+	"require_auth methods cannot be duplicated, !scram-sha-256 case",
+	expected_stderr =>
+	  qr/require_auth method "!scram-sha-256" is specified more than once/);
 
 # Unknown value defined in require_auth.
 $node->connect_fails(
-- 
2.34.1



  [application/octet-stream] v43-0003-libpq-handle-asynchronous-actions-during-SASL.patch (18.1K, ../../CAOYmi+=K3BBRXwvJpt5D_4nK_EimCJBj7a42maACszsQC3=t9w@mail.gmail.com/5-v43-0003-libpq-handle-asynchronous-actions-during-SASL.patch)
  download | inline diff:
From f0afefb80d6c2c7c0e23aa4e43585786c90b9c72 Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Wed, 8 Jan 2025 09:30:05 -0800
Subject: [PATCH v43 3/6] libpq: handle asynchronous actions during SASL

This adds the ability for a SASL mechanism to signal to PQconnectPoll()
that some arbitrary work must be done, external to the Postgres
connection, before authentication can continue. The intent is for the
upcoming OAUTHBEARER mechanism to make use of this functionality.

To ensure that threads are not blocked waiting for the SASL mechanism to
make long-running calls, the mechanism communicates with the top-level
client via the "altsock": a file or socket descriptor, opaque to this
layer of libpq, which is signaled when work is ready to be done again.
This socket temporarily takes the place of the standard connection
descriptor, so PQsocket() clients should continue to operate correctly
using their existing polling implementations.

A mechanism should set an authentication callback (conn->async_auth())
and a cleanup callback (conn->cleanup_async_auth()), return SASL_ASYNC
during the exchange, and assign conn->altsock during the first call to
async_auth(). When the cleanup callback is called, either because
authentication has succeeded or because the connection is being
dropped, the altsock must be released and disconnected from the PGconn.
---
 src/interfaces/libpq/fe-auth-sasl.h  |  11 ++-
 src/interfaces/libpq/fe-auth-scram.c |   6 +-
 src/interfaces/libpq/fe-auth.c       | 120 ++++++++++++++++++++-------
 src/interfaces/libpq/fe-auth.h       |   3 +-
 src/interfaces/libpq/fe-connect.c    |  93 ++++++++++++++++++++-
 src/interfaces/libpq/fe-misc.c       |  35 +++++---
 src/interfaces/libpq/libpq-fe.h      |   2 +
 src/interfaces/libpq/libpq-int.h     |   6 ++
 8 files changed, 227 insertions(+), 49 deletions(-)

diff --git a/src/interfaces/libpq/fe-auth-sasl.h b/src/interfaces/libpq/fe-auth-sasl.h
index f0c62139092..f06f547c07d 100644
--- a/src/interfaces/libpq/fe-auth-sasl.h
+++ b/src/interfaces/libpq/fe-auth-sasl.h
@@ -30,6 +30,7 @@ typedef enum
 	SASL_COMPLETE = 0,
 	SASL_FAILED,
 	SASL_CONTINUE,
+	SASL_ASYNC,
 } SASLStatus;
 
 /*
@@ -77,6 +78,8 @@ typedef struct pg_fe_sasl_mech
 	 *
 	 *	state:	   The opaque mechanism state returned by init()
 	 *
+	 *	final:	   true if the server has sent a final exchange outcome
+	 *
 	 *	input:	   The challenge data sent by the server, or NULL when
 	 *			   generating a client-first initial response (that is, when
 	 *			   the server expects the client to send a message to start
@@ -101,12 +104,18 @@ typedef struct pg_fe_sasl_mech
 	 *
 	 *	SASL_CONTINUE:	The output buffer is filled with a client response.
 	 *					Additional server challenge is expected
+	 *	SASL_ASYNC:		Some asynchronous processing external to the
+	 *					connection needs to be done before a response can be
+	 *					generated. The mechanism is responsible for setting up
+	 *					conn->async_auth/cleanup_async_auth appropriately
+	 *					before returning.
 	 *	SASL_COMPLETE:	The SASL exchange has completed successfully.
 	 *	SASL_FAILED:	The exchange has failed and the connection should be
 	 *					dropped.
 	 *--------
 	 */
-	SASLStatus	(*exchange) (void *state, char *input, int inputlen,
+	SASLStatus	(*exchange) (void *state, bool final,
+							 char *input, int inputlen,
 							 char **output, int *outputlen);
 
 	/*--------
diff --git a/src/interfaces/libpq/fe-auth-scram.c b/src/interfaces/libpq/fe-auth-scram.c
index 557e9c568b6..fe18615197f 100644
--- a/src/interfaces/libpq/fe-auth-scram.c
+++ b/src/interfaces/libpq/fe-auth-scram.c
@@ -24,7 +24,8 @@
 /* The exported SCRAM callback mechanism. */
 static void *scram_init(PGconn *conn, const char *password,
 						const char *sasl_mechanism);
-static SASLStatus scram_exchange(void *opaq, char *input, int inputlen,
+static SASLStatus scram_exchange(void *opaq, bool final,
+								 char *input, int inputlen,
 								 char **output, int *outputlen);
 static bool scram_channel_bound(void *opaq);
 static void scram_free(void *opaq);
@@ -205,7 +206,8 @@ scram_free(void *opaq)
  * Exchange a SCRAM message with backend.
  */
 static SASLStatus
-scram_exchange(void *opaq, char *input, int inputlen,
+scram_exchange(void *opaq, bool final,
+			   char *input, int inputlen,
 			   char **output, int *outputlen)
 {
 	fe_scram_state *state = (fe_scram_state *) opaq;
diff --git a/src/interfaces/libpq/fe-auth.c b/src/interfaces/libpq/fe-auth.c
index 70753d8ec29..761ee8f88f7 100644
--- a/src/interfaces/libpq/fe-auth.c
+++ b/src/interfaces/libpq/fe-auth.c
@@ -430,7 +430,7 @@ pg_SSPI_startup(PGconn *conn, int use_negotiate, int payloadlen)
  * Initialize SASL authentication exchange.
  */
 static int
-pg_SASL_init(PGconn *conn, int payloadlen)
+pg_SASL_init(PGconn *conn, int payloadlen, bool *async)
 {
 	char	   *initialresponse = NULL;
 	int			initialresponselen;
@@ -448,7 +448,7 @@ pg_SASL_init(PGconn *conn, int payloadlen)
 		goto error;
 	}
 
-	if (conn->sasl_state)
+	if (conn->sasl_state && !conn->async_auth)
 	{
 		libpq_append_conn_error(conn, "duplicate SASL authentication request");
 		goto error;
@@ -607,26 +607,54 @@ pg_SASL_init(PGconn *conn, int payloadlen)
 
 	Assert(conn->sasl);
 
-	/*
-	 * Initialize the SASL state information with all the information gathered
-	 * during the initial exchange.
-	 *
-	 * Note: Only tls-unique is supported for the moment.
-	 */
-	conn->sasl_state = conn->sasl->init(conn,
-										password,
-										selected_mechanism);
 	if (!conn->sasl_state)
-		goto oom_error;
+	{
+		/*
+		 * Initialize the SASL state information with all the information
+		 * gathered during the initial exchange.
+		 *
+		 * Note: Only tls-unique is supported for the moment.
+		 */
+		conn->sasl_state = conn->sasl->init(conn,
+											password,
+											selected_mechanism);
+		if (!conn->sasl_state)
+			goto oom_error;
+	}
+	else
+	{
+		/*
+		 * This is only possible if we're returning from an async loop.
+		 * Disconnect it now.
+		 */
+		Assert(conn->async_auth);
+		conn->async_auth = NULL;
+	}
 
 	/* Get the mechanism-specific Initial Client Response, if any */
-	status = conn->sasl->exchange(conn->sasl_state,
+	status = conn->sasl->exchange(conn->sasl_state, false,
 								  NULL, -1,
 								  &initialresponse, &initialresponselen);
 
 	if (status == SASL_FAILED)
 		goto error;
 
+	if (status == SASL_ASYNC)
+	{
+		/*
+		 * The mechanism should have set up the necessary callbacks; all we
+		 * need to do is signal the caller.
+		 *
+		 * In non-assertion builds, this postcondition is enforced at time of
+		 * use in PQconnectPoll().
+		 */
+		Assert(conn->async_auth);
+		Assert(conn->cleanup_async_auth);
+
+		*async = true;
+		return STATUS_OK;
+	}
+
 	/*
 	 * Build a SASLInitialResponse message, and send it.
 	 */
@@ -671,7 +699,7 @@ oom_error:
  * the protocol.
  */
 static int
-pg_SASL_continue(PGconn *conn, int payloadlen, bool final)
+pg_SASL_continue(PGconn *conn, int payloadlen, bool final, bool *async)
 {
 	char	   *output;
 	int			outputlen;
@@ -701,11 +729,25 @@ pg_SASL_continue(PGconn *conn, int payloadlen, bool final)
 	/* For safety and convenience, ensure the buffer is NULL-terminated. */
 	challenge[payloadlen] = '\0';
 
-	status = conn->sasl->exchange(conn->sasl_state,
+	status = conn->sasl->exchange(conn->sasl_state, final,
 								  challenge, payloadlen,
 								  &output, &outputlen);
 	free(challenge);			/* don't need the input anymore */
 
+	if (status == SASL_ASYNC)
+	{
+		/*
+		 * The mechanism should have set up the necessary callbacks; all we
+		 * need to do is signal the caller.
+		 */
+		*async = true;
+
+		/*
+		 * The mechanism may optionally generate some output to send before
+		 * switching over to async auth, so continue onwards.
+		 */
+	}
+
 	if (final && status == SASL_CONTINUE)
 	{
 		if (outputlen != 0)
@@ -1013,12 +1055,18 @@ check_expected_areq(AuthRequest areq, PGconn *conn)
  * it. We are responsible for reading any remaining extra data, specific
  * to the authentication method. 'payloadlen' is the remaining length in
  * the message.
+ *
+ * If *async is set to true on return, the client doesn't yet have enough
+ * information to respond, and the caller must temporarily switch to
+ * conn->async_auth() to continue driving the exchange.
  */
 int
-pg_fe_sendauth(AuthRequest areq, int payloadlen, PGconn *conn)
+pg_fe_sendauth(AuthRequest areq, int payloadlen, PGconn *conn, bool *async)
 {
 	int			oldmsglen;
 
+	*async = false;
+
 	if (!check_expected_areq(areq, conn))
 		return STATUS_ERROR;
 
@@ -1176,7 +1224,7 @@ pg_fe_sendauth(AuthRequest areq, int payloadlen, PGconn *conn)
 			 * The request contains the name (as assigned by IANA) of the
 			 * authentication mechanism.
 			 */
-			if (pg_SASL_init(conn, payloadlen) != STATUS_OK)
+			if (pg_SASL_init(conn, payloadlen, async) != STATUS_OK)
 			{
 				/* pg_SASL_init already set the error message */
 				return STATUS_ERROR;
@@ -1185,23 +1233,33 @@ pg_fe_sendauth(AuthRequest areq, int payloadlen, PGconn *conn)
 
 		case AUTH_REQ_SASL_CONT:
 		case AUTH_REQ_SASL_FIN:
-			if (conn->sasl_state == NULL)
 			{
-				appendPQExpBufferStr(&conn->errorMessage,
-									 "fe_sendauth: invalid authentication request from server: AUTH_REQ_SASL_CONT without AUTH_REQ_SASL\n");
-				return STATUS_ERROR;
-			}
-			oldmsglen = conn->errorMessage.len;
-			if (pg_SASL_continue(conn, payloadlen,
-								 (areq == AUTH_REQ_SASL_FIN)) != STATUS_OK)
-			{
-				/* Use this message if pg_SASL_continue didn't supply one */
-				if (conn->errorMessage.len == oldmsglen)
+				bool		final = false;
+
+				if (conn->sasl_state == NULL)
+				{
 					appendPQExpBufferStr(&conn->errorMessage,
-										 "fe_sendauth: error in SASL authentication\n");
-				return STATUS_ERROR;
+										 "fe_sendauth: invalid authentication request from server: AUTH_REQ_SASL_CONT without AUTH_REQ_SASL\n");
+					return STATUS_ERROR;
+				}
+				oldmsglen = conn->errorMessage.len;
+
+				if (areq == AUTH_REQ_SASL_FIN)
+					final = true;
+
+				if (pg_SASL_continue(conn, payloadlen, final, async) != STATUS_OK)
+				{
+					/*
+					 * Append a generic error message unless pg_SASL_continue
+					 * did set a more specific one already.
+					 */
+					if (conn->errorMessage.len == oldmsglen)
+						appendPQExpBufferStr(&conn->errorMessage,
+											 "fe_sendauth: error in SASL authentication\n");
+					return STATUS_ERROR;
+				}
+				break;
 			}
-			break;
 
 		default:
 			libpq_append_conn_error(conn, "authentication method %u not supported", areq);
diff --git a/src/interfaces/libpq/fe-auth.h b/src/interfaces/libpq/fe-auth.h
index df0a68b0b21..1d4991f8996 100644
--- a/src/interfaces/libpq/fe-auth.h
+++ b/src/interfaces/libpq/fe-auth.h
@@ -19,7 +19,8 @@
 
 
 /* Prototypes for functions in fe-auth.c */
-extern int	pg_fe_sendauth(AuthRequest areq, int payloadlen, PGconn *conn);
+extern int	pg_fe_sendauth(AuthRequest areq, int payloadlen, PGconn *conn,
+						   bool *async);
 extern char *pg_fe_getusername(uid_t user_id, PQExpBuffer errorMessage);
 extern char *pg_fe_getauthname(PQExpBuffer errorMessage);
 
diff --git a/src/interfaces/libpq/fe-connect.c b/src/interfaces/libpq/fe-connect.c
index ccbcbb7acda..85ebf9f6d87 100644
--- a/src/interfaces/libpq/fe-connect.c
+++ b/src/interfaces/libpq/fe-connect.c
@@ -501,6 +501,19 @@ pqDropConnection(PGconn *conn, bool flushInput)
 	conn->cmd_queue_recycle = NULL;
 
 	/* Free authentication/encryption state */
+	if (conn->cleanup_async_auth)
+	{
+		/*
+		 * Any in-progress async authentication should be torn down first so
+		 * that cleanup_async_auth() can depend on the other authentication
+		 * state if necessary.
+		 */
+		conn->cleanup_async_auth(conn);
+		conn->cleanup_async_auth = NULL;
+	}
+	conn->async_auth = NULL;
+	conn->altsock = PGINVALID_SOCKET;	/* cleanup_async_auth() should have
+										 * done this, but make sure. */
 #ifdef ENABLE_GSS
 	{
 		OM_uint32	min_s;
@@ -2847,6 +2860,7 @@ PQconnectPoll(PGconn *conn)
 		case CONNECTION_NEEDED:
 		case CONNECTION_GSS_STARTUP:
 		case CONNECTION_CHECK_TARGET:
+		case CONNECTION_AUTHENTICATING:
 			break;
 
 		default:
@@ -3882,6 +3896,7 @@ keep_going:						/* We will come back to here until there is
 				int			avail;
 				AuthRequest areq;
 				int			res;
+				bool		async;
 
 				/*
 				 * Scan the message from current point (note that if we find
@@ -4070,7 +4085,17 @@ keep_going:						/* We will come back to here until there is
 				 * Note that conn->pghost must be non-NULL if we are going to
 				 * avoid the Kerberos code doing a hostname look-up.
 				 */
-				res = pg_fe_sendauth(areq, msgLength, conn);
+				res = pg_fe_sendauth(areq, msgLength, conn, &async);
+
+				if (async && (res == STATUS_OK))
+				{
+					/*
+					 * We'll come back later once we're ready to respond.
+					 * Don't consume the request yet.
+					 */
+					conn->status = CONNECTION_AUTHENTICATING;
+					goto keep_going;
+				}
 
 				/*
 				 * OK, we have processed the message; mark data consumed.  We
@@ -4107,6 +4132,69 @@ keep_going:						/* We will come back to here until there is
 				goto keep_going;
 			}
 
+		case CONNECTION_AUTHENTICATING:
+			{
+				PostgresPollingStatusType status;
+
+				if (!conn->async_auth || !conn->cleanup_async_auth)
+				{
+					/* programmer error; should not happen */
+					libpq_append_conn_error(conn,
+											"internal error: async authentication has no handler");
+					goto error_return;
+				}
+
+				/* Drive some external authentication work. */
+				status = conn->async_auth(conn);
+
+				if (status == PGRES_POLLING_FAILED)
+					goto error_return;
+
+				if (status == PGRES_POLLING_OK)
+				{
+					/* Done. Tear down the async implementation. */
+					conn->cleanup_async_auth(conn);
+					conn->cleanup_async_auth = NULL;
+
+					/*
+					 * Cleanup must unset altsock, both as an indication that
+					 * it's been released, and to stop pqSocketCheck from
+					 * looking at the wrong socket after async auth is done.
+					 */
+					if (conn->altsock != PGINVALID_SOCKET)
+					{
+						Assert(false);
+						libpq_append_conn_error(conn,
+												"internal error: async cleanup did not release polling socket");
+						goto error_return;
+					}
+
+					/*
+					 * Reenter the authentication exchange with the server. We
+					 * didn't consume the message that started external
+					 * authentication, so it'll be reprocessed as if we just
+					 * received it.
+					 */
+					conn->status = CONNECTION_AWAITING_RESPONSE;
+
+					goto keep_going;
+				}
+
+				/*
+				 * Caller needs to poll some more. conn->async_auth() should
+				 * have assigned an altsock to poll on.
+				 */
+				if (conn->altsock == PGINVALID_SOCKET)
+				{
+					Assert(false);
+					libpq_append_conn_error(conn,
+											"internal error: async authentication did not set a socket for polling");
+					goto error_return;
+				}
+
+				return status;
+			}
+
 		case CONNECTION_AUTH_OK:
 			{
 				/*
@@ -4788,6 +4876,7 @@ pqMakeEmptyPGconn(void)
 	conn->verbosity = PQERRORS_DEFAULT;
 	conn->show_context = PQSHOW_CONTEXT_ERRORS;
 	conn->sock = PGINVALID_SOCKET;
+	conn->altsock = PGINVALID_SOCKET;
 	conn->Pfdebug = NULL;
 
 	/*
@@ -7439,6 +7528,8 @@ PQsocket(const PGconn *conn)
 {
 	if (!conn)
 		return -1;
+	if (conn->altsock != PGINVALID_SOCKET)
+		return conn->altsock;
 	return (conn->sock != PGINVALID_SOCKET) ? conn->sock : -1;
 }
 
diff --git a/src/interfaces/libpq/fe-misc.c b/src/interfaces/libpq/fe-misc.c
index 2c60eb5b569..d78445c70af 100644
--- a/src/interfaces/libpq/fe-misc.c
+++ b/src/interfaces/libpq/fe-misc.c
@@ -1049,34 +1049,43 @@ pqWriteReady(PGconn *conn)
  * or both.  Returns >0 if one or more conditions are met, 0 if it timed
  * out, -1 if an error occurred.
  *
- * If SSL is in use, the SSL buffer is checked prior to checking the socket
- * for read data directly.
+ * If an altsock is set for asynchronous authentication, that will be used in
+ * preference to the "server" socket. Otherwise, if SSL is in use, the SSL
+ * buffer is checked prior to checking the socket for read data directly.
  */
 static int
 pqSocketCheck(PGconn *conn, int forRead, int forWrite, pg_usec_time_t end_time)
 {
 	int			result;
+	pgsocket	sock;
 
 	if (!conn)
 		return -1;
-	if (conn->sock == PGINVALID_SOCKET)
+
+	if (conn->altsock != PGINVALID_SOCKET)
+		sock = conn->altsock;
+	else
 	{
-		libpq_append_conn_error(conn, "invalid socket");
-		return -1;
-	}
+		sock = conn->sock;
+		if (sock == PGINVALID_SOCKET)
+		{
+			libpq_append_conn_error(conn, "invalid socket");
+			return -1;
+		}
 
 #ifdef USE_SSL
-	/* Check for SSL library buffering read bytes */
-	if (forRead && conn->ssl_in_use && pgtls_read_pending(conn))
-	{
-		/* short-circuit the select */
-		return 1;
-	}
+		/* Check for SSL library buffering read bytes */
+		if (forRead && conn->ssl_in_use && pgtls_read_pending(conn))
+		{
+			/* short-circuit the select */
+			return 1;
+		}
 #endif
+	}
 
 	/* We will retry as long as we get EINTR */
 	do
-		result = PQsocketPoll(conn->sock, forRead, forWrite, end_time);
+		result = PQsocketPoll(sock, forRead, forWrite, end_time);
 	while (result < 0 && SOCK_ERRNO == EINTR);
 
 	if (result < 0)
diff --git a/src/interfaces/libpq/libpq-fe.h b/src/interfaces/libpq/libpq-fe.h
index cce9ce60c55..a3491faf0c3 100644
--- a/src/interfaces/libpq/libpq-fe.h
+++ b/src/interfaces/libpq/libpq-fe.h
@@ -103,6 +103,8 @@ typedef enum
 	CONNECTION_CHECK_STANDBY,	/* Checking if server is in standby mode. */
 	CONNECTION_ALLOCATED,		/* Waiting for connection attempt to be
 								 * started.  */
+	CONNECTION_AUTHENTICATING,	/* Authentication is in progress with some
+								 * external system. */
 } ConnStatusType;
 
 typedef enum
diff --git a/src/interfaces/libpq/libpq-int.h b/src/interfaces/libpq/libpq-int.h
index e0d5b5fe0be..2546f9f8a50 100644
--- a/src/interfaces/libpq/libpq-int.h
+++ b/src/interfaces/libpq/libpq-int.h
@@ -513,6 +513,12 @@ struct pg_conn
 										 * know which auth response we're
 										 * sending */
 
+	/* Callbacks for external async authentication */
+	PostgresPollingStatusType (*async_auth) (PGconn *conn);
+	void		(*cleanup_async_auth) (PGconn *conn);
+	pgsocket	altsock;		/* alternative socket for client to poll */
+
+
 	/* Transient state needed while establishing connection */
 	PGTargetServerType target_server_type;	/* desired session properties */
 	PGLoadBalanceType load_balance_type;	/* desired load balancing
-- 
2.34.1



  [application/octet-stream] v43-0004-Add-OAUTHBEARER-SASL-mechanism.patch (284.1K, ../../CAOYmi+=K3BBRXwvJpt5D_4nK_EimCJBj7a42maACszsQC3=t9w@mail.gmail.com/6-v43-0004-Add-OAUTHBEARER-SASL-mechanism.patch)
  download | inline diff:
From 711ca3f1efc00e22b6155d1592f214098fa925e2 Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Wed, 23 Oct 2024 09:37:33 -0700
Subject: [PATCH v43 4/6] Add OAUTHBEARER SASL mechanism

DO NOT USE THIS PROOF OF CONCEPT IN PRODUCTION.

Implement OAUTHBEARER (RFC 7628) and OAuth 2.0 Device Authorization
Grants (RFC 8628). This adds a new auth method, oauth, to pg_hba. When
speaking to a OAuth-enabled server, it looks a bit like this:

    $ psql 'host=example.org oauth_issuer=... oauth_client_id=...'
    Visit https://oauth.example.org/login and enter the code: FPQ2-M4BG

The OAuth issuer must support device authorization. No other OAuth flows
are currently implemented (but clients may provide their own flows).

The client implementation requires libcurl and its development headers.
Pass --with-libcurl/-Dlibcurl=enabled during configuration. The server
implementation does not require additional build-time dependencies, but
an external validator module must be supplied.

Thomas Munro wrote the kqueue() implementation for oauth-curl; thanks!

Several TODOs:
- perform several sanity checks on the OAuth issuer's responses
- improve error debuggability during the OAuth handshake
- fix libcurl initialization thread-safety
- harden the libcurl flow implementation
- fill in documentation stubs
- support protocol "variants" implemented by major providers
- implement more helpful handling of HBA misconfigurations
- use logdetail during auth failures
- ...and more.

Co-authored-by: Daniel Gustafsson <[email protected]>
---
 .cirrus.tasks.yml                             |   15 +-
 configure                                     |  213 ++
 configure.ac                                  |   32 +
 doc/src/sgml/client-auth.sgml                 |  252 ++
 doc/src/sgml/config.sgml                      |   26 +
 doc/src/sgml/filelist.sgml                    |    1 +
 doc/src/sgml/installation.sgml                |   27 +
 doc/src/sgml/libpq.sgml                       |  381 +++
 doc/src/sgml/oauth-validators.sgml            |  402 +++
 doc/src/sgml/postgres.sgml                    |    1 +
 doc/src/sgml/regress.sgml                     |   10 +
 meson.build                                   |   23 +
 meson_options.txt                             |    3 +
 src/Makefile.global.in                        |    1 +
 src/backend/libpq/Makefile                    |    1 +
 src/backend/libpq/auth-oauth.c                |  860 ++++++
 src/backend/libpq/auth.c                      |   10 +-
 src/backend/libpq/hba.c                       |   64 +-
 src/backend/libpq/meson.build                 |    1 +
 src/backend/libpq/pg_hba.conf.sample          |    4 +-
 src/backend/utils/adt/hbafuncs.c              |   19 +
 src/backend/utils/misc/guc_tables.c           |   12 +
 src/backend/utils/misc/postgresql.conf.sample |    3 +
 src/include/common/oauth-common.h             |   19 +
 src/include/libpq/auth.h                      |    1 +
 src/include/libpq/hba.h                       |    7 +-
 src/include/libpq/oauth.h                     |   54 +
 src/include/pg_config.h.in                    |    6 +
 src/interfaces/libpq/Makefile                 |   11 +-
 src/interfaces/libpq/exports.txt              |    3 +
 src/interfaces/libpq/fe-auth-oauth-curl.c     | 2541 +++++++++++++++++
 src/interfaces/libpq/fe-auth-oauth.c          | 1141 ++++++++
 src/interfaces/libpq/fe-auth-oauth.h          |   45 +
 src/interfaces/libpq/fe-auth.c                |   36 +-
 src/interfaces/libpq/fe-auth.h                |    3 +
 src/interfaces/libpq/fe-connect.c             |   50 +-
 src/interfaces/libpq/libpq-fe.h               |   82 +
 src/interfaces/libpq/libpq-int.h              |   13 +-
 src/interfaces/libpq/meson.build              |    5 +
 src/makefiles/meson.build                     |    1 +
 src/test/authentication/t/001_password.pl     |    8 +-
 src/test/modules/Makefile                     |    1 +
 src/test/modules/meson.build                  |    1 +
 src/test/modules/oauth_validator/.gitignore   |    4 +
 src/test/modules/oauth_validator/Makefile     |   40 +
 src/test/modules/oauth_validator/README       |   13 +
 .../modules/oauth_validator/fail_validator.c  |   42 +
 src/test/modules/oauth_validator/meson.build  |   69 +
 .../oauth_validator/oauth_hook_client.c       |  264 ++
 .../modules/oauth_validator/t/001_server.pl   |  551 ++++
 .../modules/oauth_validator/t/002_client.pl   |  154 +
 .../modules/oauth_validator/t/OAuth/Server.pm |  140 +
 .../modules/oauth_validator/t/oauth_server.py |  391 +++
 src/test/modules/oauth_validator/validator.c  |  135 +
 src/test/perl/PostgreSQL/Test/Cluster.pm      |   20 +-
 src/tools/pgindent/pgindent                   |   14 +
 src/tools/pgindent/typedefs.list              |   11 +
 57 files changed, 8206 insertions(+), 31 deletions(-)
 create mode 100644 doc/src/sgml/oauth-validators.sgml
 create mode 100644 src/backend/libpq/auth-oauth.c
 create mode 100644 src/include/common/oauth-common.h
 create mode 100644 src/include/libpq/oauth.h
 create mode 100644 src/interfaces/libpq/fe-auth-oauth-curl.c
 create mode 100644 src/interfaces/libpq/fe-auth-oauth.c
 create mode 100644 src/interfaces/libpq/fe-auth-oauth.h
 create mode 100644 src/test/modules/oauth_validator/.gitignore
 create mode 100644 src/test/modules/oauth_validator/Makefile
 create mode 100644 src/test/modules/oauth_validator/README
 create mode 100644 src/test/modules/oauth_validator/fail_validator.c
 create mode 100644 src/test/modules/oauth_validator/meson.build
 create mode 100644 src/test/modules/oauth_validator/oauth_hook_client.c
 create mode 100644 src/test/modules/oauth_validator/t/001_server.pl
 create mode 100644 src/test/modules/oauth_validator/t/002_client.pl
 create mode 100644 src/test/modules/oauth_validator/t/OAuth/Server.pm
 create mode 100755 src/test/modules/oauth_validator/t/oauth_server.py
 create mode 100644 src/test/modules/oauth_validator/validator.c

diff --git a/.cirrus.tasks.yml b/.cirrus.tasks.yml
index 18e944ca89d..8c518c317e7 100644
--- a/.cirrus.tasks.yml
+++ b/.cirrus.tasks.yml
@@ -20,7 +20,7 @@ env:
   MTEST_ARGS: --print-errorlogs --no-rebuild -C build
   PGCTLTIMEOUT: 120 # avoids spurious failures during parallel tests
   TEMP_CONFIG: ${CIRRUS_WORKING_DIR}/src/tools/ci/pg_ci_base.conf
-  PG_TEST_EXTRA: kerberos ldap ssl libpq_encryption load_balance
+  PG_TEST_EXTRA: kerberos ldap ssl libpq_encryption load_balance oauth
 
 
 # What files to preserve in case tests fail
@@ -164,7 +164,7 @@ task:
     chown root:postgres /tmp/cores
     sysctl kern.corefile='/tmp/cores/%N.%P.core'
   setup_additional_packages_script: |
-    #pkg install -y ...
+    pkg install -y curl
 
   # NB: Intentionally build without -Dllvm. The freebsd image size is already
   # large enough to make VM startup slow, and even without llvm freebsd
@@ -219,6 +219,7 @@ LINUX_CONFIGURE_FEATURES: &LINUX_CONFIGURE_FEATURES >-
   --with-gssapi
   --with-icu
   --with-ldap
+  --with-libcurl
   --with-libxml
   --with-libxslt
   --with-llvm
@@ -312,8 +313,10 @@ task:
     EOF
 
   setup_additional_packages_script: |
-    #apt-get update
-    #DEBIAN_FRONTEND=noninteractive apt-get -y install ...
+    apt-get update
+    DEBIAN_FRONTEND=noninteractive apt-get -y install \
+      libcurl4-openssl-dev \
+      libcurl4-openssl-dev:i386 \
 
   matrix:
     - name: Linux - Debian Bookworm - Autoconf
@@ -689,8 +692,8 @@ task:
     folder: $CCACHE_DIR
 
   setup_additional_packages_script: |
-    #apt-get update
-    #DEBIAN_FRONTEND=noninteractive apt-get -y install ...
+    apt-get update
+    DEBIAN_FRONTEND=noninteractive apt-get -y install libcurl4-openssl-dev
 
   ###
   # Test that code can be built with gcc/clang without warnings
diff --git a/configure b/configure
index ceeef9b0915..0686496d0d2 100755
--- a/configure
+++ b/configure
@@ -708,6 +708,9 @@ XML2_LIBS
 XML2_CFLAGS
 XML2_CONFIG
 with_libxml
+LIBCURL_LIBS
+LIBCURL_CFLAGS
+with_libcurl
 with_uuid
 with_readline
 with_systemd
@@ -864,6 +867,7 @@ with_readline
 with_libedit_preferred
 with_uuid
 with_ossp_uuid
+with_libcurl
 with_libxml
 with_libxslt
 with_system_tzdata
@@ -894,6 +898,8 @@ PKG_CONFIG_PATH
 PKG_CONFIG_LIBDIR
 ICU_CFLAGS
 ICU_LIBS
+LIBCURL_CFLAGS
+LIBCURL_LIBS
 XML2_CONFIG
 XML2_CFLAGS
 XML2_LIBS
@@ -1574,6 +1580,7 @@ Optional Packages:
                           prefer BSD Libedit over GNU Readline
   --with-uuid=LIB         build contrib/uuid-ossp using LIB (bsd,e2fs,ossp)
   --with-ossp-uuid        obsolete spelling of --with-uuid=ossp
+  --with-libcurl          build with libcurl support
   --with-libxml           build with XML support
   --with-libxslt          use XSLT support when building contrib/xml2
   --with-system-tzdata=DIR
@@ -1607,6 +1614,10 @@ Some influential environment variables:
               path overriding pkg-config's built-in search path
   ICU_CFLAGS  C compiler flags for ICU, overriding pkg-config
   ICU_LIBS    linker flags for ICU, overriding pkg-config
+  LIBCURL_CFLAGS
+              C compiler flags for LIBCURL, overriding pkg-config
+  LIBCURL_LIBS
+              linker flags for LIBCURL, overriding pkg-config
   XML2_CONFIG path to xml2-config utility
   XML2_CFLAGS C compiler flags for XML2, overriding pkg-config
   XML2_LIBS   linker flags for XML2, overriding pkg-config
@@ -8762,6 +8773,144 @@ fi
 
 
 
+#
+# libcurl
+#
+{ $as_echo "$as_me:${as_lineno-$LINENO}: checking whether to build with libcurl support" >&5
+$as_echo_n "checking whether to build with libcurl support... " >&6; }
+
+
+
+# Check whether --with-libcurl was given.
+if test "${with_libcurl+set}" = set; then :
+  withval=$with_libcurl;
+  case $withval in
+    yes)
+
+$as_echo "#define USE_LIBCURL 1" >>confdefs.h
+
+      ;;
+    no)
+      :
+      ;;
+    *)
+      as_fn_error $? "no argument expected for --with-libcurl option" "$LINENO" 5
+      ;;
+  esac
+
+else
+  with_libcurl=no
+
+fi
+
+
+{ $as_echo "$as_me:${as_lineno-$LINENO}: result: $with_libcurl" >&5
+$as_echo "$with_libcurl" >&6; }
+
+
+if test "$with_libcurl" = yes ; then
+  # Check for libcurl 7.61.0 or higher (corresponding to RHEL8 and the ability
+  # to explicitly set TLS 1.3 ciphersuites).
+
+pkg_failed=no
+{ $as_echo "$as_me:${as_lineno-$LINENO}: checking for libcurl >= 7.61.0" >&5
+$as_echo_n "checking for libcurl >= 7.61.0... " >&6; }
+
+if test -n "$LIBCURL_CFLAGS"; then
+    pkg_cv_LIBCURL_CFLAGS="$LIBCURL_CFLAGS"
+ elif test -n "$PKG_CONFIG"; then
+    if test -n "$PKG_CONFIG" && \
+    { { $as_echo "$as_me:${as_lineno-$LINENO}: \$PKG_CONFIG --exists --print-errors \"libcurl >= 7.61.0\""; } >&5
+  ($PKG_CONFIG --exists --print-errors "libcurl >= 7.61.0") 2>&5
+  ac_status=$?
+  $as_echo "$as_me:${as_lineno-$LINENO}: \$? = $ac_status" >&5
+  test $ac_status = 0; }; then
+  pkg_cv_LIBCURL_CFLAGS=`$PKG_CONFIG --cflags "libcurl >= 7.61.0" 2>/dev/null`
+		      test "x$?" != "x0" && pkg_failed=yes
+else
+  pkg_failed=yes
+fi
+ else
+    pkg_failed=untried
+fi
+if test -n "$LIBCURL_LIBS"; then
+    pkg_cv_LIBCURL_LIBS="$LIBCURL_LIBS"
+ elif test -n "$PKG_CONFIG"; then
+    if test -n "$PKG_CONFIG" && \
+    { { $as_echo "$as_me:${as_lineno-$LINENO}: \$PKG_CONFIG --exists --print-errors \"libcurl >= 7.61.0\""; } >&5
+  ($PKG_CONFIG --exists --print-errors "libcurl >= 7.61.0") 2>&5
+  ac_status=$?
+  $as_echo "$as_me:${as_lineno-$LINENO}: \$? = $ac_status" >&5
+  test $ac_status = 0; }; then
+  pkg_cv_LIBCURL_LIBS=`$PKG_CONFIG --libs "libcurl >= 7.61.0" 2>/dev/null`
+		      test "x$?" != "x0" && pkg_failed=yes
+else
+  pkg_failed=yes
+fi
+ else
+    pkg_failed=untried
+fi
+
+
+
+if test $pkg_failed = yes; then
+        { $as_echo "$as_me:${as_lineno-$LINENO}: result: no" >&5
+$as_echo "no" >&6; }
+
+if $PKG_CONFIG --atleast-pkgconfig-version 0.20; then
+        _pkg_short_errors_supported=yes
+else
+        _pkg_short_errors_supported=no
+fi
+        if test $_pkg_short_errors_supported = yes; then
+	        LIBCURL_PKG_ERRORS=`$PKG_CONFIG --short-errors --print-errors --cflags --libs "libcurl >= 7.61.0" 2>&1`
+        else
+	        LIBCURL_PKG_ERRORS=`$PKG_CONFIG --print-errors --cflags --libs "libcurl >= 7.61.0" 2>&1`
+        fi
+	# Put the nasty error message in config.log where it belongs
+	echo "$LIBCURL_PKG_ERRORS" >&5
+
+	as_fn_error $? "Package requirements (libcurl >= 7.61.0) were not met:
+
+$LIBCURL_PKG_ERRORS
+
+Consider adjusting the PKG_CONFIG_PATH environment variable if you
+installed software in a non-standard prefix.
+
+Alternatively, you may set the environment variables LIBCURL_CFLAGS
+and LIBCURL_LIBS to avoid the need to call pkg-config.
+See the pkg-config man page for more details." "$LINENO" 5
+elif test $pkg_failed = untried; then
+        { $as_echo "$as_me:${as_lineno-$LINENO}: result: no" >&5
+$as_echo "no" >&6; }
+	{ { $as_echo "$as_me:${as_lineno-$LINENO}: error: in \`$ac_pwd':" >&5
+$as_echo "$as_me: error: in \`$ac_pwd':" >&2;}
+as_fn_error $? "The pkg-config script could not be found or is too old.  Make sure it
+is in your PATH or set the PKG_CONFIG environment variable to the full
+path to pkg-config.
+
+Alternatively, you may set the environment variables LIBCURL_CFLAGS
+and LIBCURL_LIBS to avoid the need to call pkg-config.
+See the pkg-config man page for more details.
+
+To get pkg-config, see <http://pkg-config.freedesktop.org/>.
+See \`config.log' for more details" "$LINENO" 5; }
+else
+	LIBCURL_CFLAGS=$pkg_cv_LIBCURL_CFLAGS
+	LIBCURL_LIBS=$pkg_cv_LIBCURL_LIBS
+        { $as_echo "$as_me:${as_lineno-$LINENO}: result: yes" >&5
+$as_echo "yes" >&6; }
+
+fi
+
+  # OAuth requires python for testing
+  if test "$with_python" != yes; then
+    { $as_echo "$as_me:${as_lineno-$LINENO}: WARNING: *** OAuth support tests require --with-python to run" >&5
+$as_echo "$as_me: WARNING: *** OAuth support tests require --with-python to run" >&2;}
+  fi
+fi
+
+
 #
 # XML
 #
@@ -12216,6 +12365,59 @@ fi
 
 fi
 
+# XXX libcurl must link after libgssapi_krb5 on FreeBSD to avoid segfaults
+# during gss_acquire_cred(). This is possibly related to Curl's Heimdal
+# dependency on that platform?
+if test "$with_libcurl" = yes ; then
+  { $as_echo "$as_me:${as_lineno-$LINENO}: checking for curl_multi_init in -lcurl" >&5
+$as_echo_n "checking for curl_multi_init in -lcurl... " >&6; }
+if ${ac_cv_lib_curl_curl_multi_init+:} false; then :
+  $as_echo_n "(cached) " >&6
+else
+  ac_check_lib_save_LIBS=$LIBS
+LIBS="-lcurl  $LIBS"
+cat confdefs.h - <<_ACEOF >conftest.$ac_ext
+/* end confdefs.h.  */
+
+/* Override any GCC internal prototype to avoid an error.
+   Use char because int might match the return type of a GCC
+   builtin and then its argument prototype would still apply.  */
+#ifdef __cplusplus
+extern "C"
+#endif
+char curl_multi_init ();
+int
+main ()
+{
+return curl_multi_init ();
+  ;
+  return 0;
+}
+_ACEOF
+if ac_fn_c_try_link "$LINENO"; then :
+  ac_cv_lib_curl_curl_multi_init=yes
+else
+  ac_cv_lib_curl_curl_multi_init=no
+fi
+rm -f core conftest.err conftest.$ac_objext \
+    conftest$ac_exeext conftest.$ac_ext
+LIBS=$ac_check_lib_save_LIBS
+fi
+{ $as_echo "$as_me:${as_lineno-$LINENO}: result: $ac_cv_lib_curl_curl_multi_init" >&5
+$as_echo "$ac_cv_lib_curl_curl_multi_init" >&6; }
+if test "x$ac_cv_lib_curl_curl_multi_init" = xyes; then :
+  cat >>confdefs.h <<_ACEOF
+#define HAVE_LIBCURL 1
+_ACEOF
+
+  LIBS="-lcurl $LIBS"
+
+else
+  as_fn_error $? "library 'curl' is required for --with-libcurl" "$LINENO" 5
+fi
+
+fi
+
 if test "$with_gssapi" = yes ; then
   if test "$PORTNAME" != "win32"; then
     { $as_echo "$as_me:${as_lineno-$LINENO}: checking for library containing gss_store_cred_into" >&5
@@ -13964,6 +14166,17 @@ fi
 
 done
 
+fi
+
+if test "$with_libcurl" = yes; then
+  ac_fn_c_check_header_mongrel "$LINENO" "curl/curl.h" "ac_cv_header_curl_curl_h" "$ac_includes_default"
+if test "x$ac_cv_header_curl_curl_h" = xyes; then :
+
+else
+  as_fn_error $? "header file <curl/curl.h> is required for --with-libcurl" "$LINENO" 5
+fi
+
+
 fi
 
 if test "$PORTNAME" = "win32" ; then
diff --git a/configure.ac b/configure.ac
index d713360f340..b13fee83701 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1007,6 +1007,27 @@ fi
 AC_SUBST(with_uuid)
 
 
+#
+# libcurl
+#
+AC_MSG_CHECKING([whether to build with libcurl support])
+PGAC_ARG_BOOL(with, libcurl, no, [build with libcurl support],
+              [AC_DEFINE([USE_LIBCURL], 1, [Define to 1 to build with libcurl support. (--with-libcurl)])])
+AC_MSG_RESULT([$with_libcurl])
+AC_SUBST(with_libcurl)
+
+if test "$with_libcurl" = yes ; then
+  # Check for libcurl 7.61.0 or higher (corresponding to RHEL8 and the ability
+  # to explicitly set TLS 1.3 ciphersuites).
+  PKG_CHECK_MODULES(LIBCURL, [libcurl >= 7.61.0])
+
+  # OAuth requires python for testing
+  if test "$with_python" != yes; then
+    AC_MSG_WARN([*** OAuth support tests require --with-python to run])
+  fi
+fi
+
+
 #
 # XML
 #
@@ -1294,6 +1315,13 @@ failure.  It is possible the compiler isn't looking in the proper directory.
 Use --without-zlib to disable zlib support.])])
 fi
 
+# XXX libcurl must link after libgssapi_krb5 on FreeBSD to avoid segfaults
+# during gss_acquire_cred(). This is possibly related to Curl's Heimdal
+# dependency on that platform?
+if test "$with_libcurl" = yes ; then
+  AC_CHECK_LIB(curl, curl_multi_init, [], [AC_MSG_ERROR([library 'curl' is required for --with-libcurl])])
+fi
+
 if test "$with_gssapi" = yes ; then
   if test "$PORTNAME" != "win32"; then
     AC_SEARCH_LIBS(gss_store_cred_into, [gssapi_krb5 gss 'gssapi -lkrb5 -lcrypto'], [],
@@ -1588,6 +1616,10 @@ elif test "$with_uuid" = ossp ; then
       [AC_MSG_ERROR([header file <ossp/uuid.h> or <uuid.h> is required for OSSP UUID])])])
 fi
 
+if test "$with_libcurl" = yes; then
+  AC_CHECK_HEADER(curl/curl.h, [], [AC_MSG_ERROR([header file <curl/curl.h> is required for --with-libcurl])])
+fi
+
 if test "$PORTNAME" = "win32" ; then
    AC_CHECK_HEADERS(crtdefs.h)
 fi
diff --git a/doc/src/sgml/client-auth.sgml b/doc/src/sgml/client-auth.sgml
index 782b49c85ac..f84085dbac4 100644
--- a/doc/src/sgml/client-auth.sgml
+++ b/doc/src/sgml/client-auth.sgml
@@ -656,6 +656,16 @@ include_dir         <replaceable>directory</replaceable>
          </para>
         </listitem>
        </varlistentry>
+
+       <varlistentry>
+        <term><literal>oauth</literal></term>
+        <listitem>
+         <para>
+          Authorize and optionally authenticate using a third-party OAuth 2.0
+          identity provider. See <xref linkend="auth-oauth"/> for details.
+         </para>
+        </listitem>
+       </varlistentry>
       </variablelist>
 
       </para>
@@ -1143,6 +1153,12 @@ omicron         bryanh                  guest1
       only on OpenBSD).
      </para>
     </listitem>
+    <listitem>
+     <para>
+      <link linkend="auth-oauth">OAuth authorization/authentication</link>,
+      which relies on an external OAuth 2.0 identity provider.
+     </para>
+    </listitem>
    </itemizedlist>
   </para>
 
@@ -2329,6 +2345,242 @@ host ... radius radiusservers="server1,server2" radiussecrets="""secret one"",""
    </note>
   </sect1>
 
+  <sect1 id="auth-oauth">
+   <title>OAuth Authorization/Authentication</title>
+
+   <indexterm zone="auth-oauth">
+    <primary>OAuth Authorization/Authentication</primary>
+   </indexterm>
+
+   <para>
+    OAuth 2.0 is an industry-standard framework, defined in
+    <ulink url="https://datatracker.ietf.org/doc/html/rfc6749">RFC 6749</ulink>,
+    to enable third-party applications to obtain limited access to a protected
+    resource.
+
+    OAuth client support has to be enabled when <productname>PostgreSQL</productname>
+    is built, see <xref linkend="installation"/> for more information.
+   </para>
+
+   <para>
+    This documentation uses the following terminology when discussing the OAuth
+    ecosystem:
+
+    <variablelist>
+
+     <varlistentry>
+      <term>Resource Owner (or End User)</term>
+      <listitem>
+       <para>
+        The user or system who owns protected resources and can grant access to
+        them. This documentation also uses the term <emphasis>end user</emphasis>
+        when the resource owner is a person. When you use
+        <application>psql</application> to connect to the database using OAuth,
+        you are the resource owner/end user.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term>Client</term>
+      <listitem>
+       <para>
+        The system which accesses the protected resources using access
+        tokens. Applications using libpq, such as <application>psql</application>,
+        are the OAuth clients when connecting to a
+        <productname>PostgreSQL</productname> cluster.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term>Resource Server</term>
+      <listitem>
+       <para>
+        The system which hosts the protected resources which are
+        accessed by the client. The <productname>PostgreSQL</productname>
+        cluster being connected to is the resource server.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term>Provider</term>
+      <listitem>
+       <para>
+        The organization, product vendor, or other entity which develops and/or
+        administers the OAuth servers and clients for a given application.
+        Different providers typically choose different implementation details
+        for their OAuth systems; a client of one provider is not generally
+        guaranteed to have access to the servers of another.
+       </para>
+       <para>
+        This use of the term "provider" is not standard, but it seems to be in
+        wide use colloquially. (It should not be confused with OpenID's similar
+        term "Identity Provider". While the implementation of OAuth in
+        <productname>PostgreSQL</productname> is intended to be interoperable
+        and compatible with OpenID Connect/OIDC, it is not itself an OIDC client
+        and does not require its use.)
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term>Authorization Server</term>
+      <listitem>
+       <para>
+        The system which receives requests from, and issues access tokens to,
+        the client after the authenticated resource owner has given approval.
+        <productname>PostgreSQL</productname> does not provide an authorization
+        server; it's obtained from the OAuth provider.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term id="auth-oauth-issuer">Issuer</term>
+      <listitem>
+       <para>
+        An identifier for an authorization server, printed as an
+        <literal>https://</literal> URL, which provides a trusted "namespace"
+        for OAuth clients and applications. The issuer identifier allows a
+        single authorization server to talk to the clients of mutually
+        untrusting entities, as long as they maintain separate issuers.
+       </para>
+      </listitem>
+     </varlistentry>
+
+    </variablelist>
+
+    <note>
+     <para>
+      For small deployments, there may not be a meaningful distinction between
+      the "provider", "authorization server", and "issuer". However, for more
+      complicated setups, there may be a one-to-many (or many-to-many)
+      relationship: a provider may rent out multiple issuer identifiers to
+      separate tenants, then provide multiple authorization servers, possibly
+      with different supported feature sets, to interact with their clients.
+     </para>
+    </note>
+   </para>
+
+   <para>
+    <productname>PostgreSQL</productname> supports bearer tokens, defined in
+    <ulink url="https://datatracker.ietf.org/doc/html/rfc6750">RFC 6750</ulink>,
+    which are a type of access token used with OAuth 2.0 where the token is an
+    opaque string.  The format of the access token is implementation specific
+    and is chosen by each authorization server.
+   </para>
+
+   <para>
+    The following configuration options are supported for OAuth:
+    <variablelist>
+     <varlistentry>
+      <term><literal>issuer</literal></term>
+      <listitem>
+       <para>
+        An HTTPS URL which is either the exact
+        <link linkend="auth-oauth-issuer">issuer identifier</link> of the
+        authorization server, as defined by its discovery document, or a
+        well-known URI that points directly to that discovery document. This
+        parameter is required.
+       </para>
+       <para>
+        When an OAuth client connects to the server, a URL for the discovery
+        document will be constructed using the issuer identifier. By default,
+        this URL uses the conventions of OpenID Connect Discovery: the path
+        <literal>/.well-known/openid-configuration</literal> will be appended
+        to the end of the issuer identifier. Alternatively, if the
+        <literal>issuer</literal> contains a <literal>/.well-known/</literal>
+        path segment, that URL will be provided to the client as-is.
+       </para>
+       <warning>
+        <para>
+         The OAuth client in libpq requires the server's issuer setting to
+         exactly match the issuer identifier which is provided in the discovery
+         document, which must in turn match the client's
+         <xref linkend="libpq-connect-oauth-issuer"/> setting. No variations in
+         case or format are permitted.
+        </para>
+       </warning>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term><literal>scope</literal></term>
+      <listitem>
+       <para>
+        A space-separated list of the OAuth scopes needed for the server to
+        both authorize the client and authenticate the user.  Appropriate values
+        are determined by the authorization server and the OAuth validation
+        module used (see <xref linkend="oauth-validators" /> for more
+        information on validators).  This parameter is required.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term><literal>validator</literal></term>
+      <listitem>
+       <para>
+        The library to use for validating bearer tokens. If given, the name must
+        exactly match one of the libraries listed in
+        <xref linkend="guc-oauth-validator-libraries" />.  This parameter is
+        optional unless <literal>oauth_validator_libraries</literal> contains
+        more than one library, in which case it is required.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term><literal>map</literal></term>
+      <listitem>
+       <para>
+        Allows for mapping between OAuth identity provider and database user
+        names.  See <xref linkend="auth-username-maps"/> for details.  If a
+        map is not specified, the user name associated with the token (as
+        determined by the OAuth validator) must exactly match the role name
+        being requested.  This parameter is optional.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term id="auth-oauth-delegate-ident-mapping" xreflabel="delegate_ident_mapping">
+       <literal>delegate_ident_mapping</literal>
+      </term>
+      <listitem>
+       <para>
+        An advanced option which is not intended for common use.
+       </para>
+       <para>
+        When set to <literal>1</literal>, standard user mapping with
+        <filename>pg_ident.conf</filename> is skipped, and the OAuth validator
+        takes full responsibility for mapping end user identities to database
+        roles.  If the validator authorizes the token, the server trusts that
+        the user is allowed to connect under the requested role, and the
+        connection is allowed to proceed regardless of the authentication
+        status of the user.
+       </para>
+       <para>
+        This parameter is incompatible with <literal>map</literal>.
+       </para>
+       <warning>
+        <para>
+         <literal>delegate_ident_mapping</literal> provides additional
+         flexibility in the design of the authentication system, but it also
+         requires careful implementation of the OAuth validator, which must
+         determine whether the provided token carries sufficient end-user
+         privileges in addition to the <link linkend="oauth-validators">standard
+         checks</link> required of all validators.  Use with caution.
+        </para>
+       </warning>
+      </listitem>
+     </varlistentry>
+    </variablelist>
+   </para>
+  </sect1>
+
   <sect1 id="client-authentication-problems">
    <title>Authentication Problems</title>
 
diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml
index a8866292d46..3aab7761e4c 100644
--- a/doc/src/sgml/config.sgml
+++ b/doc/src/sgml/config.sgml
@@ -1209,6 +1209,32 @@ include_dir 'conf.d'
        </para>
       </listitem>
      </varlistentry>
+
+     <varlistentry id="guc-oauth-validator-libraries" xreflabel="oauth_validator_libraries">
+      <term><varname>oauth_validator_libraries</varname> (<type>string</type>)
+      <indexterm>
+       <primary><varname>oauth_validator_libraries</varname> configuration parameter</primary>
+      </indexterm>
+      </term>
+      <listitem>
+       <para>
+        The library/libraries to use for validating OAuth connection tokens. If
+        only one validator library is provided, it will be used by default for
+        any OAuth connections; otherwise, all
+        <link linkend="auth-oauth"><literal>oauth</literal> HBA entries</link>
+        must explicitly set a <literal>validator</literal> chosen from this
+        list. If set to an empty string (the default), OAuth connections will be
+        refused. This parameter can only be set in the
+        <filename>postgresql.conf</filename> file.
+       </para>
+       <para>
+        Validator modules must be implemented/obtained separately;
+        <productname>PostgreSQL</productname> does not ship with any default
+        implementations. For more information on implementing OAuth validators,
+        see <xref linkend="oauth-validators" />.
+       </para>
+      </listitem>
+     </varlistentry>
      </variablelist>
      </sect2>
 
diff --git a/doc/src/sgml/filelist.sgml b/doc/src/sgml/filelist.sgml
index 66e6dccd4c9..25fb99cee69 100644
--- a/doc/src/sgml/filelist.sgml
+++ b/doc/src/sgml/filelist.sgml
@@ -111,6 +111,7 @@
 <!ENTITY generic-wal SYSTEM "generic-wal.sgml">
 <!ENTITY custom-rmgr SYSTEM "custom-rmgr.sgml">
 <!ENTITY backup-manifest SYSTEM "backup-manifest.sgml">
+<!ENTITY oauth-validators SYSTEM "oauth-validators.sgml">
 
 <!-- contrib information -->
 <!ENTITY contrib         SYSTEM "contrib.sgml">
diff --git a/doc/src/sgml/installation.sgml b/doc/src/sgml/installation.sgml
index 271615e4a65..cdf73747da0 100644
--- a/doc/src/sgml/installation.sgml
+++ b/doc/src/sgml/installation.sgml
@@ -1141,6 +1141,19 @@ build-postgresql:
        </listitem>
       </varlistentry>
 
+      <varlistentry id="configure-option-with-libcurl">
+       <term><option>--with-libcurl</option></term>
+       <listitem>
+        <para>
+         Build with libcurl support for OAuth 2.0 client flows.
+         This requires the <productname>curl</productname> package to be
+         installed.  Building with this will check for the required header files
+         and libraries to make sure that your <productname>curl</productname>
+         installation is sufficient before proceeding.
+        </para>
+       </listitem>
+      </varlistentry>
+
       <varlistentry id="configure-option-with-libxml">
        <term><option>--with-libxml</option></term>
        <listitem>
@@ -2582,6 +2595,20 @@ ninja install
       </listitem>
      </varlistentry>
 
+     <varlistentry id="configure-with-libcurl-meson">
+      <term><option>-Dlibcurl={ auto | enabled | disabled }</option></term>
+      <listitem>
+       <para>
+        Build with libcurl support for OAuth 2.0 client flows.
+        This requires the <productname>curl</productname> package to be
+        installed.  Building with this will check for the required header files
+        and libraries to make sure that your <productname>curl</productname>
+        installation is sufficient before proceeding. The default for this
+        option is auto.
+       </para>
+      </listitem>
+     </varlistentry>
+
      <varlistentry id="configure-with-libxml-meson">
       <term><option>-Dlibxml={ auto | enabled | disabled }</option></term>
       <listitem>
diff --git a/doc/src/sgml/libpq.sgml b/doc/src/sgml/libpq.sgml
index e04acf1c208..0ed80f547c4 100644
--- a/doc/src/sgml/libpq.sgml
+++ b/doc/src/sgml/libpq.sgml
@@ -1385,6 +1385,15 @@ postgresql://%2Fvar%2Flib%2Fpostgresql/dbname
           </listitem>
          </varlistentry>
 
+         <varlistentry>
+          <term><literal>oauth</literal></term>
+          <listitem>
+           <para>
+            The server must request an OAuth bearer token from the client.
+           </para>
+          </listitem>
+         </varlistentry>
+
          <varlistentry>
           <term><literal>none</literal></term>
           <listitem>
@@ -2373,6 +2382,106 @@ postgresql://%2Fvar%2Flib%2Fpostgresql/dbname
        </para>
       </listitem>
      </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-issuer" xreflabel="oauth_issuer">
+      <term><literal>oauth_issuer</literal></term>
+      <listitem>
+       <para>
+        The HTTPS URL of a trusted issuer to contact if the server requests an
+        OAuth token for the connection. This parameter is required for all OAuth
+        connections; it should exactly match the <literal>issuer</literal>
+        setting in <link linkend="auth-oauth">the server's HBA configuration.</link>
+       </para>
+       <para>
+        As part of the standard authentication handshake, <application>libpq</application>
+        will ask the server for a <emphasis>discovery document:</emphasis> a URL
+        providing a set of OAuth configuration parameters. The server must
+        provide a URL that is directly constructed from the components of the
+        <literal>oauth_issuer</literal>, and this value must exactly match the
+        issuer identifier that is declared in the discovery document itself, or
+        the connection will fail. This is required to prevent a class of "mix-up
+        attacks" on OAuth clients.
+       </para>
+       <para>
+        You may also explicitly set <literal>oauth_issuer</literal> to the
+        <literal>/.well-known/</literal> URI used for OAuth discovery. In this
+        case, if the server asks for a different URL, the connection will fail,
+        but a <link linkend="libpq-oauth-authdata-hooks">custom OAuth flow</link>
+        may be able to speed up the standard handshake by using previously
+        cached tokens. (In this case, it is recommended that
+        <xref linkend="libpq-connect-oauth-scope"/> be set as well, since the
+        client will not have a chance to ask the server for a correct scope
+        setting, and the default scopes for a token may not be sufficient to
+        connect.) <application>libpq</application> currently supports the
+        following well-known endpoints:
+        <itemizedlist spacing="compact">
+         <listitem><para><literal>/.well-known/openid-configuration</literal></para></listitem>
+         <listitem><para><literal>/.well-known/oauth-authorization-server</literal></para></listitem>
+        </itemizedlist>
+       </para>
+       <warning>
+        <para>
+         Issuers are highly privileged during the OAuth connection handshake. As
+         a rule of thumb, if you would not trust the operator of a URL to handle
+         access to your servers, or to impersonate you directly, that URL should
+         not be trusted as an <literal>oauth_issuer</literal>.
+        </para>
+       </warning>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-client-id" xreflabel="oauth_client_id">
+      <term><literal>oauth_client_id</literal></term>
+      <listitem>
+       <para>
+        An OAuth 2.0 client identifier, as issued by the authorization server.
+        If the <productname>PostgreSQL</productname> server
+        <link linkend="auth-oauth">requests an OAuth token</link> for the
+        connection (and if no <link linkend="libpq-oauth-authdata-hooks">custom
+        OAuth hook</link> is installed to provide one), then this parameter must
+        be set; otherwise, the connection will fail.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-client-secret" xreflabel="oauth_client_secret">
+      <term><literal>oauth_client_secret</literal></term>
+      <listitem>
+       <para>
+        The client password, if any, to use when contacting the OAuth
+        authorization server. Whether this parameter is required or not is
+        determined by the OAuth provider; "public" clients generally do not use
+        a secret, whereas "confidential" clients generally do.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-scope" xreflabel="oauth_scope">
+      <term><literal>oauth_scope</literal></term>
+      <listitem>
+       <para>
+        The scope of the access request sent to the authorization server,
+        specified as a (possibly empty) space-separated list of OAuth scope
+        identifiers. This parameter is optional and intended for advanced usage.
+       </para>
+       <para>
+        Usually the client will obtain appropriate scope settings from the
+        <productname>PostgreSQL</productname> server. If this parameter is used,
+        the server's requested scope list will be ignored. This can prevent a
+        less-trusted server from requesting inappropriate access scopes from the
+        end user. However, if the client's scope setting does not contain the
+        server's required scopes, the server is likely to reject the issued
+        token, and the connection will fail.
+       </para>
+       <para>
+        The meaning of an empty scope list is provider-dependent. An OAuth
+        authorization server may choose to issue a token with "default scope",
+        whatever that happens to be, or it may reject the token request
+        entirely.
+       </para>
+      </listitem>
+     </varlistentry>
+
     </variablelist>
    </para>
   </sect2>
@@ -10020,6 +10129,278 @@ void PQinitSSL(int do_ssl);
 
  </sect1>
 
+ <sect1 id="libpq-oauth">
+  <title>OAuth Support</title>
+
+  <para>
+   TODO
+  </para>
+
+  <sect2 id="libpq-oauth-authdata-hooks">
+   <title>Authdata Hooks</title>
+
+   <para>
+    The behavior of the OAuth flow may be modified or replaced by a client using
+    the following hook API:
+
+    <variablelist>
+     <varlistentry id="libpq-PQsetAuthDataHook">
+      <term><function>PQsetAuthDataHook</function><indexterm><primary>PQsetAuthDataHook</primary></indexterm></term>
+
+      <listitem>
+       <para>
+        Sets the <symbol>PGauthDataHook</symbol>, overriding
+        <application>libpq</application>'s handling of one or more aspects of
+        its OAuth client flow.
+<synopsis>
+void PQsetAuthDataHook(PQauthDataHook_type hook);
+</synopsis>
+        If <replaceable>hook</replaceable> is <literal>NULL</literal>, the
+        default handler will be reinstalled. Otherwise, the application passes
+        a pointer to a callback function with the signature:
+<programlisting>
+int hook_fn(PGauthData type, PGconn *conn, void *data);
+</programlisting>
+        which <application>libpq</application> will call when when action is
+        required of the application. <replaceable>type</replaceable> describes
+        the request being made, <replaceable>conn</replaceable> is the
+        connection handle being authenticated, and <replaceable>data</replaceable>
+        points to request-specific metadata. The contents of this pointer are
+        determined by <replaceable>type</replaceable>; see
+        <xref linkend="libpq-oauth-authdata-hooks-types"/> for the supported
+        list.
+       </para>
+       <para>
+        Hooks can be chained together to allow cooperative and/or fallback
+        behavior. In general, a hook implementation should examine the incoming
+        <replaceable>type</replaceable> (and, potentially, the request metadata
+        and/or the settings for the particular <replaceable>conn</replaceable>
+        in use) to decide whether or not to handle a specific piece of authdata.
+        If not, it should delegate to the previous hook in the chain
+        (retrievable via <function>PQgetAuthDataHook</function>).
+       </para>
+       <para>
+        Success is indicated by returning an integer greater than zero.
+        Returning a negative integer signals an error condition and abandons the
+        connection attempt. (A zero value is reserved for the default
+        implementation.)
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="libpq-PQgetAuthDataHook">
+      <term><function>PQgetAuthDataHook</function><indexterm><primary>PQgetAuthDataHook</primary></indexterm></term>
+
+      <listitem>
+       <para>
+        Retrieves the current value of <symbol>PGauthDataHook</symbol>.
+<synopsis>
+PQauthDataHook_type PQgetAuthDataHook(void);
+</synopsis>
+        At initialization time (before the first call to
+        <function>PQsetAuthDataHook</function>), this function will return
+        <symbol>PQdefaultAuthDataHook</symbol>.
+       </para>
+      </listitem>
+     </varlistentry>
+    </variablelist>
+   </para>
+
+   <sect3 id="libpq-oauth-authdata-hooks-types">
+    <title>Hook Types</title>
+    <para>
+     The following <symbol>PGauthData</symbol> types and their corresponding
+     <replaceable>data</replaceable> structures are defined:
+
+     <variablelist>
+      <varlistentry id="libpq-oauth-authdata-prompt-oauth-device">
+       <term>
+        <symbol>PQAUTHDATA_PROMPT_OAUTH_DEVICE</symbol>
+        <indexterm><primary>PQAUTHDATA_PROMPT_OAUTH_DEVICE</primary></indexterm>
+       </term>
+       <listitem>
+        <para>
+         Replaces the default user prompt during the builtin device
+         authorization client flow. <replaceable>data</replaceable> points to
+         an instance of <symbol>PGpromptOAuthDevice</symbol>:
+<synopsis>
+typedef struct _PGpromptOAuthDevice
+{
+    const char *verification_uri;   /* verification URI to visit */
+    const char *user_code;          /* user code to enter */
+} PGpromptOAuthDevice;
+</synopsis>
+        </para>
+        <para>
+         The OAuth Device Authorization flow included in <application>libpq</application>
+         requires the end user to visit a URL with a browser, then enter a code
+         which permits <application>libpq</application> to connect to the server
+         on their behalf. The default prompt simply prints the
+         <literal>verification_uri</literal> and <literal>user_code</literal>
+         on standard error. Replacement implementations may display this
+         information using any preferred method, for example with a GUI.
+        </para>
+        <para>
+         This callback is only invoked during the builtin device
+         authorization flow. If the application installs a
+         <link linkend="libpq-oauth-authdata-oauth-bearer-token">custom OAuth
+         flow</link>, this authdata type will not be used.
+        </para>
+       </listitem>
+      </varlistentry>
+
+      <varlistentry id="libpq-oauth-authdata-oauth-bearer-token">
+       <term>
+        <symbol>PQAUTHDATA_OAUTH_BEARER_TOKEN</symbol>
+        <indexterm><primary>PQAUTHDATA_OAUTH_BEARER_TOKEN</primary></indexterm>
+       </term>
+       <listitem>
+        <para>
+         Replaces the entire OAuth flow with a custom implementation. The hook
+         should either directly return a Bearer token for the current
+         user/issuer/scope combination, if one is available without blocking, or
+         else set up an asynchronous callback to retrieve one.
+        </para>
+        <para>
+         <replaceable>data</replaceable> points to an instance
+         of <symbol>PGoauthBearerRequest</symbol>, which should be filled in
+         by the implementation:
+<synopsis>
+typedef struct _PGoauthBearerRequest
+{
+    /* Hook inputs (constant across all calls) */
+    const char *const openid_configuration; /* OIDC discovery URL */
+    const char *const scope;                /* required scope(s), or NULL */
+
+    /* Hook outputs */
+
+    /* Callback implementing a custom asynchronous OAuth flow. */
+    PostgresPollingStatusType (*async) (PGconn *conn,
+                                        struct _PGoauthBearerRequest *request,
+                                        SOCKTYPE *altsock);
+
+    /* Callback to clean up custom allocations. */
+    void        (*cleanup) (PGconn *conn, struct _PGoauthBearerRequest *request);
+
+    char       *token;   /* acquired Bearer token */
+    void       *user;    /* hook-defined allocated data */
+} PGoauthBearerRequest;
+</synopsis>
+        </para>
+        <para>
+         Two pieces of information are provided to the hook by
+         <application>libpq</application>:
+         <replaceable>openid_configuration</replaceable> contains the URL of an
+         OAuth discovery document describing the authorization server's
+         supported flows, and <replaceable>scope</replaceable> contains a
+         (possibly empty) space-separated list of OAuth scopes which are
+         required to access the server. Either or both may be
+         <literal>NULL</literal> to indicate that the information was not
+         discoverable. (In this case, implementations may be able to establish
+         the requirements using some other preconfigured knowledge, or they may
+         choose to fail.)
+        </para>
+        <para>
+         The final output of the hook is <replaceable>token</replaceable>, which
+         must point to a valid Bearer token for use on the connection. (This
+         token should be issued by the
+         <xref linkend="libpq-connect-oauth-issuer"/> and hold the requested
+         scopes, or the connection will be rejected by the server's validator
+         module.) The allocated token string must remain valid until
+         <application>libpq</application> is finished connecting; the hook
+         should set a <replaceable>cleanup</replaceable> callback which will be
+         called when <application>libpq</application> no longer requires it.
+        </para>
+        <para>
+         If an implementation cannot immediately produce a
+         <replaceable>token</replaceable> during the initial call to the hook,
+         it should set the <replaceable>async</replaceable> callback to handle
+         nonblocking communication with the authorization server.
+         <footnote>
+          <para>
+           Performing blocking operations during the
+           <symbol>PQAUTHDATA_OAUTH_BEARER_TOKEN</symbol> hook callback will
+           interfere with nonblocking connection APIs such as
+           <function>PQconnectPoll</function> and prevent concurrent connections
+           from making progress. Applications which only ever use the
+           synchronous connection primitives, such as
+           <function>PQconnectdb</function>, may synchronously retrieve a token
+           during the hook instead of implementing the
+           <replaceable>async</replaceable> callback, but they will necessarily
+           be limited to one connection at a time.
+          </para>
+         </footnote>
+         This will be called to begin the flow immediately upon return from the
+         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
+         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>
+         after setting <replaceable>token</replaceable> when the flow is
+         complete, or <symbol>PGRES_POLLING_FAILED</symbol> to indicate failure.
+        </para>
+        <para>
+         Implementations may wish to store additional data for bookkeeping
+         across calls to the <replaceable>async</replaceable> and
+         <replaceable>cleanup</replaceable> callbacks. The
+         <replaceable>user</replaceable> pointer is provided for this purpose;
+         <application>libpq</application> will not touch its contents and the
+         application may use it at its convenience. (Remember to free any
+         allocations during token cleanup.)
+        </para>
+       </listitem>
+      </varlistentry>
+     </variablelist>
+    </para>
+   </sect3>
+  </sect2>
+
+  <sect2 id="libpq-oauth-debugging">
+   <title>Debugging and Developer Settings</title>
+
+   <para>
+    A "dangerous debugging mode" may be enabled by setting the environment
+    variable <envar>PGOAUTHDEBUG=UNSAFE</envar>. This functionality is provided
+    for ease of local development and testing only. It does several things that
+    you will not want a production system to do:
+
+    <itemizedlist spacing="compact">
+     <listitem>
+      <para>
+       permits the use of unencrypted HTTP during the OAuth provider exchange
+      </para>
+     </listitem>
+     <listitem>
+      <para>
+       allows the system's trusted CA list to be completely replaced using the
+       <envar>PGOAUTHCAFILE</envar> environment variable
+      </para>
+     </listitem>
+     <listitem>
+      <para>
+       sprays HTTP traffic (containing several critical secrets) to standard
+       error during the OAuth flow
+      </para>
+     </listitem>
+     <listitem>
+      <para>
+       permits the use of zero-second retry intervals, which can cause the
+       client to busy-loop and pointlessly consume CPU
+      </para>
+     </listitem>
+    </itemizedlist>
+   </para>
+   <warning>
+    <para>
+     Do not share the output of the OAuth flow traffic with third parties. It
+     contains secrets that can be used to attack your clients and servers.
+    </para>
+   </warning>
+  </sect2>
+ </sect1>
+
 
  <sect1 id="libpq-threading">
   <title>Behavior in Threaded Programs</title>
diff --git a/doc/src/sgml/oauth-validators.sgml b/doc/src/sgml/oauth-validators.sgml
new file mode 100644
index 00000000000..d0bca9196d9
--- /dev/null
+++ b/doc/src/sgml/oauth-validators.sgml
@@ -0,0 +1,402 @@
+<!-- doc/src/sgml/oauth-validators.sgml -->
+
+<chapter id="oauth-validators">
+ <title>OAuth Validator Modules</title>
+ <indexterm zone="oauth-validators">
+  <primary>OAuth Validators</primary>
+ </indexterm>
+ <para>
+  <productname>PostgreSQL</productname> provides infrastructure for creating
+  custom modules to perform server-side validation of OAuth bearer tokens.
+  Because OAuth implementations vary so wildly, and bearer token validation is
+  heavily dependent on the issuing party, the server cannot check the token
+  itself; validator modules provide the glue between the server and the OAuth
+  provider in use.
+ </para>
+ <para>
+  OAuth validator modules must at least consist of an initialization function
+  (see <xref linkend="oauth-validator-init"/>) and the required callback for
+  performing validation (see <xref linkend="oauth-validator-callback-validate"/>).
+ </para>
+ <warning>
+  <para>
+   Since a misbehaving validator might let unauthorized users into the database,
+   correct implementation is critical. See
+   <xref linkend="oauth-validator-design"/> for design considerations.
+  </para>
+ </warning>
+
+ <sect1 id="oauth-validator-design">
+  <title>Safely Designing a Validator Module</title>
+  <warning>
+   <para>
+    Read and understand the entirety of this section before implementing a
+    validator module. A malfunctioning validator is potentially worse than no
+    authentication at all, both because of the false sense of security it
+    provides, and because it may contribute to attacks against other pieces of
+    an OAuth ecosystem.
+   </para>
+  </warning>
+
+  <sect2 id="oauth-validator-design-responsibilities">
+   <title>Validator Responsibilities</title>
+   <para>
+    TODO
+   </para>
+   <variablelist>
+    <varlistentry>
+     <term>Validate the Token</term>
+     <listitem>
+      <para>
+       The validator must first ensure that the presented token is in fact a
+       valid Bearer token for use in client authentication. The correct way to
+       do this depends on the provider, but it generally involves either
+       cryptographic operations to prove that the token was created by a trusted
+       party (offline validation), or the presentation of the token to that
+       trusted party so that it can perform validation for you (online
+       validation).
+      </para>
+      <para>
+       Online validation, usually implemented via
+       <ulink url="https://datatracker.ietf.org/doc/html/rfc7662">OAuth Token
+       Introspection</ulink>, requires fewer steps of a validator module and
+       allows central revocation of a token in the event that it is stolen
+       or misissued. However, it does require the module to make at least one
+       network call per authentication attempt (all of which must complete
+       within the configured <xref linkend="guc-authentication-timeout"/>).
+       Additionally, your provider may not provide introspection endpoints for
+       use by external resource servers.
+      </para>
+      <para>
+       Offline validation is much more involved, typically requiring a validator
+       to maintain a list of trusted signing keys for a provider and then
+       check the token's cryptographic signature along with its contents.
+       Implementations must follow the provider's instructions to the letter,
+       including any verification of issuer ("where is this token from?"),
+       audience ("who is this token for?"), and validity period ("when can this
+       token be used?"). Since there is no communication between the module and
+       the provider, tokens cannot be centrally revoked using this method;
+       offline validator implementations may wish to place restrictions on the
+       maximum length of a token's validity period.
+      </para>
+      <para>
+       If the token cannot be validated, the module should immediately fail.
+       Further authentication/authorization is pointless if the bearer token
+       wasn't issued by a trusted party.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Authorize the Client</term>
+     <listitem>
+      <para>
+       Next the validator must ensure that the end user has given the client
+       permission to access the server on their behalf. This generally involves
+       checking the scopes that have been assigned to the token, to make sure
+       that they cover database access for the current HBA parameters.
+      </para>
+      <para>
+       The purpose of this step is to prevent an OAuth client from obtaining a
+       token under false pretenses. If the validator requires all tokens to
+       carry scopes that cover database access, the provider should then loudly
+       prompt the user to grant that access during the flow. This gives them the
+       opportunity to reject the request if the client isn't supposed to be
+       using their credentials to connect to databases.
+      </para>
+      <para>
+       While it is possible to establish client authorization without explicit
+       scopes by using out-of-band knowledge of the deployed architecture, doing
+       so removes the user from the loop, which prevents them from catching
+       deployment mistakes and allows any such mistakes to be exploited
+       silently. Access to the database must be tightly restricted to only
+       trusted clients
+       <footnote>
+        <para>
+         That is, "trusted" in the sense that the OAuth client and the
+         <productname>PostgreSQL</productname> server are controlled by the same
+         entity. Notably, the Device Authorization client flow supported by
+         libpq does not usually meet this bar, since it's designed for use by
+         public/untrusted clients.
+        </para>
+       </footnote>
+       if users are not prompted for additional scopes.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Authenticate the End User</term>
+     <listitem>
+      <para>
+       Finally, the validator should determine a user identifier for the token,
+       either by asking the provider for this information or by extracting it
+       from the token itself, and return that identifier to the server (which
+       will then make a final authorization decision using the HBA
+       configuration). This identifier will be available within the session via
+       <link linkend="functions-info-session-table"><function>system_user</function></link>
+       and recorded in the server logs if <xref linkend="guc-log-connections"/>
+       is enabled.
+      </para>
+      <para>
+       Different providers may record a variety of different authentication
+       information for an end user, typically referred to as
+       <emphasis>claims</emphasis>. Providers usually document which of these
+       claims are trustworthy enough to use for authorization decisions and
+       which are not. (For instance, it would probably not be wise to use an
+       end user's full name as the identifier for authentication, since many
+       providers allow users to change their display names arbitrarily.)
+       Ultimately, the choice of which claim (or combination of claims) to use
+       comes down to the provider implementation and application requirements.
+      </para>
+      <para>
+       Note that anonymous/pseudonymous login is possible as well, by enabling
+       usermap delegation; see
+       <xref linkend="oauth-validator-design-usermap-delegation"/>.
+      </para>
+     </listitem>
+    </varlistentry>
+   </variablelist>
+  </sect2>
+
+  <sect2 id="oauth-validator-design-guidelines">
+   <title>General Coding Guidelines</title>
+   <para>
+    Developers should keep the following in mind when implementing token
+    validation:
+   </para>
+   <variablelist>
+    <varlistentry>
+     <term>Token Confidentiality</term>
+     <listitem>
+      <para>
+       Modules should not write tokens, or pieces of tokens, into the server
+       log. This is true even if the module considers the token invalid; an
+       attacker who confuses a client into communicating with the wrong provider
+       should not be able to retrieve that (otherwise valid) token from the
+       disk.
+      </para>
+      <para>
+       Implementations that send tokens over the network (for example, to
+       perform online token validation with a provider) must authenticate the
+       peer and ensure that strong transport security is in use.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Logging</term>
+     <listitem>
+      <para>
+       Modules may use the same <link linkend="error-message-reporting">logging
+       facilities</link> as standard extensions; however, the rules for emitting
+       log entries to the client are subtly different during the authentication
+       phase of the connection. Generally speaking, modules should log
+       verification problems at the <symbol>COMMERROR</symbol> level and return
+       normally, instead of using <symbol>ERROR</symbol>/<symbol>FATAL</symbol>
+       to unwind the stack, to avoid leaking information to unauthenticated
+       clients.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Interruptibility</term>
+     <listitem>
+      <para>
+       Modules must remain interruptible by signals so that the server can
+       correctly handle authentication timeouts and shutdown signals from
+       <application>pg_ctl</application>. For example, a module receiving
+       <symbol>EINTR</symbol>/<symbol>EAGAIN</symbol> from a blocking call
+       should call <function>CHECK_FOR_INTERRUPTS()</function> before retrying.
+       The same should be done during any long-running loops. Failure to follow
+       this guidance may result in unresponsive backend sessions.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Testing</term>
+     <listitem>
+      <para>
+       The breadth of testing an OAuth system is well beyond the scope of this
+       documentation, but at minimum, negative testing should be considered
+       mandatory. It's trivial to design a module that lets authorized users in;
+       the whole point of the system is to keep unauthorized users out.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Documentation</term>
+     <listitem>
+      <para>
+       Validator implementations should document the contents and format of the
+       authenticated ID that is reported to the server for each end user, since
+       DBAs may need to use this information to construct pg_ident maps. (For
+       instance, is it an email address? an organizational ID number? a UUID?)
+       They should also document whether or not it is safe to use the module in
+       <symbol>delegate_ident_mapping=1</symbol> mode, and what additional
+       configuration is required in order to do so.
+      </para>
+     </listitem>
+    </varlistentry>
+   </variablelist>
+  </sect2>
+
+  <sect2 id="oauth-validator-design-usermap-delegation">
+   <title>Authorizing Users (Usermap Delegation)</title>
+   <para>
+    The standard deliverable of a validation module is the user identifier,
+    which the server will then compare to any configured
+    <link linkend="auth-username-maps"><filename>pg_ident.conf</filename>
+    mappings</link> and determine whether the end user is authorized to connect.
+    However, OAuth is itself an authorization framework, and tokens may carry
+    information about user privileges. For example, a token may be associated
+    with the organizational groups that a user belongs to, or list the roles
+    that a user may assume, and duplicating that knowledge into local usermaps
+    for every server may not be desirable.
+   </para>
+   <para>
+    To bypass username mapping entirely, and have the validator module assume
+    the additional responsibility of authorizing user connections, the HBA may
+    be configured with <xref linkend="auth-oauth-delegate-ident-mapping"/>.
+    The module may then use token scopes or an equivalent method to decide
+    whether the user is allowed to connect under their desired role. The user
+    identifier will still be recorded by the server, but it plays no part in
+    determining whether to continue the connection.
+   </para>
+   <para>
+    Using this scheme, authentication itself is optional. As long as the module
+    reports that the connection is authorized, login will continue even if there
+    is no recorded user identifier at all. This makes it possible to implement
+    anonymous or pseudonymous access to the database, where the third-party
+    provider performs all necessary authentication but does not provide any
+    user-identifying information to the server. (Some providers may create an
+    anonymized ID number that can be recorded instead, for later auditing.)
+   </para>
+   <para>
+    Usermap delegation provides the most architectural flexibility, but it turns
+    the validator module into a single point of failure for connection
+    authorization. Use with caution.
+   </para>
+  </sect2>
+ </sect1>
+
+ <sect1 id="oauth-validator-init">
+  <title>Initialization Functions</title>
+  <indexterm zone="oauth-validator-init">
+   <primary>_PG_oauth_validator_module_init</primary>
+  </indexterm>
+  <para>
+   An OAuth validator module is loaded by dynamically loading one of the shared
+   libraries listed in <xref linkend="guc-oauth-validator-libraries"/>.
+   The normal library search path is used to locate the library. To
+   provide the validator callbacks and to indicate that the library is an OAuth
+   validator module a function named
+   <function>_PG_oauth_validator_module_init</function> must be provided. The
+   return value of the function must be a pointer to a struct of type
+   <structname>OAuthValidatorCallbacks</structname>, which contains pointers to
+   the module's token validation functions. The returned
+   pointer must be of server lifetime, which is typically achieved by defining
+   it as a <literal>static const</literal> variable in global scope.
+<programlisting>
+typedef struct OAuthValidatorCallbacks
+{
+    ValidatorStartupCB startup_cb;
+    ValidatorShutdownCB shutdown_cb;
+    ValidatorValidateCB validate_cb;
+} OAuthValidatorCallbacks;
+
+typedef const OAuthValidatorCallbacks *(*OAuthValidatorModuleInit) (void);
+</programlisting>
+
+   Only the <function>validate_cb</function> callback is required, the others
+   are optional.
+  </para>
+ </sect1>
+
+ <sect1 id="oauth-validator-callbacks">
+  <title>OAuth Validator Callbacks</title>
+  <para>
+   OAuth validator modules implement their functionality by defining a set of
+   callbacks. The server will call them as required to process the
+   authentication request from the user.
+  </para>
+
+  <sect2 id="oauth-validator-callback-startup">
+   <title>Startup Callback</title>
+   <para>
+    The <function>startup_cb</function> callback is executed directly after
+    loading the module. This callback can be used to set up local state and
+    perform additional initialization if required. If the validator module
+    has state it can use <structfield>state->private_data</structfield> to
+    store it.
+
+<programlisting>
+typedef void (*ValidatorStartupCB) (ValidatorModuleState *state);
+</programlisting>
+   </para>
+  </sect2>
+
+  <sect2 id="oauth-validator-callback-validate">
+   <title>Validate Callback</title>
+   <para>
+    The <function>validate_cb</function> callback is executed during the OAuth
+    exchange when a user attempts to authenticate using OAuth.  Any state set in
+    previous calls will be available in <structfield>state->private_data</structfield>.
+
+<programlisting>
+typedef ValidatorModuleResult *(*ValidatorValidateCB) (ValidatorModuleState *state, const char *token, const char *role);
+</programlisting>
+
+    <replaceable>token</replaceable> will contain the bearer token to validate.
+    The server has ensured that the token is well-formed syntactically, but no
+    other validation has been performed.  <replaceable>role</replaceable> will
+    contain the role the user has requested to log in as.  The callback must
+    return a palloc'd <literal>ValidatorModuleResult</literal> struct, which is
+    defined as below:
+
+<programlisting>
+typedef struct ValidatorModuleResult
+{
+    bool        authorized;
+    char       *authn_id;
+} ValidatorModuleResult;
+</programlisting>
+
+    The connection will only proceed if the module sets
+    <structfield>authorized</structfield> to <literal>true</literal>.  To
+    authenticate the user, the authenticated user name (as determined using the
+    token) shall be palloc'd and returned in the <structfield>authn_id</structfield>
+    field.  Alternatively, <structfield>authn_id</structfield> may be set to
+    NULL if the token is valid but the associated user identity cannot be
+    determined.
+   </para>
+   <para>
+    The caller assumes ownership of the returned memory allocation, the
+    validator module should not in any way access the memory after it has been
+    returned.  A validator may instead return NULL to signal an internal
+    error.
+   </para>
+   <para>
+    The behavior after <function>validate_cb</function> returns depends on the
+    specific HBA setup.  Normally, the <structfield>authn_id</structfield> user
+    name must exactly match the role that the user is logging in as.  (This
+    behavior may be modified with a usermap.)  But when authenticating against
+    an HBA rule with <literal>trust_validator_authz</literal> turned on, the
+    server will not perform any checks on the value of
+    <structfield>authn_id</structfield> at all; in this case it is up to the
+    validator to ensure that the token carries enough privileges for the user to
+    log in under the indicated <replaceable>role</replaceable>.
+   </para>
+  </sect2>
+
+  <sect2 id="oauth-validator-callback-shutdown">
+   <title>Shutdown Callback</title>
+   <para>
+    The <function>shutdown_cb</function> callback is executed when the backend
+    process associated with the connection exits. If the validator module has
+    any state, this callback should free it to avoid resource leaks.
+<programlisting>
+typedef void (*ValidatorShutdownCB) (ValidatorModuleState *state);
+</programlisting>
+   </para>
+  </sect2>
+
+ </sect1>
+</chapter>
diff --git a/doc/src/sgml/postgres.sgml b/doc/src/sgml/postgres.sgml
index 7be25c58507..af476c82fcc 100644
--- a/doc/src/sgml/postgres.sgml
+++ b/doc/src/sgml/postgres.sgml
@@ -229,6 +229,7 @@ break is not needed in a wider output rendering.
   &logicaldecoding;
   &replication-origins;
   &archive-modules;
+  &oauth-validators;
 
  </part>
 
diff --git a/doc/src/sgml/regress.sgml b/doc/src/sgml/regress.sgml
index f4cef9e80f7..ae4732df656 100644
--- a/doc/src/sgml/regress.sgml
+++ b/doc/src/sgml/regress.sgml
@@ -336,6 +336,16 @@ make check-world PG_TEST_EXTRA='kerberos ldap ssl load_balance libpq_encryption'
       </para>
      </listitem>
     </varlistentry>
+
+    <varlistentry>
+     <term><literal>oauth</literal></term>
+     <listitem>
+      <para>
+       Runs the test suite under <filename>src/test/modules/oauth_validator</filename>.
+       This opens TCP/IP listen sockets for a test-server running HTTPS.
+      </para>
+     </listitem>
+    </varlistentry>
    </variablelist>
 
    Tests for features that are not supported by the current build
diff --git a/meson.build b/meson.build
index 32fc89f3a4b..351f89a92dc 100644
--- a/meson.build
+++ b/meson.build
@@ -854,6 +854,24 @@ endif
 
 
 
+###############################################################
+# Library: libcurl
+###############################################################
+
+libcurlopt = get_option('libcurl')
+if not libcurlopt.disabled()
+  # Check for libcurl 7.61.0 or higher (corresponding to RHEL8 and the ability
+  # to explicitly set TLS 1.3 ciphersuites).
+  libcurl = dependency('libcurl', version: '>= 7.61.0', required: libcurlopt)
+  if libcurl.found()
+    cdata.set('USE_LIBCURL', 1)
+  endif
+else
+  libcurl = not_found_dep
+endif
+
+
+
 ###############################################################
 # Library: libxml
 ###############################################################
@@ -3034,6 +3052,10 @@ libpq_deps += [
 
   gssapi,
   ldap_r,
+  # XXX libcurl must link after libgssapi_krb5 on FreeBSD to avoid segfaults
+  # during gss_acquire_cred(). This is possibly related to Curl's Heimdal
+  # dependency on that platform?
+  libcurl,
   libintl,
   ssl,
 ]
@@ -3702,6 +3724,7 @@ if meson.version().version_compare('>=0.57')
       'gss': gssapi,
       'icu': icu,
       'ldap': ldap,
+      'libcurl': libcurl,
       'libxml': libxml,
       'libxslt': libxslt,
       'llvm': llvm,
diff --git a/meson_options.txt b/meson_options.txt
index d9c7ddccbc4..702c4517145 100644
--- a/meson_options.txt
+++ b/meson_options.txt
@@ -100,6 +100,9 @@ option('icu', type: 'feature', value: 'auto',
 option('ldap', type: 'feature', value: 'auto',
   description: 'LDAP support')
 
+option('libcurl', type : 'feature', value: 'auto',
+  description: 'libcurl support')
+
 option('libedit_preferred', type: 'boolean', value: false,
   description: 'Prefer BSD Libedit over GNU Readline')
 
diff --git a/src/Makefile.global.in b/src/Makefile.global.in
index 1278b7744f4..6a745b5984a 100644
--- a/src/Makefile.global.in
+++ b/src/Makefile.global.in
@@ -190,6 +190,7 @@ with_systemd	= @with_systemd@
 with_gssapi	= @with_gssapi@
 with_krb_srvnam	= @with_krb_srvnam@
 with_ldap	= @with_ldap@
+with_libcurl	= @with_libcurl@
 with_libxml	= @with_libxml@
 with_libxslt	= @with_libxslt@
 with_llvm	= @with_llvm@
diff --git a/src/backend/libpq/Makefile b/src/backend/libpq/Makefile
index 6d385fd6a45..98eb2a8242d 100644
--- a/src/backend/libpq/Makefile
+++ b/src/backend/libpq/Makefile
@@ -15,6 +15,7 @@ include $(top_builddir)/src/Makefile.global
 # be-fsstubs is here for historical reasons, probably belongs elsewhere
 
 OBJS = \
+	auth-oauth.o \
 	auth-sasl.o \
 	auth-scram.o \
 	auth.o \
diff --git a/src/backend/libpq/auth-oauth.c b/src/backend/libpq/auth-oauth.c
new file mode 100644
index 00000000000..6155d63a116
--- /dev/null
+++ b/src/backend/libpq/auth-oauth.c
@@ -0,0 +1,860 @@
+/*-------------------------------------------------------------------------
+ *
+ * auth-oauth.c
+ *	  Server-side implementation of the SASL OAUTHBEARER mechanism.
+ *
+ * See the following RFC for more details:
+ * - RFC 7628: https://tools.ietf.org/html/rfc7628
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/backend/libpq/auth-oauth.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include <unistd.h>
+#include <fcntl.h>
+
+#include "common/oauth-common.h"
+#include "fmgr.h"
+#include "lib/stringinfo.h"
+#include "libpq/auth.h"
+#include "libpq/hba.h"
+#include "libpq/oauth.h"
+#include "libpq/sasl.h"
+#include "storage/fd.h"
+#include "storage/ipc.h"
+#include "utils/json.h"
+#include "utils/varlena.h"
+
+/* GUC */
+char	   *oauth_validator_libraries_string = NULL;
+
+static void oauth_get_mechanisms(Port *port, StringInfo buf);
+static void *oauth_init(Port *port, const char *selected_mech, const char *shadow_pass);
+static int	oauth_exchange(void *opaq, const char *input, int inputlen,
+						   char **output, int *outputlen, const char **logdetail);
+
+static void load_validator_library(const char *libname);
+static void shutdown_validator_library(int code, Datum arg);
+
+static ValidatorModuleState *validator_module_state;
+static const OAuthValidatorCallbacks *ValidatorCallbacks;
+
+/* Mechanism declaration */
+const pg_be_sasl_mech pg_be_oauth_mech = {
+	.get_mechanisms = oauth_get_mechanisms,
+	.init = oauth_init,
+	.exchange = oauth_exchange,
+
+	.max_message_length = PG_MAX_AUTH_TOKEN_LENGTH,
+};
+
+/* Valid states for the oauth_exchange() machine. */
+enum oauth_state
+{
+	OAUTH_STATE_INIT = 0,
+	OAUTH_STATE_ERROR,
+	OAUTH_STATE_FINISHED,
+};
+
+/* Mechanism callback state. */
+struct oauth_ctx
+{
+	enum oauth_state state;
+	Port	   *port;
+	const char *issuer;
+	const char *scope;
+};
+
+static char *sanitize_char(char c);
+static char *parse_kvpairs_for_auth(char **input);
+static void generate_error_response(struct oauth_ctx *ctx, char **output, int *outputlen);
+static bool validate(Port *port, const char *auth);
+
+/* Constants seen in an OAUTHBEARER client initial response. */
+#define KVSEP 0x01				/* separator byte for key/value pairs */
+#define AUTH_KEY "auth"			/* key containing the Authorization header */
+#define BEARER_SCHEME "Bearer " /* required header scheme (case-insensitive!) */
+
+/*
+ * Retrieves the OAUTHBEARER mechanism list (currently a single item).
+ *
+ * For a full description of the API, see libpq/sasl.h.
+ */
+static void
+oauth_get_mechanisms(Port *port, StringInfo buf)
+{
+	/* Only OAUTHBEARER is supported. */
+	appendStringInfoString(buf, OAUTHBEARER_NAME);
+	appendStringInfoChar(buf, '\0');
+}
+
+/*
+ * Initializes mechanism state and loads the configured validator module.
+ *
+ * For a full description of the API, see libpq/sasl.h.
+ */
+static void *
+oauth_init(Port *port, const char *selected_mech, const char *shadow_pass)
+{
+	struct oauth_ctx *ctx;
+
+	if (strcmp(selected_mech, OAUTHBEARER_NAME) != 0)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("client selected an invalid SASL authentication mechanism"));
+
+	ctx = palloc0(sizeof(*ctx));
+
+	ctx->state = OAUTH_STATE_INIT;
+	ctx->port = port;
+
+	Assert(port->hba);
+	ctx->issuer = port->hba->oauth_issuer;
+	ctx->scope = port->hba->oauth_scope;
+
+	load_validator_library(port->hba->oauth_validator);
+
+	return ctx;
+}
+
+/*
+ * Implements the OAUTHBEARER SASL exchange (RFC 7628, Sec. 3.2). This pulls
+ * apart the client initial response and validates the Bearer token. It also
+ * handles the dummy error response for a failed handshake, as described in
+ * Sec. 3.2.3.
+ *
+ * For a full description of the API, see libpq/sasl.h.
+ */
+static int
+oauth_exchange(void *opaq, const char *input, int inputlen,
+			   char **output, int *outputlen, const char **logdetail)
+{
+	char	   *input_copy;
+	char	   *p;
+	char		cbind_flag;
+	char	   *auth;
+	int			status;
+
+	struct oauth_ctx *ctx = opaq;
+
+	*output = NULL;
+	*outputlen = -1;
+
+	/*
+	 * If the client didn't include an "Initial Client Response" in the
+	 * SASLInitialResponse message, send an empty challenge, to which the
+	 * client will respond with the same data that usually comes in the
+	 * Initial Client Response.
+	 */
+	if (input == NULL)
+	{
+		Assert(ctx->state == OAUTH_STATE_INIT);
+
+		*output = pstrdup("");
+		*outputlen = 0;
+		return PG_SASL_EXCHANGE_CONTINUE;
+	}
+
+	/*
+	 * Check that the input length agrees with the string length of the input.
+	 */
+	if (inputlen == 0)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("The message is empty."));
+	if (inputlen != strlen(input))
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message length does not match input length."));
+
+	switch (ctx->state)
+	{
+		case OAUTH_STATE_INIT:
+			/* Handle this case below. */
+			break;
+
+		case OAUTH_STATE_ERROR:
+
+			/*
+			 * Only one response is valid for the client during authentication
+			 * failure: a single kvsep.
+			 */
+			if (inputlen != 1 || *input != KVSEP)
+				ereport(ERROR,
+						errcode(ERRCODE_PROTOCOL_VIOLATION),
+						errmsg("malformed OAUTHBEARER message"),
+						errdetail("Client did not send a kvsep response."));
+
+			/* The (failed) handshake is now complete. */
+			ctx->state = OAUTH_STATE_FINISHED;
+			return PG_SASL_EXCHANGE_FAILURE;
+
+		default:
+			elog(ERROR, "invalid OAUTHBEARER exchange state");
+			return PG_SASL_EXCHANGE_FAILURE;
+	}
+
+	/* Handle the client's initial message. */
+	p = input_copy = pstrdup(input);
+
+	/*
+	 * OAUTHBEARER does not currently define a channel binding (so there is no
+	 * OAUTHBEARER-PLUS, and we do not accept a 'p' specifier). We accept a
+	 * 'y' specifier purely for the remote chance that a future specification
+	 * could define one; then future clients can still interoperate with this
+	 * server implementation. 'n' is the expected case.
+	 */
+	cbind_flag = *p;
+	switch (cbind_flag)
+	{
+		case 'p':
+			ereport(ERROR,
+					errcode(ERRCODE_PROTOCOL_VIOLATION),
+					errmsg("malformed OAUTHBEARER message"),
+					errdetail("The server does not support channel binding for OAuth, but the client message includes channel binding data."));
+			break;
+
+		case 'y':				/* fall through */
+		case 'n':
+			p++;
+			if (*p != ',')
+				ereport(ERROR,
+						errcode(ERRCODE_PROTOCOL_VIOLATION),
+						errmsg("malformed OAUTHBEARER message"),
+						errdetail("Comma expected, but found character \"%s\".",
+								  sanitize_char(*p)));
+			p++;
+			break;
+
+		default:
+			ereport(ERROR,
+					errcode(ERRCODE_PROTOCOL_VIOLATION),
+					errmsg("malformed OAUTHBEARER message"),
+					errdetail("Unexpected channel-binding flag \"%s\".",
+							  sanitize_char(cbind_flag)));
+	}
+
+	/*
+	 * Forbid optional authzid (authorization identity).  We don't support it.
+	 */
+	if (*p == 'a')
+		ereport(ERROR,
+				errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				errmsg("client uses authorization identity, but it is not supported"));
+	if (*p != ',')
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Unexpected attribute \"%s\" in client-first-message.",
+						  sanitize_char(*p)));
+	p++;
+
+	/* All remaining fields are separated by the RFC's kvsep (\x01). */
+	if (*p != KVSEP)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Key-value separator expected, but found character \"%s\".",
+						  sanitize_char(*p)));
+	p++;
+
+	auth = parse_kvpairs_for_auth(&p);
+	if (!auth)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message does not contain an auth value."));
+
+	/* We should be at the end of our message. */
+	if (*p)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message contains additional data after the final terminator."));
+
+	if (!validate(ctx->port, auth))
+	{
+		generate_error_response(ctx, output, outputlen);
+
+		ctx->state = OAUTH_STATE_ERROR;
+		status = PG_SASL_EXCHANGE_CONTINUE;
+	}
+	else
+	{
+		ctx->state = OAUTH_STATE_FINISHED;
+		status = PG_SASL_EXCHANGE_SUCCESS;
+	}
+
+	/* Don't let extra copies of the bearer token hang around. */
+	explicit_bzero(input_copy, inputlen);
+
+	return status;
+}
+
+/*
+ * Convert an arbitrary byte to printable form.  For error messages.
+ *
+ * If it's a printable ASCII character, print it as a single character.
+ * otherwise, print it in hex.
+ *
+ * The returned pointer points to a static buffer.
+ */
+static char *
+sanitize_char(char c)
+{
+	static char buf[5];
+
+	if (c >= 0x21 && c <= 0x7E)
+		snprintf(buf, sizeof(buf), "'%c'", c);
+	else
+		snprintf(buf, sizeof(buf), "0x%02x", (unsigned char) c);
+	return buf;
+}
+
+/*
+ * Performs syntactic validation of a key and value from the initial client
+ * response. (Semantic validation of interesting values must be performed
+ * later.)
+ */
+static void
+validate_kvpair(const char *key, const char *val)
+{
+	/*-----
+	 * From Sec 3.1:
+	 *     key            = 1*(ALPHA)
+	 */
+	static const char *key_allowed_set =
+		"abcdefghijklmnopqrstuvwxyz"
+		"ABCDEFGHIJKLMNOPQRSTUVWXYZ";
+
+	size_t		span;
+
+	if (!key[0])
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message contains an empty key name."));
+
+	span = strspn(key, key_allowed_set);
+	if (key[span] != '\0')
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message contains an invalid key name."));
+
+	/*-----
+	 * From Sec 3.1:
+	 *     value          = *(VCHAR / SP / HTAB / CR / LF )
+	 *
+	 * The VCHAR (visible character) class is large; a loop is more
+	 * straightforward than strspn().
+	 */
+	for (; *val; ++val)
+	{
+		if (0x21 <= *val && *val <= 0x7E)
+			continue;			/* VCHAR */
+
+		switch (*val)
+		{
+			case ' ':
+			case '\t':
+			case '\r':
+			case '\n':
+				continue;		/* SP, HTAB, CR, LF */
+
+			default:
+				ereport(ERROR,
+						errcode(ERRCODE_PROTOCOL_VIOLATION),
+						errmsg("malformed OAUTHBEARER message"),
+						errdetail("Message contains an invalid value."));
+		}
+	}
+}
+
+/*
+ * Consumes all kvpairs in an OAUTHBEARER exchange message. If the "auth" key is
+ * found, its value is returned.
+ */
+static char *
+parse_kvpairs_for_auth(char **input)
+{
+	char	   *pos = *input;
+	char	   *auth = NULL;
+
+	/*----
+	 * The relevant ABNF, from Sec. 3.1:
+	 *
+	 *     kvsep          = %x01
+	 *     key            = 1*(ALPHA)
+	 *     value          = *(VCHAR / SP / HTAB / CR / LF )
+	 *     kvpair         = key "=" value kvsep
+	 *   ;;gs2-header     = See RFC 5801
+	 *     client-resp    = (gs2-header kvsep *kvpair kvsep) / kvsep
+	 *
+	 * By the time we reach this code, the gs2-header and initial kvsep have
+	 * already been validated. We start at the beginning of the first kvpair.
+	 */
+
+	while (*pos)
+	{
+		char	   *end;
+		char	   *sep;
+		char	   *key;
+		char	   *value;
+
+		/*
+		 * Find the end of this kvpair. Note that input is null-terminated by
+		 * the SASL code, so the strchr() is bounded.
+		 */
+		end = strchr(pos, KVSEP);
+		if (!end)
+			ereport(ERROR,
+					errcode(ERRCODE_PROTOCOL_VIOLATION),
+					errmsg("malformed OAUTHBEARER message"),
+					errdetail("Message contains an unterminated key/value pair."));
+		*end = '\0';
+
+		if (pos == end)
+		{
+			/* Empty kvpair, signifying the end of the list. */
+			*input = pos + 1;
+			return auth;
+		}
+
+		/*
+		 * Find the end of the key name.
+		 */
+		sep = strchr(pos, '=');
+		if (!sep)
+			ereport(ERROR,
+					errcode(ERRCODE_PROTOCOL_VIOLATION),
+					errmsg("malformed OAUTHBEARER message"),
+					errdetail("Message contains a key without a value."));
+		*sep = '\0';
+
+		/* Both key and value are now safely terminated. */
+		key = pos;
+		value = sep + 1;
+		validate_kvpair(key, value);
+
+		if (strcmp(key, AUTH_KEY) == 0)
+		{
+			if (auth)
+				ereport(ERROR,
+						errcode(ERRCODE_PROTOCOL_VIOLATION),
+						errmsg("malformed OAUTHBEARER message"),
+						errdetail("Message contains multiple auth values."));
+
+			auth = value;
+		}
+		else
+		{
+			/*
+			 * The RFC also defines the host and port keys, but they are not
+			 * required for OAUTHBEARER and we do not use them. Also, per Sec.
+			 * 3.1, any key/value pairs we don't recognize must be ignored.
+			 */
+		}
+
+		/* Move to the next pair. */
+		pos = end + 1;
+	}
+
+	ereport(ERROR,
+			errcode(ERRCODE_PROTOCOL_VIOLATION),
+			errmsg("malformed OAUTHBEARER message"),
+			errdetail("Message did not contain a final terminator."));
+
+	pg_unreachable();
+	return NULL;
+}
+
+/*
+ * Builds the JSON response for failed authentication (RFC 7628, Sec. 3.2.2).
+ * This contains the required scopes for entry and a pointer to the OAuth/OpenID
+ * discovery document, which the client may use to conduct its OAuth flow.
+ */
+static void
+generate_error_response(struct oauth_ctx *ctx, char **output, int *outputlen)
+{
+	StringInfoData buf;
+	StringInfoData issuer;
+
+	/*
+	 * The admin needs to set an issuer and scope for OAuth to work. There's
+	 * not really a way to hide this from the user, either, because we can't
+	 * choose a "default" issuer, so be honest in the failure message.
+	 *
+	 * TODO: see if there's a better place to fail, earlier than this.
+	 */
+	if (!ctx->issuer || !ctx->scope)
+		ereport(FATAL,
+				errcode(ERRCODE_INTERNAL_ERROR),
+				errmsg("OAuth is not properly configured for this user"),
+				errdetail_log("The issuer and scope parameters must be set in pg_hba.conf."));
+
+	/*
+	 * Build a default .well-known URI based on our issuer, unless the HBA has
+	 * already provided one.
+	 */
+	initStringInfo(&issuer);
+	appendStringInfoString(&issuer, ctx->issuer);
+	if (strstr(ctx->issuer, "/.well-known/") == NULL)
+		appendStringInfoString(&issuer, "/.well-known/openid-configuration");
+
+	initStringInfo(&buf);
+
+	/*
+	 * Escaping the string here is belt-and-suspenders defensive programming
+	 * since escapable characters aren't valid in either the issuer URI or the
+	 * scope list, but the HBA doesn't enforce that yet.
+	 */
+	appendStringInfoString(&buf, "{ \"status\": \"invalid_token\", ");
+
+	appendStringInfoString(&buf, "\"openid-configuration\": ");
+	escape_json(&buf, issuer.data);
+	pfree(issuer.data);
+
+	appendStringInfoString(&buf, ", \"scope\": ");
+	escape_json(&buf, ctx->scope);
+
+	appendStringInfoString(&buf, " }");
+
+	*output = buf.data;
+	*outputlen = buf.len;
+}
+
+/*-----
+ * Validates the provided Authorization header and returns the token from
+ * within it. NULL is returned on validation failure.
+ *
+ * Only Bearer tokens are accepted. The ABNF is defined in RFC 6750, Sec.
+ * 2.1:
+ *
+ *      b64token    = 1*( ALPHA / DIGIT /
+ *                        "-" / "." / "_" / "~" / "+" / "/" ) *"="
+ *      credentials = "Bearer" 1*SP b64token
+ *
+ * The "credentials" construction is what we receive in our auth value.
+ *
+ * Since that spec is subordinate to HTTP (i.e. the HTTP Authorization
+ * header format; RFC 9110 Sec. 11), the "Bearer" scheme string must be
+ * compared case-insensitively. (This is not mentioned in RFC 6750, but the
+ * OAUTHBEARER spec points it out: RFC 7628 Sec. 4.)
+ *
+ * Invalid formats are technically a protocol violation, but we shouldn't
+ * reflect any information about the sensitive Bearer token back to the
+ * client; log at COMMERROR instead.
+ */
+static const char *
+validate_token_format(const char *header)
+{
+	size_t		span;
+	const char *token;
+	static const char *const b64token_allowed_set =
+		"abcdefghijklmnopqrstuvwxyz"
+		"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+		"0123456789-._~+/";
+
+	/* Missing auth headers should be handled by the caller. */
+	Assert(header);
+
+	if (header[0] == '\0')
+	{
+		/*
+		 * A completely empty auth header represents a query for
+		 * authentication parameters. The client expects it to fail; there's
+		 * no need to make any extra noise in the logs.
+		 *
+		 * TODO: should we find a way to return STATUS_EOF at the top level,
+		 * to suppress the authentication error entirely?
+		 */
+		return NULL;
+	}
+
+	if (pg_strncasecmp(header, BEARER_SCHEME, strlen(BEARER_SCHEME)))
+	{
+		ereport(COMMERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAuth bearer token"),
+				errdetail_log("Client response indicated a non-Bearer authentication scheme."));
+		return NULL;
+	}
+
+	/* Pull the bearer token out of the auth value. */
+	token = header + strlen(BEARER_SCHEME);
+
+	/* Swallow any additional spaces. */
+	while (*token == ' ')
+		token++;
+
+	/* Tokens must not be empty. */
+	if (!*token)
+	{
+		ereport(COMMERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAuth bearer token"),
+				errdetail_log("Bearer token is empty."));
+		return NULL;
+	}
+
+	/*
+	 * Make sure the token contains only allowed characters. Tokens may end
+	 * with any number of '=' characters.
+	 */
+	span = strspn(token, b64token_allowed_set);
+	while (token[span] == '=')
+		span++;
+
+	if (token[span] != '\0')
+	{
+		/*
+		 * This error message could be more helpful by printing the
+		 * problematic character(s), but that'd be a bit like printing a piece
+		 * of someone's password into the logs.
+		 */
+		ereport(COMMERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAuth bearer token"),
+				errdetail_log("Bearer token is not in the correct format."));
+		return NULL;
+	}
+
+	return token;
+}
+
+/*
+ * Checks that the "auth" kvpair in the client response contains a syntactically
+ * valid Bearer token, then passes it along to the loaded validator module for
+ * authorization. Returns true if validation succeeds.
+ */
+static bool
+validate(Port *port, const char *auth)
+{
+	int			map_status;
+	ValidatorModuleResult *ret;
+	const char *token;
+	bool		status;
+
+	/* Ensure that we have a correct token to validate */
+	if (!(token = validate_token_format(auth)))
+		return false;
+
+	/*
+	 * Ensure that we have a validation library loaded, this should always be
+	 * the case and an error here is indicative of a bug.
+	 */
+	if (!ValidatorCallbacks || !ValidatorCallbacks->validate_cb)
+		ereport(FATAL,
+				errcode(ERRCODE_INTERNAL_ERROR),
+				errmsg("validation of OAuth token requested without a validator loaded"));
+
+	/* Call the validation function from the validator module */
+	ret = ValidatorCallbacks->validate_cb(validator_module_state,
+										  token, port->user_name);
+	if (ret == NULL)
+	{
+		ereport(LOG, errmsg("internal error in OAuth validator module"));
+		return false;
+	}
+
+	/*
+	 * Log any authentication results even if the token isn't authorized; it
+	 * might be useful for auditing or troubleshooting.
+	 */
+	if (ret->authn_id)
+		set_authn_id(port, ret->authn_id);
+
+	if (!ret->authorized)
+	{
+		ereport(LOG,
+				errmsg("OAuth bearer authentication failed for user \"%s\"",
+					   port->user_name),
+				errdetail_log("Validator failed to authorize the provided token."));
+
+		status = false;
+		goto cleanup;
+	}
+
+	if (port->hba->oauth_skip_usermap)
+	{
+		/*
+		 * If the validator is our authorization authority, we're done.
+		 * Authentication may or may not have been performed depending on the
+		 * validator implementation; all that matters is that the validator
+		 * says the user can log in with the target role.
+		 */
+		status = true;
+		goto cleanup;
+	}
+
+	/* Make sure the validator authenticated the user. */
+	if (ret->authn_id == NULL || ret->authn_id[0] == '\0')
+	{
+		ereport(LOG,
+				errmsg("OAuth bearer authentication failed for user \"%s\"",
+					   port->user_name),
+				errdetail_log("Validator provided no identity."));
+
+		status = false;
+		goto cleanup;
+	}
+
+	/* Finally, check the user map. */
+	map_status = check_usermap(port->hba->usermap, port->user_name,
+							   MyClientConnectionInfo.authn_id, false);
+	status = (map_status == STATUS_OK);
+
+cleanup:
+
+	/*
+	 * Clear and free the validation result from the validator module once
+	 * we're done with it.
+	 */
+	if (ret->authn_id != NULL)
+		pfree(ret->authn_id);
+	pfree(ret);
+
+	return status;
+}
+
+/*
+ * load_validator_library
+ *
+ * Load the configured validator library in order to perform token validation.
+ * There is no built-in fallback since validation is implementation specific. If
+ * no validator library is configured, or if it fails to load, then error out
+ * since token validation won't be possible.
+ */
+static void
+load_validator_library(const char *libname)
+{
+	OAuthValidatorModuleInit validator_init;
+
+	Assert(libname && *libname);
+
+	validator_init = (OAuthValidatorModuleInit)
+		load_external_function(libname, "_PG_oauth_validator_module_init",
+							   false, NULL);
+
+	/*
+	 * The validator init function is required since it will set the callbacks
+	 * for the validator library.
+	 */
+	if (validator_init == NULL)
+		ereport(ERROR,
+				errmsg("%s module \"%s\" must define the symbol %s",
+					   "OAuth validator", libname, "_PG_oauth_validator_module_init"));
+
+	ValidatorCallbacks = (*validator_init) ();
+	Assert(ValidatorCallbacks);
+
+	/* Allocate memory for validator library private state data */
+	validator_module_state = (ValidatorModuleState *) palloc0(sizeof(ValidatorModuleState));
+	if (ValidatorCallbacks->startup_cb != NULL)
+		ValidatorCallbacks->startup_cb(validator_module_state);
+
+	before_shmem_exit(shutdown_validator_library, 0);
+}
+
+/*
+ * Call the validator module's shutdown callback, if one is provided. This is
+ * invoked via before_shmem_exit().
+ */
+static void
+shutdown_validator_library(int code, Datum arg)
+{
+	if (ValidatorCallbacks->shutdown_cb != NULL)
+		ValidatorCallbacks->shutdown_cb(validator_module_state);
+}
+
+/*
+ * Ensure an OAuth validator named in the HBA is permitted by the configuration.
+ *
+ * If the validator is currently unset and exactly one library is declared in
+ * oauth_validator_libraries, then that library will be used as the validator.
+ * Otherwise the name must be present in the list of oauth_validator_libraries.
+ */
+bool
+check_oauth_validator(HbaLine *hbaline, int elevel, char **err_msg)
+{
+	int			line_num = hbaline->linenumber;
+	char	   *file_name = hbaline->sourcefile;
+	char	   *rawstring;
+	List	   *elemlist = NIL;
+
+	*err_msg = NULL;
+
+	if (oauth_validator_libraries_string[0] == '\0')
+	{
+		ereport(elevel,
+				errcode(ERRCODE_CONFIG_FILE_ERROR),
+				errmsg("oauth_validator_libraries must be set for authentication method %s",
+					   "oauth"),
+				errcontext("line %d of configuration file \"%s\"",
+						   line_num, file_name));
+		*err_msg = psprintf("oauth_validator_libraries must be set for authentication method %s",
+							"oauth");
+		return false;
+	}
+
+	/* SplitDirectoriesString needs a modifiable copy */
+	rawstring = pstrdup(oauth_validator_libraries_string);
+
+	if (!SplitDirectoriesString(rawstring, ',', &elemlist))
+	{
+		/* syntax error in list */
+		ereport(elevel,
+				errcode(ERRCODE_CONFIG_FILE_ERROR),
+				errmsg("invalid list syntax in parameter \"%s\"",
+					   "oauth_validator_libraries"));
+		*err_msg = psprintf("invalid list syntax in parameter \"%s\"",
+							"oauth_validator_libraries");
+		goto done;
+	}
+
+	if (!hbaline->oauth_validator)
+	{
+		if (elemlist->length == 1)
+		{
+			hbaline->oauth_validator = pstrdup(linitial(elemlist));
+			goto done;
+		}
+
+		ereport(elevel,
+				errcode(ERRCODE_CONFIG_FILE_ERROR),
+				errmsg("authentication method \"oauth\" requires argument \"validator\" to be set when oauth_validator_libraries contains multiple options"),
+				errcontext("line %d of configuration file \"%s\"",
+						   line_num, file_name));
+		*err_msg = "authentication method \"oauth\" requires argument \"validator\" to be set when oauth_validator_libraries contains multiple options";
+		goto done;
+	}
+
+	foreach_ptr(char, allowed, elemlist)
+	{
+		if (strcmp(allowed, hbaline->oauth_validator) == 0)
+			goto done;
+	}
+
+	ereport(elevel,
+			errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+			errmsg("validator \"%s\" is not permitted by %s",
+				   hbaline->oauth_validator, "oauth_validator_libraries"),
+			errcontext("line %d of configuration file \"%s\"",
+					   line_num, file_name));
+	*err_msg = psprintf("validator \"%s\" is not permitted by %s",
+						hbaline->oauth_validator, "oauth_validator_libraries");
+
+done:
+	list_free_deep(elemlist);
+	pfree(rawstring);
+
+	return (*err_msg == NULL);
+}
diff --git a/src/backend/libpq/auth.c b/src/backend/libpq/auth.c
index d6ef32cc823..0f65014e64f 100644
--- a/src/backend/libpq/auth.c
+++ b/src/backend/libpq/auth.c
@@ -29,6 +29,7 @@
 #include "libpq/auth.h"
 #include "libpq/crypt.h"
 #include "libpq/libpq.h"
+#include "libpq/oauth.h"
 #include "libpq/pqformat.h"
 #include "libpq/sasl.h"
 #include "libpq/scram.h"
@@ -45,7 +46,6 @@
  */
 static void auth_failed(Port *port, int status, const char *logdetail);
 static char *recv_password_packet(Port *port);
-static void set_authn_id(Port *port, const char *id);
 
 
 /*----------------------------------------------------------------
@@ -289,6 +289,9 @@ auth_failed(Port *port, int status, const char *logdetail)
 		case uaRADIUS:
 			errstr = gettext_noop("RADIUS authentication failed for user \"%s\"");
 			break;
+		case uaOAuth:
+			errstr = gettext_noop("OAuth bearer authentication failed for user \"%s\"");
+			break;
 		default:
 			errstr = gettext_noop("authentication failed for user \"%s\": invalid authentication method");
 			break;
@@ -324,7 +327,7 @@ auth_failed(Port *port, int status, const char *logdetail)
  * lifetime of MyClientConnectionInfo, so it is safe to pass a string that is
  * managed by an external library.
  */
-static void
+void
 set_authn_id(Port *port, const char *id)
 {
 	Assert(id);
@@ -611,6 +614,9 @@ ClientAuthentication(Port *port)
 		case uaTrust:
 			status = STATUS_OK;
 			break;
+		case uaOAuth:
+			status = CheckSASLAuth(&pg_be_oauth_mech, port, NULL, NULL);
+			break;
 	}
 
 	if ((status == STATUS_OK && port->hba->clientcert == clientCertFull)
diff --git a/src/backend/libpq/hba.c b/src/backend/libpq/hba.c
index 510c9ffc6d7..332fad27835 100644
--- a/src/backend/libpq/hba.c
+++ b/src/backend/libpq/hba.c
@@ -32,6 +32,7 @@
 #include "libpq/hba.h"
 #include "libpq/ifaddr.h"
 #include "libpq/libpq-be.h"
+#include "libpq/oauth.h"
 #include "postmaster/postmaster.h"
 #include "regex/regex.h"
 #include "replication/walsender.h"
@@ -114,7 +115,8 @@ static const char *const UserAuthName[] =
 	"ldap",
 	"cert",
 	"radius",
-	"peer"
+	"peer",
+	"oauth",
 };
 
 /*
@@ -1747,6 +1749,8 @@ parse_hba_line(TokenizedAuthLine *tok_line, int elevel)
 #endif
 	else if (strcmp(token->string, "radius") == 0)
 		parsedline->auth_method = uaRADIUS;
+	else if (strcmp(token->string, "oauth") == 0)
+		parsedline->auth_method = uaOAuth;
 	else
 	{
 		ereport(elevel,
@@ -2039,6 +2043,36 @@ parse_hba_line(TokenizedAuthLine *tok_line, int elevel)
 		parsedline->clientcert = clientCertFull;
 	}
 
+	/*
+	 * Enforce proper configuration of OAuth authentication.
+	 */
+	if (parsedline->auth_method == uaOAuth)
+	{
+		MANDATORY_AUTH_ARG(parsedline->oauth_scope, "scope", "oauth");
+		MANDATORY_AUTH_ARG(parsedline->oauth_issuer, "issuer", "oauth");
+
+		/* Ensure a validator library is set and permitted by the config. */
+		if (!check_oauth_validator(parsedline, elevel, err_msg))
+			return NULL;
+
+		/*
+		 * Supplying a usermap combined with the option to skip usermapping is
+		 * nonsensical and indicates a configuration error.
+		 */
+		if (parsedline->oauth_skip_usermap && parsedline->usermap != NULL)
+		{
+			ereport(elevel,
+					errcode(ERRCODE_CONFIG_FILE_ERROR),
+			/* translator: strings are replaced with hba options */
+					errmsg("%s cannot be used in combination with %s",
+						   "map", "delegate_ident_mapping"),
+					errcontext("line %d of configuration file \"%s\"",
+							   line_num, file_name));
+			*err_msg = "map cannot be used in combination with delegate_ident_mapping";
+			return NULL;
+		}
+	}
+
 	return parsedline;
 }
 
@@ -2066,8 +2100,9 @@ parse_hba_auth_opt(char *name, char *val, HbaLine *hbaline,
 			hbaline->auth_method != uaPeer &&
 			hbaline->auth_method != uaGSS &&
 			hbaline->auth_method != uaSSPI &&
-			hbaline->auth_method != uaCert)
-			INVALID_AUTH_OPTION("map", gettext_noop("ident, peer, gssapi, sspi, and cert"));
+			hbaline->auth_method != uaCert &&
+			hbaline->auth_method != uaOAuth)
+			INVALID_AUTH_OPTION("map", gettext_noop("ident, peer, gssapi, sspi, cert, and oauth"));
 		hbaline->usermap = pstrdup(val);
 	}
 	else if (strcmp(name, "clientcert") == 0)
@@ -2450,6 +2485,29 @@ parse_hba_auth_opt(char *name, char *val, HbaLine *hbaline,
 		hbaline->radiusidentifiers = parsed_identifiers;
 		hbaline->radiusidentifiers_s = pstrdup(val);
 	}
+	else if (strcmp(name, "issuer") == 0)
+	{
+		REQUIRE_AUTH_OPTION(uaOAuth, "issuer", "oauth");
+		hbaline->oauth_issuer = pstrdup(val);
+	}
+	else if (strcmp(name, "scope") == 0)
+	{
+		REQUIRE_AUTH_OPTION(uaOAuth, "scope", "oauth");
+		hbaline->oauth_scope = pstrdup(val);
+	}
+	else if (strcmp(name, "validator") == 0)
+	{
+		REQUIRE_AUTH_OPTION(uaOAuth, "validator", "oauth");
+		hbaline->oauth_validator = pstrdup(val);
+	}
+	else if (strcmp(name, "delegate_ident_mapping") == 0)
+	{
+		REQUIRE_AUTH_OPTION(uaOAuth, "delegate_ident_mapping", "oauth");
+		if (strcmp(val, "1") == 0)
+			hbaline->oauth_skip_usermap = true;
+		else
+			hbaline->oauth_skip_usermap = false;
+	}
 	else
 	{
 		ereport(elevel,
diff --git a/src/backend/libpq/meson.build b/src/backend/libpq/meson.build
index 0f0421037e4..31aa2faae1e 100644
--- a/src/backend/libpq/meson.build
+++ b/src/backend/libpq/meson.build
@@ -1,6 +1,7 @@
 # Copyright (c) 2022-2025, PostgreSQL Global Development Group
 
 backend_sources += files(
+  'auth-oauth.c',
   'auth-sasl.c',
   'auth-scram.c',
   'auth.c',
diff --git a/src/backend/libpq/pg_hba.conf.sample b/src/backend/libpq/pg_hba.conf.sample
index bad13497a34..b64c8dea97c 100644
--- a/src/backend/libpq/pg_hba.conf.sample
+++ b/src/backend/libpq/pg_hba.conf.sample
@@ -53,8 +53,8 @@
 # directly connected to.
 #
 # METHOD can be "trust", "reject", "md5", "password", "scram-sha-256",
-# "gss", "sspi", "ident", "peer", "pam", "ldap", "radius" or "cert".
-# Note that "password" sends passwords in clear text; "md5" or
+# "gss", "sspi", "ident", "peer", "pam", "oauth", "ldap", "radius" or
+# "cert".  Note that "password" sends passwords in clear text; "md5" or
 # "scram-sha-256" are preferred since they send encrypted passwords.
 #
 # OPTIONS are a set of options for the authentication in the format
diff --git a/src/backend/utils/adt/hbafuncs.c b/src/backend/utils/adt/hbafuncs.c
index 03c38e8c451..b62c3d944cf 100644
--- a/src/backend/utils/adt/hbafuncs.c
+++ b/src/backend/utils/adt/hbafuncs.c
@@ -152,6 +152,25 @@ get_hba_options(HbaLine *hba)
 				CStringGetTextDatum(psprintf("radiusports=%s", hba->radiusports_s));
 	}
 
+	if (hba->auth_method == uaOAuth)
+	{
+		if (hba->oauth_issuer)
+			options[noptions++] =
+				CStringGetTextDatum(psprintf("issuer=%s", hba->oauth_issuer));
+
+		if (hba->oauth_scope)
+			options[noptions++] =
+				CStringGetTextDatum(psprintf("scope=%s", hba->oauth_scope));
+
+		if (hba->oauth_validator)
+			options[noptions++] =
+				CStringGetTextDatum(psprintf("validator=%s", hba->oauth_validator));
+
+		if (hba->oauth_skip_usermap)
+			options[noptions++] =
+				CStringGetTextDatum(psprintf("delegate_ident_mapping=true"));
+	}
+
 	/* If you add more options, consider increasing MAX_HBA_OPTIONS. */
 	Assert(noptions <= MAX_HBA_OPTIONS);
 
diff --git a/src/backend/utils/misc/guc_tables.c b/src/backend/utils/misc/guc_tables.c
index 38cb9e970d5..db582d2d62c 100644
--- a/src/backend/utils/misc/guc_tables.c
+++ b/src/backend/utils/misc/guc_tables.c
@@ -49,6 +49,7 @@
 #include "jit/jit.h"
 #include "libpq/auth.h"
 #include "libpq/libpq.h"
+#include "libpq/oauth.h"
 #include "libpq/scram.h"
 #include "nodes/queryjumble.h"
 #include "optimizer/cost.h"
@@ -4823,6 +4824,17 @@ struct config_string ConfigureNamesString[] =
 		check_restrict_nonsystem_relation_kind, assign_restrict_nonsystem_relation_kind, NULL
 	},
 
+	{
+		{"oauth_validator_libraries", PGC_SIGHUP, CONN_AUTH_AUTH,
+			gettext_noop("Lists libraries that may be called to validate OAuth v2 bearer tokens."),
+			NULL,
+			GUC_LIST_INPUT | GUC_LIST_QUOTE | GUC_SUPERUSER_ONLY
+		},
+		&oauth_validator_libraries_string,
+		"",
+		NULL, NULL, NULL
+	},
+
 	/* End-of-list marker */
 	{
 		{NULL, 0, 0, NULL, NULL}, NULL, NULL, NULL, NULL, NULL
diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample
index 079efa1baa7..378aa8438d6 100644
--- a/src/backend/utils/misc/postgresql.conf.sample
+++ b/src/backend/utils/misc/postgresql.conf.sample
@@ -121,6 +121,9 @@
 #ssl_passphrase_command = ''
 #ssl_passphrase_command_supports_reload = off
 
+# OAuth
+#oauth_validator_libraries = ''
+
 
 #------------------------------------------------------------------------------
 # RESOURCE USAGE (except WAL)
diff --git a/src/include/common/oauth-common.h b/src/include/common/oauth-common.h
new file mode 100644
index 00000000000..8fe56267780
--- /dev/null
+++ b/src/include/common/oauth-common.h
@@ -0,0 +1,19 @@
+/*-------------------------------------------------------------------------
+ *
+ * oauth-common.h
+ *		Declarations for helper functions used for OAuth/OIDC authentication
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/include/common/oauth-common.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef OAUTH_COMMON_H
+#define OAUTH_COMMON_H
+
+/* Name of SASL mechanism per IANA */
+#define OAUTHBEARER_NAME "OAUTHBEARER"
+
+#endif							/* OAUTH_COMMON_H */
diff --git a/src/include/libpq/auth.h b/src/include/libpq/auth.h
index 902c5f6de32..25b5742068f 100644
--- a/src/include/libpq/auth.h
+++ b/src/include/libpq/auth.h
@@ -39,6 +39,7 @@ extern PGDLLIMPORT bool pg_gss_accept_delegation;
 extern void ClientAuthentication(Port *port);
 extern void sendAuthRequest(Port *port, AuthRequest areq, const char *extradata,
 							int extralen);
+extern void set_authn_id(Port *port, const char *id);
 
 /* Hook for plugins to get control in ClientAuthentication() */
 typedef void (*ClientAuthentication_hook_type) (Port *, int);
diff --git a/src/include/libpq/hba.h b/src/include/libpq/hba.h
index b20d0051f7d..3657f182db3 100644
--- a/src/include/libpq/hba.h
+++ b/src/include/libpq/hba.h
@@ -39,7 +39,8 @@ typedef enum UserAuth
 	uaCert,
 	uaRADIUS,
 	uaPeer,
-#define USER_AUTH_LAST uaPeer	/* Must be last value of this enum */
+	uaOAuth,
+#define USER_AUTH_LAST uaOAuth	/* Must be last value of this enum */
 } UserAuth;
 
 /*
@@ -135,6 +136,10 @@ typedef struct HbaLine
 	char	   *radiusidentifiers_s;
 	List	   *radiusports;
 	char	   *radiusports_s;
+	char	   *oauth_issuer;
+	char	   *oauth_scope;
+	char	   *oauth_validator;
+	bool		oauth_skip_usermap;
 } HbaLine;
 
 typedef struct IdentLine
diff --git a/src/include/libpq/oauth.h b/src/include/libpq/oauth.h
new file mode 100644
index 00000000000..4fcdda74305
--- /dev/null
+++ b/src/include/libpq/oauth.h
@@ -0,0 +1,54 @@
+/*-------------------------------------------------------------------------
+ *
+ * oauth.h
+ *	  Interface to libpq/auth-oauth.c
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/include/libpq/oauth.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PG_OAUTH_H
+#define PG_OAUTH_H
+
+#include "libpq/libpq-be.h"
+#include "libpq/sasl.h"
+
+extern PGDLLIMPORT char *oauth_validator_libraries_string;
+
+typedef struct ValidatorModuleState
+{
+	void	   *private_data;
+} ValidatorModuleState;
+
+typedef struct ValidatorModuleResult
+{
+	bool		authorized;
+	char	   *authn_id;
+} ValidatorModuleResult;
+
+typedef void (*ValidatorStartupCB) (ValidatorModuleState *state);
+typedef void (*ValidatorShutdownCB) (ValidatorModuleState *state);
+typedef ValidatorModuleResult *(*ValidatorValidateCB) (ValidatorModuleState *state, const char *token, const char *role);
+
+typedef struct OAuthValidatorCallbacks
+{
+	ValidatorStartupCB startup_cb;
+	ValidatorShutdownCB shutdown_cb;
+	ValidatorValidateCB validate_cb;
+} OAuthValidatorCallbacks;
+
+typedef const OAuthValidatorCallbacks *(*OAuthValidatorModuleInit) (void);
+extern PGDLLEXPORT const OAuthValidatorCallbacks *_PG_oauth_validator_module_init(void);
+
+/* Implementation */
+extern const pg_be_sasl_mech pg_be_oauth_mech;
+
+/*
+ * Ensure a validator named in the HBA is permitted by the configuration.
+ */
+extern bool check_oauth_validator(HbaLine *hba, int elevel, char **err_msg);
+
+#endif							/* PG_OAUTH_H */
diff --git a/src/include/pg_config.h.in b/src/include/pg_config.h.in
index 07b2f798abd..9b1ed7996d3 100644
--- a/src/include/pg_config.h.in
+++ b/src/include/pg_config.h.in
@@ -229,6 +229,9 @@
 /* Define to 1 if you have the `crypto' library (-lcrypto). */
 #undef HAVE_LIBCRYPTO
 
+/* Define to 1 if you have the `curl' library (-lcurl). */
+#undef HAVE_LIBCURL
+
 /* Define to 1 if you have the `ldap' library (-lldap). */
 #undef HAVE_LIBLDAP
 
@@ -663,6 +666,9 @@
 /* Define to 1 to build with LDAP support. (--with-ldap) */
 #undef USE_LDAP
 
+/* Define to 1 to build with libcurl support. (--with-libcurl) */
+#undef USE_LIBCURL
+
 /* Define to 1 to build with XML support. (--with-libxml) */
 #undef USE_LIBXML
 
diff --git a/src/interfaces/libpq/Makefile b/src/interfaces/libpq/Makefile
index 6a0def7273c..e9422888e3e 100644
--- a/src/interfaces/libpq/Makefile
+++ b/src/interfaces/libpq/Makefile
@@ -31,6 +31,7 @@ endif
 
 OBJS = \
 	$(WIN32RES) \
+	fe-auth-oauth.o \
 	fe-auth-scram.o \
 	fe-cancel.o \
 	fe-connect.o \
@@ -63,6 +64,10 @@ OBJS += \
 	fe-secure-gssapi.o
 endif
 
+ifeq ($(with_libcurl),yes)
+OBJS += fe-auth-oauth-curl.o
+endif
+
 ifeq ($(PORTNAME), cygwin)
 override shlib = cyg$(NAME)$(DLSUFFIX)
 endif
@@ -81,7 +86,7 @@ endif
 # that are built correctly for use in a shlib.
 SHLIB_LINK_INTERNAL = -lpgcommon_shlib -lpgport_shlib
 ifneq ($(PORTNAME), win32)
-SHLIB_LINK += $(filter -lcrypt -ldes -lcom_err -lcrypto -lk5crypto -lkrb5 -lgssapi_krb5 -lgss -lgssapi -lssl -lsocket -lnsl -lresolv -lintl -lm, $(LIBS)) $(LDAP_LIBS_FE) $(PTHREAD_LIBS)
+SHLIB_LINK += $(filter -lcrypt -ldes -lcom_err -lcrypto -lk5crypto -lkrb5 -lgssapi_krb5 -lgss -lgssapi -lssl -lcurl -lsocket -lnsl -lresolv -lintl -lm, $(LIBS)) $(LDAP_LIBS_FE) $(PTHREAD_LIBS)
 else
 SHLIB_LINK += $(filter -lcrypt -ldes -lcom_err -lcrypto -lk5crypto -lkrb5 -lgssapi32 -lssl -lsocket -lnsl -lresolv -lintl -lm $(PTHREAD_LIBS), $(LIBS)) $(LDAP_LIBS_FE)
 endif
@@ -110,6 +115,8 @@ backend_src = $(top_srcdir)/src/backend
 # which seems to insert references to that even in pure C code. Excluding
 # __tsan_func_exit is necessary when using ThreadSanitizer data race detector
 # which use this function for instrumentation of function exit.
+# libcurl registers an exit handler in the memory debugging code when running
+# with LeakSanitizer.
 # Skip the test when profiling, as gcc may insert exit() calls for that.
 # Also skip the test on platforms where libpq infrastructure may be provided
 # by statically-linked libraries, as we can't expect them to honor this
@@ -117,7 +124,7 @@ backend_src = $(top_srcdir)/src/backend
 libpq-refs-stamp: $(shlib)
 ifneq ($(enable_coverage), yes)
 ifeq (,$(filter solaris,$(PORTNAME)))
-	@if nm -A -u $< 2>/dev/null | grep -v -e __cxa_atexit -e __tsan_func_exit | grep exit; then \
+	@if nm -A -u $< 2>/dev/null | grep -v -e __cxa_atexit -e __tsan_func_exit -e _atexit | grep exit; then \
 		echo 'libpq must not be calling any function which invokes exit'; exit 1; \
 	fi
 endif
diff --git a/src/interfaces/libpq/exports.txt b/src/interfaces/libpq/exports.txt
index 2ad2cbf5ca3..9b789cbec0b 100644
--- a/src/interfaces/libpq/exports.txt
+++ b/src/interfaces/libpq/exports.txt
@@ -206,3 +206,6 @@ PQsocketPoll              203
 PQsetChunkedRowsMode      204
 PQgetCurrentTimeUSec      205
 PQservice                 206
+PQsetAuthDataHook         207
+PQgetAuthDataHook         208
+PQdefaultAuthDataHook     209
diff --git a/src/interfaces/libpq/fe-auth-oauth-curl.c b/src/interfaces/libpq/fe-auth-oauth-curl.c
new file mode 100644
index 00000000000..258602cfbfc
--- /dev/null
+++ b/src/interfaces/libpq/fe-auth-oauth-curl.c
@@ -0,0 +1,2541 @@
+/*-------------------------------------------------------------------------
+ *
+ * fe-auth-oauth-curl.c
+ *	   The libcurl implementation of OAuth/OIDC authentication, using the
+ *	   OAuth Device Authorization Grant (RFC 8628).
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/interfaces/libpq/fe-auth-oauth-curl.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres_fe.h"
+
+#include <curl/curl.h>
+#include <math.h>
+#ifdef HAVE_SYS_EPOLL_H
+#include <sys/epoll.h>
+#include <sys/timerfd.h>
+#endif
+#ifdef HAVE_SYS_EVENT_H
+#include <sys/event.h>
+#endif
+#include <unistd.h>
+
+#include "common/jsonapi.h"
+#include "fe-auth.h"
+#include "fe-auth-oauth.h"
+#include "libpq-int.h"
+#include "mb/pg_wchar.h"
+
+#define MAX_OAUTH_RESPONSE_SIZE (1024 * 1024)
+
+/*
+ * Parsed JSON Representations
+ *
+ * As a general rule, we parse and cache only the fields we're currently using.
+ * When adding new fields, ensure the corresponding free_*() function is updated
+ * too.
+ */
+
+/*
+ * The OpenID Provider configuration (alternatively named "authorization server
+ * metadata") jointly described by OpenID Connect Discovery 1.0 and RFC 8414:
+ *
+ *     https://openid.net/specs/openid-connect-discovery-1_0.html
+ *     https://www.rfc-editor.org/rfc/rfc8414#section-3.2
+ */
+struct provider
+{
+	char	   *issuer;
+	char	   *token_endpoint;
+	char	   *device_authorization_endpoint;
+	struct curl_slist *grant_types_supported;
+};
+
+static void
+free_provider(struct provider *provider)
+{
+	free(provider->issuer);
+	free(provider->token_endpoint);
+	free(provider->device_authorization_endpoint);
+	curl_slist_free_all(provider->grant_types_supported);
+}
+
+/*
+ * The Device Authorization response, described by RFC 8628:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc8628#section-3.2
+ */
+struct device_authz
+{
+	char	   *device_code;
+	char	   *user_code;
+	char	   *verification_uri;
+	char	   *interval_str;
+
+	/* Fields below are parsed from the corresponding string above. */
+	int			interval;
+};
+
+static void
+free_device_authz(struct device_authz *authz)
+{
+	free(authz->device_code);
+	free(authz->user_code);
+	free(authz->verification_uri);
+	free(authz->interval_str);
+}
+
+/*
+ * The Token Endpoint error response, as described by RFC 6749:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc6749#section-5.2
+ *
+ * Note that this response type can also be returned from the Device
+ * Authorization Endpoint.
+ */
+struct token_error
+{
+	char	   *error;
+	char	   *error_description;
+};
+
+static void
+free_token_error(struct token_error *err)
+{
+	free(err->error);
+	free(err->error_description);
+}
+
+/*
+ * The Access Token response, as described by RFC 6749:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc6749#section-4.1.4
+ *
+ * During the Device Authorization flow, several temporary errors are expected
+ * as part of normal operation. To make it easy to handle these in the happy
+ * path, this contains an embedded token_error that is filled in if needed.
+ */
+struct token
+{
+	/* for successful responses */
+	char	   *access_token;
+	char	   *token_type;
+
+	/* for error responses */
+	struct token_error err;
+};
+
+static void
+free_token(struct token *tok)
+{
+	free(tok->access_token);
+	free(tok->token_type);
+	free_token_error(&tok->err);
+}
+
+/*
+ * Asynchronous State
+ */
+
+/* States for the overall async machine. */
+enum OAuthStep
+{
+	OAUTH_STEP_INIT = 0,
+	OAUTH_STEP_DISCOVERY,
+	OAUTH_STEP_DEVICE_AUTHORIZATION,
+	OAUTH_STEP_TOKEN_REQUEST,
+	OAUTH_STEP_WAIT_INTERVAL,
+};
+
+/*
+ * The async_ctx holds onto state that needs to persist across multiple calls
+ * to pg_fe_run_oauth_flow(). Almost everything interacts with this in some
+ * way.
+ */
+struct async_ctx
+{
+	enum OAuthStep step;		/* where are we in the flow? */
+
+#ifdef HAVE_SYS_EPOLL_H
+	int			timerfd;		/* a timerfd for signaling async timeouts */
+#endif
+	pgsocket	mux;			/* the multiplexer socket containing all
+								 * descriptors tracked by libcurl, plus the
+								 * timerfd */
+	CURLM	   *curlm;			/* top-level multi handle for libcurl
+								 * operations */
+	CURL	   *curl;			/* the (single) easy handle for serial
+								 * requests */
+
+	struct curl_slist *headers; /* common headers for all requests */
+	PQExpBufferData work_data;	/* scratch buffer for general use (remember to
+								 * clear out prior contents first!) */
+
+	/*------
+	 * Since a single logical operation may stretch across multiple calls to
+	 * our entry point, errors have three parts:
+	 *
+	 * - errctx:	an optional static string, describing the global operation
+	 *				currently in progress. It'll be translated for you.
+	 *
+	 * - errbuf:	contains the actual error message. Generally speaking, use
+	 *				actx_error[_str] to manipulate this. This must be filled
+	 *				with something useful on an error.
+	 *
+	 * - curl_err:	an optional static error buffer used by libcurl to put
+	 *				detailed information about failures. Unfortunately
+	 *				untranslatable.
+	 *
+	 * These pieces will be combined into a single error message looking
+	 * something like the following, with errctx and/or curl_err omitted when
+	 * absent:
+	 *
+	 *     connection to server ... failed: errctx: errbuf (curl_err)
+	 */
+	const char *errctx;			/* not freed; must point to static allocation */
+	PQExpBufferData errbuf;
+	char		curl_err[CURL_ERROR_SIZE];
+
+	/*
+	 * These documents need to survive over multiple calls, and are therefore
+	 * cached directly in the async_ctx.
+	 */
+	struct provider provider;
+	struct device_authz authz;
+
+	int			running;		/* is asynchronous work in progress? */
+	bool		user_prompted;	/* have we already sent the authz prompt? */
+	bool		used_basic_auth;	/* did we send a client secret? */
+	bool		debugging;		/* can we give unsafe developer assistance? */
+};
+
+/*
+ * Tears down the Curl handles and frees the async_ctx.
+ */
+static void
+free_async_ctx(PGconn *conn, struct async_ctx *actx)
+{
+	/*
+	 * TODO: in general, none of the error cases below should ever happen if
+	 * we have no bugs above. But if we do hit them, surfacing those errors
+	 * somehow might be the only way to have a chance to debug them. What's
+	 * the best way to do that? Assertions? Spraying messages on stderr?
+	 * Bubbling an error code to the top? Appending to the connection's error
+	 * message only helps if the bug caused a connection failure; otherwise
+	 * it'll be buried...
+	 */
+
+	if (actx->curlm && actx->curl)
+	{
+		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));
+	}
+
+	if (actx->curl)
+	{
+		/*
+		 * curl_multi_cleanup() doesn't free any associated easy handles; we
+		 * need to do that separately. We only ever have one easy handle per
+		 * multi handle.
+		 */
+		curl_easy_cleanup(actx->curl);
+	}
+
+	if (actx->curlm)
+	{
+		CURLMcode	err = curl_multi_cleanup(actx->curlm);
+
+		if (err)
+			libpq_append_conn_error(conn,
+									"libcurl multi handle cleanup failed: %s",
+									curl_multi_strerror(err));
+	}
+
+	free_provider(&actx->provider);
+	free_device_authz(&actx->authz);
+
+	curl_slist_free_all(actx->headers);
+	termPQExpBuffer(&actx->work_data);
+	termPQExpBuffer(&actx->errbuf);
+
+	if (actx->mux != PGINVALID_SOCKET)
+		close(actx->mux);
+#ifdef HAVE_SYS_EPOLL_H
+	if (actx->timerfd >= 0)
+		close(actx->timerfd);
+#endif
+
+	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.
+ */
+void
+pg_fe_cleanup_oauth_flow(PGconn *conn)
+{
+	fe_oauth_state *state = conn->sasl_state;
+
+	if (state->async_ctx)
+	{
+		free_async_ctx(conn, state->async_ctx);
+		state->async_ctx = NULL;
+	}
+
+	conn->altsock = PGINVALID_SOCKET;
+}
+
+/*
+ * Macros for manipulating actx->errbuf. actx_error() translates and formats a
+ * string for you; actx_error_str() appends a string directly without
+ * translation.
+ */
+
+#define actx_error(ACTX, FMT, ...) \
+	appendPQExpBuffer(&(ACTX)->errbuf, libpq_gettext(FMT), ##__VA_ARGS__)
+
+#define actx_error_str(ACTX, S) \
+	appendPQExpBufferStr(&(ACTX)->errbuf, S)
+
+/*
+ * Macros for getting and setting state for the connection's two libcurl
+ * handles, so you don't have to write out the error handling every time.
+ */
+
+#define CHECK_MSETOPT(ACTX, OPT, VAL, FAILACTION) \
+	do { \
+		struct async_ctx *_actx = (ACTX); \
+		CURLMcode	_setopterr = curl_multi_setopt(_actx->curlm, OPT, VAL); \
+		if (_setopterr) { \
+			actx_error(_actx, "failed to set %s on OAuth connection: %s",\
+					   #OPT, curl_multi_strerror(_setopterr)); \
+			FAILACTION; \
+		} \
+	} while (0)
+
+#define CHECK_SETOPT(ACTX, OPT, VAL, FAILACTION) \
+	do { \
+		struct async_ctx *_actx = (ACTX); \
+		CURLcode	_setopterr = curl_easy_setopt(_actx->curl, OPT, VAL); \
+		if (_setopterr) { \
+			actx_error(_actx, "failed to set %s on OAuth connection: %s",\
+					   #OPT, curl_easy_strerror(_setopterr)); \
+			FAILACTION; \
+		} \
+	} while (0)
+
+#define CHECK_GETINFO(ACTX, INFO, OUT, FAILACTION) \
+	do { \
+		struct async_ctx *_actx = (ACTX); \
+		CURLcode	_getinfoerr = curl_easy_getinfo(_actx->curl, INFO, OUT); \
+		if (_getinfoerr) { \
+			actx_error(_actx, "failed to get %s from OAuth response: %s",\
+					   #INFO, curl_easy_strerror(_getinfoerr)); \
+			FAILACTION; \
+		} \
+	} while (0)
+
+/*
+ * General JSON Parsing for OAuth Responses
+ */
+
+/*
+ * Represents a single name/value pair in a JSON object. This is the primary
+ * interface to parse_oauth_json().
+ *
+ * All fields are stored internally as strings or lists of strings, so clients
+ * have to explicitly parse other scalar types (though they will have gone
+ * through basic lexical validation). Storing nested objects is not currently
+ * supported, nor is parsing arrays of anything other than strings.
+ */
+struct json_field
+{
+	const char *name;			/* name (key) of the member */
+
+	JsonTokenType type;			/* currently supports JSON_TOKEN_STRING,
+								 * JSON_TOKEN_NUMBER, and
+								 * JSON_TOKEN_ARRAY_START */
+	union
+	{
+		char	  **scalar;		/* for all scalar types */
+		struct curl_slist **array;	/* for type == JSON_TOKEN_ARRAY_START */
+	}			target;
+
+	bool		required;		/* REQUIRED field, or just OPTIONAL? */
+};
+
+/* Documentation macros for json_field.required. */
+#define REQUIRED true
+#define OPTIONAL false
+
+/* Parse state for parse_oauth_json(). */
+struct oauth_parse
+{
+	PQExpBuffer errbuf;			/* detail message for JSON_SEM_ACTION_FAILED */
+	int			nested;			/* nesting level (zero is the top) */
+
+	const struct json_field *fields;	/* field definition array */
+	const struct json_field *active;	/* points inside the fields array */
+};
+
+#define oauth_parse_set_error(ctx, fmt, ...) \
+	appendPQExpBuffer((ctx)->errbuf, libpq_gettext(fmt), ##__VA_ARGS__)
+
+static void
+report_type_mismatch(struct oauth_parse *ctx)
+{
+	char	   *msgfmt;
+
+	Assert(ctx->active);
+
+	/*
+	 * At the moment, the only fields we're interested in are strings,
+	 * numbers, and arrays of strings.
+	 */
+	switch (ctx->active->type)
+	{
+		case JSON_TOKEN_STRING:
+			msgfmt = "field \"%s\" must be a string";
+			break;
+
+		case JSON_TOKEN_NUMBER:
+			msgfmt = "field \"%s\" must be a number";
+			break;
+
+		case JSON_TOKEN_ARRAY_START:
+			msgfmt = "field \"%s\" must be an array of strings";
+			break;
+
+		default:
+			Assert(false);
+			msgfmt = "field \"%s\" has unexpected type";
+	}
+
+	oauth_parse_set_error(ctx, msgfmt, ctx->active->name);
+}
+
+static JsonParseErrorType
+oauth_json_object_start(void *state)
+{
+	struct oauth_parse *ctx = state;
+
+	if (ctx->active)
+	{
+		/*
+		 * Currently, none of the fields we're interested in can be or contain
+		 * objects, so we can reject this case outright.
+		 */
+		report_type_mismatch(ctx);
+		return JSON_SEM_ACTION_FAILED;
+	}
+
+	++ctx->nested;
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_object_field_start(void *state, char *name, bool isnull)
+{
+	struct oauth_parse *ctx = state;
+
+	/* We care only about the top-level fields. */
+	if (ctx->nested == 1)
+	{
+		const struct json_field *field = ctx->fields;
+
+		/*
+		 * We should never start parsing a new field while a previous one is
+		 * still active.
+		 *
+		 * TODO: this code relies on assertions too much. We need to exit
+		 * sanely on internal logic errors, to avoid turning bugs into
+		 * vulnerabilities.
+		 */
+		Assert(!ctx->active);
+
+		while (field->name)
+		{
+			if (strcmp(name, field->name) == 0)
+			{
+				ctx->active = field;
+				break;
+			}
+
+			++field;
+		}
+
+		/*
+		 * We don't allow duplicate field names; error out if the target has
+		 * already been set.
+		 */
+		if (ctx->active)
+		{
+			field = ctx->active;
+
+			if ((field->type == JSON_TOKEN_ARRAY_START && *field->target.array)
+				|| (field->type != JSON_TOKEN_ARRAY_START && *field->target.scalar))
+			{
+				oauth_parse_set_error(ctx, "field \"%s\" is duplicated",
+									  field->name);
+				return JSON_SEM_ACTION_FAILED;
+			}
+		}
+	}
+
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_object_end(void *state)
+{
+	struct oauth_parse *ctx = state;
+
+	--ctx->nested;
+	if (!ctx->nested)
+		Assert(!ctx->active);	/* all fields should be fully processed */
+
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_array_start(void *state)
+{
+	struct oauth_parse *ctx = state;
+
+	if (!ctx->nested)
+	{
+		oauth_parse_set_error(ctx, "top-level element must be an object");
+		return JSON_SEM_ACTION_FAILED;
+	}
+
+	if (ctx->active)
+	{
+		if (ctx->active->type != JSON_TOKEN_ARRAY_START
+		/* The arrays we care about must not have arrays as values. */
+			|| ctx->nested > 1)
+		{
+			report_type_mismatch(ctx);
+			return JSON_SEM_ACTION_FAILED;
+		}
+	}
+
+	++ctx->nested;
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_array_end(void *state)
+{
+	struct oauth_parse *ctx = state;
+
+	if (ctx->active)
+	{
+		/*
+		 * This assumes that no target arrays can contain other arrays, which
+		 * we check in the array_start callback.
+		 */
+		Assert(ctx->nested == 2);
+		Assert(ctx->active->type == JSON_TOKEN_ARRAY_START);
+
+		ctx->active = NULL;
+	}
+
+	--ctx->nested;
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_scalar(void *state, char *token, JsonTokenType type)
+{
+	struct oauth_parse *ctx = state;
+
+	if (!ctx->nested)
+	{
+		oauth_parse_set_error(ctx, "top-level element must be an object");
+		return JSON_SEM_ACTION_FAILED;
+	}
+
+	if (ctx->active)
+	{
+		const struct json_field *field = ctx->active;
+		JsonTokenType expected = field->type;
+
+		/* Make sure this matches what the active field expects. */
+		if (expected == JSON_TOKEN_ARRAY_START)
+		{
+			/* Are we actually inside an array? */
+			if (ctx->nested < 2)
+			{
+				report_type_mismatch(ctx);
+				return JSON_SEM_ACTION_FAILED;
+			}
+
+			/* Currently, arrays can only contain strings. */
+			expected = JSON_TOKEN_STRING;
+		}
+
+		if (type != expected)
+		{
+			report_type_mismatch(ctx);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		if (field->type != JSON_TOKEN_ARRAY_START)
+		{
+			Assert(ctx->nested == 1);
+			Assert(!*field->target.scalar);
+
+			*field->target.scalar = strdup(token);
+			if (!*field->target.scalar)
+				return JSON_OUT_OF_MEMORY;
+
+			ctx->active = NULL;
+
+			return JSON_SUCCESS;
+		}
+		else
+		{
+			struct curl_slist *temp;
+
+			Assert(ctx->nested == 2);
+
+			/* Note that curl_slist_append() makes a copy of the token. */
+			temp = curl_slist_append(*field->target.array, token);
+			if (!temp)
+				return JSON_OUT_OF_MEMORY;
+
+			*field->target.array = temp;
+		}
+	}
+	else
+	{
+		/* otherwise we just ignore it */
+	}
+
+	return JSON_SUCCESS;
+}
+
+/*
+ * Checks the Content-Type header against the expected type. Parameters are
+ * allowed but ignored.
+ */
+static bool
+check_content_type(struct async_ctx *actx, const char *type)
+{
+	const size_t type_len = strlen(type);
+	char	   *content_type;
+
+	CHECK_GETINFO(actx, CURLINFO_CONTENT_TYPE, &content_type, return false);
+
+	if (!content_type)
+	{
+		actx_error(actx, "no content type was provided");
+		return false;
+	}
+
+	/*
+	 * We need to perform a length limited comparison and not compare the
+	 * whole string.
+	 */
+	if (pg_strncasecmp(content_type, type, type_len) != 0)
+		goto fail;
+
+	/* On an exact match, we're done. */
+	Assert(strlen(content_type) >= type_len);
+	if (content_type[type_len] == '\0')
+		return true;
+
+	/*
+	 * Only a semicolon (optionally preceded by HTTP optional whitespace) is
+	 * acceptable after the prefix we checked. This marks the start of media
+	 * type parameters, which we currently have no use for.
+	 */
+	for (size_t i = type_len; content_type[i]; ++i)
+	{
+		switch (content_type[i])
+		{
+			case ';':
+				return true;	/* success! */
+
+			case ' ':
+			case '\t':
+				/* HTTP optional whitespace allows only spaces and htabs. */
+				break;
+
+			default:
+				goto fail;
+		}
+	}
+
+fail:
+	actx_error(actx, "unexpected content type: \"%s\"", content_type);
+	return false;
+}
+
+/*
+ * A helper function for general JSON parsing. fields is the array of field
+ * definitions with their backing pointers. The response will be parsed from
+ * actx->curl and actx->work_data (as set up by start_request()), and any
+ * parsing errors will be placed into actx->errbuf.
+ */
+static bool
+parse_oauth_json(struct async_ctx *actx, const struct json_field *fields)
+{
+	PQExpBuffer resp = &actx->work_data;
+	JsonLexContext lex = {0};
+	JsonSemAction sem = {0};
+	JsonParseErrorType err;
+	struct oauth_parse ctx = {0};
+	bool		success = false;
+
+	if (!check_content_type(actx, "application/json"))
+		return false;
+
+	if (strlen(resp->data) != resp->len)
+	{
+		actx_error(actx, "response contains embedded NULLs");
+		return false;
+	}
+
+	/*
+	 * pg_parse_json doesn't validate the incoming UTF-8, so we have to check
+	 * that up front.
+	 */
+	if (pg_encoding_verifymbstr(PG_UTF8, resp->data, resp->len) != resp->len)
+	{
+		actx_error(actx, "response is not valid UTF-8");
+		return false;
+	}
+
+	makeJsonLexContextCstringLen(&lex, resp->data, resp->len, PG_UTF8, true);
+	setJsonLexContextOwnsTokens(&lex, true);	/* must not leak on error */
+
+	ctx.errbuf = &actx->errbuf;
+	ctx.fields = fields;
+	sem.semstate = &ctx;
+
+	sem.object_start = oauth_json_object_start;
+	sem.object_field_start = oauth_json_object_field_start;
+	sem.object_end = oauth_json_object_end;
+	sem.array_start = oauth_json_array_start;
+	sem.array_end = oauth_json_array_end;
+	sem.scalar = oauth_json_scalar;
+
+	err = pg_parse_json(&lex, &sem);
+
+	if (err != JSON_SUCCESS)
+	{
+		/*
+		 * For JSON_SEM_ACTION_FAILED, we've already written the error
+		 * message. Other errors come directly from pg_parse_json(), already
+		 * translated.
+		 */
+		if (err != JSON_SEM_ACTION_FAILED)
+			actx_error_str(actx, json_errdetail(err, &lex));
+
+		goto cleanup;
+	}
+
+	/* Check all required fields. */
+	while (fields->name)
+	{
+		if (fields->required
+			&& !*fields->target.scalar
+			&& !*fields->target.array)
+		{
+			actx_error(actx, "field \"%s\" is missing", fields->name);
+			goto cleanup;
+		}
+
+		fields++;
+	}
+
+	success = true;
+
+cleanup:
+	freeJsonLexContext(&lex);
+	return success;
+}
+
+/*
+ * JSON Parser Definitions
+ */
+
+/*
+ * Parses authorization server metadata. Fields are defined by OIDC Discovery
+ * 1.0 and RFC 8414.
+ */
+static bool
+parse_provider(struct async_ctx *actx, struct provider *provider)
+{
+	struct json_field fields[] = {
+		{"issuer", JSON_TOKEN_STRING, {&provider->issuer}, REQUIRED},
+		{"token_endpoint", JSON_TOKEN_STRING, {&provider->token_endpoint}, REQUIRED},
+
+		/*----
+		 * The following fields are technically REQUIRED, but we don't use
+		 * them anywhere yet:
+		 *
+		 * - jwks_uri
+		 * - response_types_supported
+		 * - subject_types_supported
+		 * - id_token_signing_alg_values_supported
+		 */
+
+		{"device_authorization_endpoint", JSON_TOKEN_STRING, {&provider->device_authorization_endpoint}, OPTIONAL},
+		{"grant_types_supported", JSON_TOKEN_ARRAY_START, {.array = &provider->grant_types_supported}, OPTIONAL},
+
+		{0},
+	};
+
+	return parse_oauth_json(actx, fields);
+}
+
+/*
+ * Parses the "interval" JSON number, corresponding to the number of seconds to
+ * wait between token endpoint requests.
+ *
+ * RFC 8628 is pretty silent on sanity checks for the interval. As a matter of
+ * practicality, round any fractional intervals up to the next second, and clamp
+ * the result at a minimum of one. (Zero-second intervals would result in an
+ * expensive network polling loop.) Tests may remove the lower bound with
+ * PGOAUTHDEBUG, for improved performance.
+ *
+ * TODO: maybe clamp the upper bound too, based on the libpq timeout and/or the
+ * code expiration time?
+ */
+static int
+parse_interval(struct async_ctx *actx, const char *interval_str)
+{
+	double		parsed;
+	int			cnt;
+
+	/*
+	 * The JSON lexer has already validated the number, which is stricter than
+	 * the %f format, so we should be good to use sscanf().
+	 */
+	cnt = sscanf(interval_str, "%lf", &parsed);
+
+	if (cnt != 1)
+	{
+		/*
+		 * Either the lexer screwed up or our assumption above isn't true, and
+		 * either way a developer needs to take a look.
+		 */
+		Assert(cnt == 1);
+		return 1;				/* don't fall through in release builds */
+	}
+
+	parsed = ceil(parsed);
+
+	if (parsed < 1)
+		return actx->debugging ? 0 : 1;
+
+	else if (INT_MAX <= parsed)
+		return INT_MAX;
+
+	return parsed;
+}
+
+/*
+ * Parses the Device Authorization Response (RFC 8628, Sec. 3.2).
+ */
+static bool
+parse_device_authz(struct async_ctx *actx, struct device_authz *authz)
+{
+	struct json_field fields[] = {
+		{"device_code", JSON_TOKEN_STRING, {&authz->device_code}, REQUIRED},
+		{"user_code", JSON_TOKEN_STRING, {&authz->user_code}, REQUIRED},
+		{"verification_uri", JSON_TOKEN_STRING, {&authz->verification_uri}, REQUIRED},
+
+		/*
+		 * Some services (Google, Azure) spell verification_uri differently.
+		 * We accept either.
+		 */
+		{"verification_url", JSON_TOKEN_STRING, {&authz->verification_uri}, REQUIRED},
+
+		/*
+		 * The following fields are technically REQUIRED, but we don't use
+		 * them anywhere yet:
+		 *
+		 * - expires_in
+		 */
+
+		{"interval", JSON_TOKEN_NUMBER, {&authz->interval_str}, OPTIONAL},
+
+		{0},
+	};
+
+	if (!parse_oauth_json(actx, fields))
+		return false;
+
+	/*
+	 * Parse our numeric fields. Lexing has already completed by this time, so
+	 * we at least know they're valid JSON numbers.
+	 */
+	if (authz->interval_str)
+		authz->interval = parse_interval(actx, authz->interval_str);
+	else
+	{
+		/*
+		 * RFC 8628 specifies 5 seconds as the default value if the server
+		 * doesn't provide an interval.
+		 */
+		authz->interval = 5;
+	}
+
+	return true;
+}
+
+/*
+ * Parses the device access token error response (RFC 8628, Sec. 3.5, which
+ * uses the error response defined in RFC 6749, Sec. 5.2).
+ */
+static bool
+parse_token_error(struct async_ctx *actx, struct token_error *err)
+{
+	bool		result;
+	struct json_field fields[] = {
+		{"error", JSON_TOKEN_STRING, {&err->error}, REQUIRED},
+
+		{"error_description", JSON_TOKEN_STRING, {&err->error_description}, OPTIONAL},
+
+		{0},
+	};
+
+	result = parse_oauth_json(actx, fields);
+
+	/*
+	 * Since token errors are parsed during other active error paths, only
+	 * override the errctx if parsing explicitly fails.
+	 */
+	if (!result)
+		actx->errctx = "failed to parse token error response";
+
+	return result;
+}
+
+/*
+ * Constructs a message from the token error response and puts it into
+ * actx->errbuf.
+ */
+static void
+record_token_error(struct async_ctx *actx, const struct token_error *err)
+{
+	if (err->error_description)
+		appendPQExpBuffer(&actx->errbuf, "%s ", err->error_description);
+	else
+	{
+		/*
+		 * Try to get some more helpful detail into the error string. A 401
+		 * status in particular implies that the oauth_client_secret is
+		 * missing or wrong.
+		 */
+		long		response_code;
+
+		CHECK_GETINFO(actx, CURLINFO_RESPONSE_CODE, &response_code, response_code = 0);
+
+		if (response_code == 401)
+		{
+			actx_error(actx, actx->used_basic_auth
+					   ? "provider rejected the oauth_client_secret"
+					   : "provider requires client authentication, and no oauth_client_secret is set");
+			actx_error_str(actx, " ");
+		}
+	}
+
+	appendPQExpBuffer(&actx->errbuf, "(%s)", err->error);
+}
+
+/*
+ * Parses the device access token response (RFC 8628, Sec. 3.5, which uses the
+ * success response defined in RFC 6749, Sec. 5.1).
+ */
+static bool
+parse_access_token(struct async_ctx *actx, struct token *tok)
+{
+	struct json_field fields[] = {
+		{"access_token", JSON_TOKEN_STRING, {&tok->access_token}, REQUIRED},
+		{"token_type", JSON_TOKEN_STRING, {&tok->token_type}, REQUIRED},
+
+		/*
+		 * The following fields are technically REQUIRED, but we don't use
+		 * them anywhere yet:
+		 *
+		 * - scope (only required if different than requested -- TODO check)
+		 */
+
+		{0},
+	};
+
+	return parse_oauth_json(actx, fields);
+}
+
+/*
+ * libcurl Multi Setup/Callbacks
+ */
+
+/*
+ * Sets up the actx->mux, which is the altsock that PQconnectPoll clients will
+ * select() on instead of the Postgres socket during OAuth negotiation.
+ *
+ * This is just an epoll set or kqueue abstracting multiple other descriptors.
+ * A timerfd is always part of the set when using epoll; it's just disabled
+ * when we're not using it.
+ */
+static bool
+setup_multiplexer(struct async_ctx *actx)
+{
+#ifdef HAVE_SYS_EPOLL_H
+	struct epoll_event ev = {.events = EPOLLIN};
+
+	actx->mux = epoll_create1(EPOLL_CLOEXEC);
+	if (actx->mux < 0)
+	{
+		actx_error(actx, "failed to create epoll set: %m");
+		return false;
+	}
+
+	actx->timerfd = timerfd_create(CLOCK_MONOTONIC, TFD_CLOEXEC);
+	if (actx->timerfd < 0)
+	{
+		actx_error(actx, "failed to create timerfd: %m");
+		return false;
+	}
+
+	if (epoll_ctl(actx->mux, EPOLL_CTL_ADD, actx->timerfd, &ev) < 0)
+	{
+		actx_error(actx, "failed to add timerfd to epoll set: %m");
+		return false;
+	}
+
+	return true;
+#endif
+#ifdef HAVE_SYS_EVENT_H
+	actx->mux = kqueue();
+	if (actx->mux < 0)
+	{
+		actx_error(actx, "failed to create kqueue: %m");
+		return false;
+	}
+
+	return true;
+#endif
+
+	actx_error(actx, "libpq does not support the Device Authorization flow on this platform");
+	return false;
+}
+
+/*
+ * Adds and removes sockets from the multiplexer set, as directed by the
+ * libcurl multi handle.
+ */
+static int
+register_socket(CURL *curl, curl_socket_t socket, int what, void *ctx,
+				void *socketp)
+{
+#ifdef HAVE_SYS_EPOLL_H
+	struct async_ctx *actx = ctx;
+	struct epoll_event ev = {0};
+	int			res;
+	int			op = EPOLL_CTL_ADD;
+
+	switch (what)
+	{
+		case CURL_POLL_IN:
+			ev.events = EPOLLIN;
+			break;
+
+		case CURL_POLL_OUT:
+			ev.events = EPOLLOUT;
+			break;
+
+		case CURL_POLL_INOUT:
+			ev.events = EPOLLIN | EPOLLOUT;
+			break;
+
+		case CURL_POLL_REMOVE:
+			op = EPOLL_CTL_DEL;
+			break;
+
+		default:
+			actx_error(actx, "unknown libcurl socket operation: %d", what);
+			return -1;
+	}
+
+	res = epoll_ctl(actx->mux, op, socket, &ev);
+	if (res < 0 && errno == EEXIST)
+	{
+		/* We already had this socket in the pollset. */
+		op = EPOLL_CTL_MOD;
+		res = epoll_ctl(actx->mux, op, socket, &ev);
+	}
+
+	if (res < 0)
+	{
+		switch (op)
+		{
+			case EPOLL_CTL_ADD:
+				actx_error(actx, "could not add to epoll set: %m");
+				break;
+
+			case EPOLL_CTL_DEL:
+				actx_error(actx, "could not delete from epoll set: %m");
+				break;
+
+			default:
+				actx_error(actx, "could not update epoll set: %m");
+		}
+
+		return -1;
+	}
+#endif
+#ifdef HAVE_SYS_EVENT_H
+	struct async_ctx *actx = ctx;
+	struct kevent ev[2] = {{0}};
+	struct kevent ev_out[2];
+	struct timespec timeout = {0};
+	int			nev = 0;
+	int			res;
+
+	switch (what)
+	{
+		case CURL_POLL_IN:
+			EV_SET(&ev[nev], socket, EVFILT_READ, EV_ADD | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			break;
+
+		case CURL_POLL_OUT:
+			EV_SET(&ev[nev], socket, EVFILT_WRITE, EV_ADD | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			break;
+
+		case CURL_POLL_INOUT:
+			EV_SET(&ev[nev], socket, EVFILT_READ, EV_ADD | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			EV_SET(&ev[nev], socket, EVFILT_WRITE, EV_ADD | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			break;
+
+		case CURL_POLL_REMOVE:
+
+			/*
+			 * We don't know which of these is currently registered, perhaps
+			 * both, so we try to remove both.  This means we need to tolerate
+			 * ENOENT below.
+			 */
+			EV_SET(&ev[nev], socket, EVFILT_READ, EV_DELETE | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			EV_SET(&ev[nev], socket, EVFILT_WRITE, EV_DELETE | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			break;
+
+		default:
+			actx_error(actx, "unknown libcurl socket operation: %d", what);
+			return -1;
+	}
+
+	res = kevent(actx->mux, ev, nev, ev_out, lengthof(ev_out), &timeout);
+	if (res < 0)
+	{
+		actx_error(actx, "could not modify kqueue: %m");
+		return -1;
+	}
+
+	/*
+	 * We can't use the simple errno version of kevent, because we need to
+	 * skip over ENOENT while still allowing a second change to be processed.
+	 * So we need a longer-form error checking loop.
+	 */
+	for (int i = 0; i < res; ++i)
+	{
+		/*
+		 * EV_RECEIPT should guarantee one EV_ERROR result for every change,
+		 * whether successful or not. Failed entries contain a non-zero errno
+		 * in the data field.
+		 */
+		Assert(ev_out[i].flags & EV_ERROR);
+
+		errno = ev_out[i].data;
+		if (errno && errno != ENOENT)
+		{
+			switch (what)
+			{
+				case CURL_POLL_REMOVE:
+					actx_error(actx, "could not delete from kqueue: %m");
+					break;
+				default:
+					actx_error(actx, "could not add to kqueue: %m");
+			}
+			return -1;
+		}
+	}
+#endif
+
+	return 0;
+}
+
+/*
+ * Enables or disables the timer in the multiplexer set. The timeout value is
+ * in milliseconds (negative values disable the timer). Rather than continually
+ * adding and removing the timer, we keep it in the set at all times and just
+ * disarm it when it's not needed.
+ */
+static bool
+set_timer(struct async_ctx *actx, long timeout)
+{
+#if HAVE_SYS_EPOLL_H
+	struct itimerspec spec = {0};
+
+	if (timeout < 0)
+	{
+		/* the zero itimerspec will disarm the timer below */
+	}
+	else if (timeout == 0)
+	{
+		/*
+		 * A zero timeout means libcurl wants us to call back immediately.
+		 * That's not technically an option for timerfd, but we can make the
+		 * timeout ridiculously short.
+		 */
+		spec.it_value.tv_nsec = 1;
+	}
+	else
+	{
+		spec.it_value.tv_sec = timeout / 1000;
+		spec.it_value.tv_nsec = (timeout % 1000) * 1000000;
+	}
+
+	if (timerfd_settime(actx->timerfd, 0 /* no flags */ , &spec, NULL) < 0)
+	{
+		actx_error(actx, "setting timerfd to %ld: %m", timeout);
+		return false;
+	}
+#endif
+#ifdef HAVE_SYS_EVENT_H
+	struct kevent ev;
+
+	EV_SET(&ev, 1, EVFILT_TIMER, timeout < 0 ? EV_DELETE : EV_ADD,
+		   0, timeout, 0);
+	if (kevent(actx->mux, &ev, 1, NULL, 0, NULL) < 0 && errno != ENOENT)
+	{
+		actx_error(actx, "setting kqueue timer to %ld: %m", timeout);
+		return false;
+	}
+#endif
+
+	return true;
+}
+
+/*
+ * Adds or removes timeouts from the multiplexer set, as directed by the
+ * libcurl multi handle.
+ */
+static int
+register_timer(CURLM *curlm, long timeout, void *ctx)
+{
+	struct async_ctx *actx = ctx;
+
+	/*
+	 * TODO: maybe just signal drive_request() to immediately call back in the
+	 * (timeout == 0) case?
+	 */
+	if (!set_timer(actx, timeout))
+		return -1;				/* actx_error already called */
+
+	return 0;
+}
+
+/*
+ * Prints Curl request debugging information to stderr.
+ *
+ * Note that this will expose a number of critical secrets, so users have to opt
+ * into this (see PGOAUTHDEBUG).
+ */
+static int
+debug_callback(CURL *handle, curl_infotype type, char *data, size_t size,
+			   void *clientp)
+{
+	const char *const end = data + size;
+	const char *prefix;
+
+	/* Prefixes are modeled off of the default libcurl debug output. */
+	switch (type)
+	{
+		case CURLINFO_TEXT:
+			prefix = "*";
+			break;
+
+		case CURLINFO_HEADER_IN:	/* fall through */
+		case CURLINFO_DATA_IN:
+			prefix = "<";
+			break;
+
+		case CURLINFO_HEADER_OUT:	/* fall through */
+		case CURLINFO_DATA_OUT:
+			prefix = ">";
+			break;
+
+		default:
+			return 0;
+	}
+
+	/*
+	 * Split the output into lines for readability; sometimes multiple headers
+	 * are included in a single call.
+	 */
+	while (data < end)
+	{
+		size_t		len = end - data;
+		char	   *eol = memchr(data, '\n', len);
+
+		if (eol)
+			len = eol - data + 1;
+
+		/* TODO: handle unprintables */
+		fprintf(stderr, "%s %.*s%s", prefix, (int) len, data,
+				eol ? "" : "\n");
+
+		data += len;
+	}
+
+	return 0;
+}
+
+/*
+ * Initializes the two libcurl handles in the async_ctx. The multi handle,
+ * actx->curlm, is what drives the asynchronous engine and tells us what to do
+ * next. The easy handle, actx->curl, encapsulates the state for a single
+ * request/response. It's added to the multi handle as needed, during
+ * start_request().
+ */
+static bool
+setup_curl_handles(struct async_ctx *actx)
+{
+	curl_version_info_data *curl_info;
+
+	/*
+	 * Create our multi handle. This encapsulates the entire conversation with
+	 * libcurl for this connection.
+	 */
+	actx->curlm = curl_multi_init();
+	if (!actx->curlm)
+	{
+		/* We don't get a lot of feedback on the failure reason. */
+		actx_error(actx, "failed to create libcurl multi handle");
+		return false;
+	}
+
+	/*
+	 * Extract information about the libcurl we are linked against.
+	 */
+	curl_info = curl_version_info(CURLVERSION_NOW);
+
+	/*
+	 * The multi handle tells us what to wait on using two callbacks. These
+	 * will manipulate actx->mux as needed.
+	 */
+	CHECK_MSETOPT(actx, CURLMOPT_SOCKETFUNCTION, register_socket, return false);
+	CHECK_MSETOPT(actx, CURLMOPT_SOCKETDATA, actx, return false);
+	CHECK_MSETOPT(actx, CURLMOPT_TIMERFUNCTION, register_timer, return false);
+	CHECK_MSETOPT(actx, CURLMOPT_TIMERDATA, actx, return false);
+
+	/*
+	 * Set up an easy handle. All of our requests are made serially, so we
+	 * only ever need to keep track of one.
+	 */
+	actx->curl = curl_easy_init();
+	if (!actx->curl)
+	{
+		actx_error(actx, "failed to create libcurl handle");
+		return false;
+	}
+
+	/*
+	 * Multi-threaded applications must set CURLOPT_NOSIGNAL. This requires us
+	 * to handle the possibility of SIGPIPE ourselves using pq_block_sigpipe;
+	 * see pg_fe_run_oauth_flow().
+	 */
+	CHECK_SETOPT(actx, CURLOPT_NOSIGNAL, 1L, return false);
+	if (!curl_info->ares_num)
+	{
+		/* No alternative resolver, TODO: warn about timeouts */
+	}
+
+	if (actx->debugging)
+	{
+		/*
+		 * Set a callback for retrieving error information from libcurl, the
+		 * function only takes effect when CURLOPT_VERBOSE has been set so
+		 * make sure the order is kept.
+		 */
+		CHECK_SETOPT(actx, CURLOPT_DEBUGFUNCTION, debug_callback, return false);
+		CHECK_SETOPT(actx, CURLOPT_VERBOSE, 1L, return false);
+	}
+
+	CHECK_SETOPT(actx, CURLOPT_ERRORBUFFER, actx->curl_err, return false);
+
+	/*
+	 * Only HTTPS is allowed. (Debug mode additionally allows HTTP; this is
+	 * intended for testing only.)
+	 *
+	 * There's a bit of unfortunate complexity around the choice of
+	 * CURLoption. CURLOPT_PROTOCOLS is deprecated in modern Curls, but its
+	 * replacement didn't show up until relatively recently.
+	 */
+	{
+#if CURL_AT_LEAST_VERSION(7, 85, 0)
+		const CURLoption popt = CURLOPT_PROTOCOLS_STR;
+		const char *protos = "https";
+		const char *const unsafe = "https,http";
+#else
+		const CURLoption popt = CURLOPT_PROTOCOLS;
+		long		protos = CURLPROTO_HTTPS;
+		const long	unsafe = CURLPROTO_HTTPS | CURLPROTO_HTTP;
+#endif
+
+		if (actx->debugging)
+			protos = unsafe;
+
+		CHECK_SETOPT(actx, popt, protos, return false);
+	}
+
+	/* TODO: would anyone use this in "real" situations, or just testing? */
+	if (actx->debugging)
+	{
+		const char *env;
+
+		if ((env = getenv("PGOAUTHCAFILE")) != NULL)
+			CHECK_SETOPT(actx, CURLOPT_CAINFO, env, return false);
+	}
+
+	/*
+	 * Suppress the Accept header to make our request as minimal as possible.
+	 * (Ideally we would set it to "application/json" instead, but OpenID is
+	 * pretty strict when it comes to provider behavior, so we have to check
+	 * what comes back anyway.)
+	 */
+	actx->headers = curl_slist_append(actx->headers, "Accept:");
+	if (actx->headers == NULL)
+	{
+		actx_error(actx, "out of memory");
+		return false;
+	}
+	CHECK_SETOPT(actx, CURLOPT_HTTPHEADER, actx->headers, return false);
+
+	return true;
+}
+
+/*
+ * Generic HTTP Request Handlers
+ */
+
+/*
+ * Response callback from libcurl which appends the response body into
+ * actx->work_data (see start_request()). The maximum size of the data is
+ * defined by CURL_MAX_WRITE_SIZE which by default is 16kb (and can only be
+ * changed by recompiling libcurl).
+ */
+static size_t
+append_data(char *buf, size_t size, size_t nmemb, void *userdata)
+{
+	struct async_ctx *actx = userdata;
+	PQExpBuffer resp = &actx->work_data;
+	size_t		len = size * nmemb;
+
+	/* In case we receive data over the threshold, abort the transfer */
+	if ((resp->len + len) > MAX_OAUTH_RESPONSE_SIZE)
+	{
+		actx_error(actx, "response is too large");
+		return 0;
+	}
+
+	/* The data passed from libcurl is not null-terminated */
+	appendBinaryPQExpBuffer(resp, buf, len);
+
+	/*
+	 * Signal an error in order to abort the transfer in case we ran out of
+	 * memory in accepting the data.
+	 */
+	if (PQExpBufferBroken(resp))
+	{
+		actx_error(actx, "out of memory");
+		return 0;
+	}
+
+	return len;
+}
+
+/*
+ * Begins an HTTP request on the multi handle. The caller should have set up all
+ * request-specific options on actx->curl first. The server's response body will
+ * be accumulated in actx->work_data (which will be reset, so don't store
+ * anything important there across this call).
+ *
+ * Once a request is queued, it can be driven to completion via drive_request().
+ * If actx->running is zero upon return, the request has already finished and
+ * drive_request() can be called without returning control to the client.
+ */
+static bool
+start_request(struct async_ctx *actx)
+{
+	CURLMcode	err;
+
+	resetPQExpBuffer(&actx->work_data);
+	CHECK_SETOPT(actx, CURLOPT_WRITEFUNCTION, append_data, return false);
+	CHECK_SETOPT(actx, CURLOPT_WRITEDATA, actx, return false);
+
+	err = curl_multi_add_handle(actx->curlm, actx->curl);
+	if (err)
+	{
+		actx_error(actx, "failed to queue HTTP request: %s",
+				   curl_multi_strerror(err));
+		return false;
+	}
+
+	/*
+	 * actx->running tracks the number of running handles, so we can
+	 * immediately call back if no waiting is needed.
+	 *
+	 * Even though this is nominally an asynchronous process, there are some
+	 * operations that can synchronously fail by this point (e.g. connections
+	 * to closed local ports) or even synchronously succeed if the stars align
+	 * (all the libcurl connection caches hit and the server is fast).
+	 */
+	err = curl_multi_socket_action(actx->curlm, CURL_SOCKET_TIMEOUT, 0, &actx->running);
+	if (err)
+	{
+		actx_error(actx, "asynchronous HTTP request failed: %s",
+				   curl_multi_strerror(err));
+		return false;
+	}
+
+	return true;
+}
+
+/*
+ * CURL_IGNORE_DEPRECATION was added in 7.87.0. If it's not defined, we can make
+ * it a no-op.
+ */
+#ifndef CURL_IGNORE_DEPRECATION
+#define CURL_IGNORE_DEPRECATION(x) x
+#endif
+
+/*
+ * Drives the multi handle towards completion. The caller should have already
+ * set up an asynchronous request via start_request().
+ */
+static PostgresPollingStatusType
+drive_request(struct async_ctx *actx)
+{
+	CURLMcode	err;
+	CURLMsg    *msg;
+	int			msgs_left;
+	bool		done;
+
+	if (actx->running)
+	{
+		/*---
+		 * There's an async request in progress. Pump the multi handle.
+		 *
+		 * curl_multi_socket_all() is officially deprecated, because it's
+		 * inefficient and pointless if your event loop has already handed you
+		 * the exact sockets that are ready. But that's not our use case --
+		 * our client has no way to tell us which sockets are ready. (They
+		 * don't even know there are sockets to begin with.)
+		 *
+		 * We can grab the list of triggered events from the multiplexer
+		 * ourselves, but that's effectively what curl_multi_socket_all() is
+		 * going to do. And there are currently no plans for the Curl project
+		 * to remove or break this API, so ignore the deprecation. See
+		 *
+		 *    https://curl.se/mail/lib-2024-11/0028.html
+		 *
+		 */
+		CURL_IGNORE_DEPRECATION(
+			err = curl_multi_socket_all(actx->curlm, &actx->running);
+		)
+
+		if (err)
+		{
+			actx_error(actx, "asynchronous HTTP request failed: %s",
+					   curl_multi_strerror(err));
+			return PGRES_POLLING_FAILED;
+		}
+
+		if (actx->running)
+		{
+			/* We'll come back again. */
+			return PGRES_POLLING_READING;
+		}
+	}
+
+	done = false;
+	while ((msg = curl_multi_info_read(actx->curlm, &msgs_left)) != NULL)
+	{
+		if (msg->msg != CURLMSG_DONE)
+		{
+			/*
+			 * Future libcurl versions may define new message types; we don't
+			 * know how to handle them, so we'll ignore them.
+			 */
+			continue;
+		}
+
+		/* First check the status of the request itself. */
+		if (msg->data.result != CURLE_OK)
+		{
+			/*
+			 * If a more specific error hasn't already been reported, use
+			 * libcurl's description.
+			 */
+			if (actx->errbuf.len == 0)
+				actx_error_str(actx, curl_easy_strerror(msg->data.result));
+
+			return PGRES_POLLING_FAILED;
+		}
+
+		/* Now remove the finished handle; we'll add it back later if needed. */
+		err = curl_multi_remove_handle(actx->curlm, msg->easy_handle);
+		if (err)
+		{
+			actx_error(actx, "libcurl easy handle removal failed: %s",
+					   curl_multi_strerror(err));
+			return PGRES_POLLING_FAILED;
+		}
+
+		done = true;
+	}
+
+	/* Sanity check. */
+	if (!done)
+	{
+		actx_error(actx, "no result was retrieved for the finished handle");
+		return PGRES_POLLING_FAILED;
+	}
+
+	return PGRES_POLLING_OK;
+}
+
+/*
+ * URL-Encoding Helpers
+ */
+
+/*
+ * Encodes a string using the application/x-www-form-urlencoded format, and
+ * appends it to the given buffer.
+ */
+static void
+append_urlencoded(PQExpBuffer buf, const char *s)
+{
+	char	   *escaped;
+	char	   *haystack;
+	char	   *match;
+
+	escaped = curl_easy_escape(NULL, s, 0);
+	if (!escaped)
+	{
+		termPQExpBuffer(buf);	/* mark the buffer broken */
+		return;
+	}
+
+	/*
+	 * curl_easy_escape() almost does what we want, but we need the
+	 * query-specific flavor which uses '+' instead of '%20' for spaces. The
+	 * Curl command-line tool does this with a simple search-and-replace, so
+	 * follow its lead.
+	 */
+	haystack = escaped;
+
+	while ((match = strstr(haystack, "%20")) != NULL)
+	{
+		/* Append the unmatched portion, followed by the plus sign. */
+		appendBinaryPQExpBuffer(buf, haystack, match - haystack);
+		appendPQExpBufferChar(buf, '+');
+
+		/* Keep searching after the match. */
+		haystack = match + 3 /* strlen("%20") */ ;
+	}
+
+	/* Push the remainder of the string onto the buffer. */
+	appendPQExpBufferStr(buf, haystack);
+
+	curl_free(escaped);
+}
+
+/*
+ * Convenience wrapper for encoding a single string. Returns NULL on allocation
+ * failure.
+ */
+static char *
+urlencode(const char *s)
+{
+	PQExpBufferData buf;
+
+	initPQExpBuffer(&buf);
+	append_urlencoded(&buf, s);
+
+	return PQExpBufferDataBroken(buf) ? NULL : buf.data;
+}
+
+/*
+ * Appends a key/value pair to the end of an application/x-www-form-urlencoded
+ * list.
+ */
+static void
+build_urlencoded(PQExpBuffer buf, const char *key, const char *value)
+{
+	if (buf->len)
+		appendPQExpBufferChar(buf, '&');
+
+	append_urlencoded(buf, key);
+	appendPQExpBufferChar(buf, '=');
+	append_urlencoded(buf, value);
+}
+
+/*
+ * Specific HTTP Request Handlers
+ *
+ * This is finally the beginning of the actual application logic. Generally
+ * speaking, a single request consists of a start_* and a finish_* step, with
+ * drive_request() pumping the machine in between.
+ */
+
+/*
+ * Queue an OpenID Provider Configuration Request:
+ *
+ *     https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationRequest
+ *     https://www.rfc-editor.org/rfc/rfc8414#section-3.1
+ *
+ * This is done first to get the endpoint URIs we need to contact and to make
+ * sure the provider provides a device authorization flow. finish_discovery()
+ * will fill in actx->provider.
+ */
+static bool
+start_discovery(struct async_ctx *actx, const char *discovery_uri)
+{
+	CHECK_SETOPT(actx, CURLOPT_HTTPGET, 1L, return false);
+	CHECK_SETOPT(actx, CURLOPT_URL, discovery_uri, return false);
+
+	return start_request(actx);
+}
+
+static bool
+finish_discovery(struct async_ctx *actx)
+{
+	long		response_code;
+
+	/*----
+	 * Now check the response. OIDC Discovery 1.0 is pretty strict:
+	 *
+	 *     A successful response MUST use the 200 OK HTTP status code and
+	 *     return a JSON object using the application/json content type that
+	 *     contains a set of Claims as its members that are a subset of the
+	 *     Metadata values defined in Section 3.
+	 *
+	 * Compared to standard HTTP semantics, this makes life easy -- we don't
+	 * need to worry about redirections (which would call the Issuer host
+	 * validation into question), or non-authoritative responses, or any other
+	 * complications.
+	 */
+	CHECK_GETINFO(actx, CURLINFO_RESPONSE_CODE, &response_code, return false);
+
+	if (response_code != 200)
+	{
+		actx_error(actx, "unexpected response code %ld", response_code);
+		return false;
+	}
+
+	/*
+	 * Pull the fields we care about from the document.
+	 */
+	actx->errctx = "failed to parse OpenID discovery document";
+	if (!parse_provider(actx, &actx->provider))
+		return false;			/* error message already set */
+
+	/*
+	 * Fill in any defaults for OPTIONAL/RECOMMENDED fields we care about.
+	 */
+	if (!actx->provider.grant_types_supported)
+	{
+		/*
+		 * Per Section 3, the default is ["authorization_code", "implicit"].
+		 */
+		struct curl_slist *temp = actx->provider.grant_types_supported;
+
+		temp = curl_slist_append(temp, "authorization_code");
+		if (temp)
+		{
+			temp = curl_slist_append(temp, "implicit");
+		}
+
+		if (!temp)
+		{
+			actx_error(actx, "out of memory");
+			return false;
+		}
+
+		actx->provider.grant_types_supported = temp;
+	}
+
+	return true;
+}
+
+/*
+ * Ensure that the discovery document is provided by the expected issuer.
+ * Currently, issuers are statically configured in the connection string.
+ */
+static bool
+check_issuer(struct async_ctx *actx, PGconn *conn)
+{
+	const struct provider *provider = &actx->provider;
+
+	Assert(conn->oauth_issuer_id);	/* ensured by setup_oauth_parameters() */
+	Assert(provider->issuer);	/* ensured by parse_provider() */
+
+	/*---
+	 * We require strict equality for issuer identifiers -- no path or case
+	 * normalization, no substitution of default ports and schemes, etc. This
+	 * is done to match the rules in OIDC Discovery Sec. 4.3 for config
+	 * validation:
+	 *
+	 *    The issuer value returned MUST be identical to the Issuer URL that
+	 *    was used as the prefix to /.well-known/openid-configuration to
+	 *    retrieve the configuration information.
+	 *
+	 * as well as the rules set out in RFC 9207 for avoiding mix-up attacks:
+	 *
+	 *    Clients MUST then [...] compare the result to the issuer identifier
+	 *    of the authorization server where the authorization request was
+	 *    sent to. This comparison MUST use simple string comparison as defined
+	 *    in Section 6.2.1 of [RFC3986].
+	 *
+	 * TODO: Encoding support?
+	 */
+	if (strcmp(conn->oauth_issuer_id, provider->issuer) != 0)
+	{
+		actx_error(actx,
+				   "the issuer identifier (%s) does not match oauth_issuer (%s)",
+				   provider->issuer, conn->oauth_issuer_id);
+		return false;
+	}
+
+	return true;
+}
+
+#define OAUTH_GRANT_TYPE_DEVICE_CODE "urn:ietf:params:oauth:grant-type:device_code"
+
+/*
+ * Ensure that the provider supports the Device Authorization flow (i.e. it
+ * accepts the device_code grant type and provides an authorization endpoint).
+ */
+static bool
+check_for_device_flow(struct async_ctx *actx)
+{
+	const struct provider *provider = &actx->provider;
+	const struct curl_slist *grant;
+	bool		device_grant_found = false;
+
+	Assert(provider->issuer);	/* ensured by parse_provider() */
+
+	/*------
+	 * First, sanity checks for discovery contents that are OPTIONAL in the
+	 * spec but required for our flow:
+	 * - the issuer must support the device_code grant
+	 * - the issuer must have actually given us a
+	 *   device_authorization_endpoint
+	 */
+
+	grant = provider->grant_types_supported;
+	while (grant)
+	{
+		if (strcmp(grant->data, OAUTH_GRANT_TYPE_DEVICE_CODE) == 0)
+		{
+			device_grant_found = true;
+			break;
+		}
+
+		grant = grant->next;
+	}
+
+	if (!device_grant_found)
+	{
+		actx_error(actx, "issuer \"%s\" does not support device code grants",
+				   provider->issuer);
+		return false;
+	}
+
+	if (!provider->device_authorization_endpoint)
+	{
+		actx_error(actx,
+				   "issuer \"%s\" does not provide a device authorization endpoint",
+				   provider->issuer);
+		return false;
+	}
+
+	/* TODO: check that the endpoint uses HTTPS */
+
+	return true;
+}
+
+/*
+ * Adds the client ID (and secret, if provided) to the current request, using
+ * either HTTP headers or the request body.
+ */
+static bool
+add_client_identification(struct async_ctx *actx, PQExpBuffer reqbody, PGconn *conn)
+{
+	bool		success = false;
+	char	   *username = NULL;
+	char	   *password = NULL;
+
+	if (conn->oauth_client_secret)	/* Zero-length secrets are permitted! */
+	{
+		/*----
+		 * Use HTTP Basic auth to send the client_id and secret. Per RFC 6749,
+		 * Sec. 2.3.1,
+		 *
+		 *   Including the client credentials in the request-body using the
+		 *   two parameters is NOT RECOMMENDED and SHOULD be limited to
+		 *   clients unable to directly utilize the HTTP Basic authentication
+		 *   scheme (or other password-based HTTP authentication schemes).
+		 *
+		 * Additionally:
+		 *
+		 *   The client identifier is encoded using the
+		 *   "application/x-www-form-urlencoded" encoding algorithm per Appendix
+		 *   B, and the encoded value is used as the username; the client
+		 *   password is encoded using the same algorithm and used as the
+		 *   password.
+		 *
+		 * (Appendix B modifies application/x-www-form-urlencoded by requiring
+		 * an initial UTF-8 encoding step. Since the client ID and secret must
+		 * both be 7-bit ASCII -- RFC 6749 Appendix A -- we don't worry about
+		 * that in this function.)
+		 *
+		 * client_id is not added to the request body in this case. Not only
+		 * would it be redundant, but some providers in the wild (e.g. Okta)
+		 * refuse to accept it.
+		 */
+		username = urlencode(conn->oauth_client_id);
+		password = urlencode(conn->oauth_client_secret);
+
+		if (!username || !password)
+		{
+			actx_error(actx, "out of memory");
+			goto cleanup;
+		}
+
+		CHECK_SETOPT(actx, CURLOPT_HTTPAUTH, CURLAUTH_BASIC, goto cleanup);
+		CHECK_SETOPT(actx, CURLOPT_USERNAME, username, goto cleanup);
+		CHECK_SETOPT(actx, CURLOPT_PASSWORD, password, goto cleanup);
+
+		actx->used_basic_auth = true;
+	}
+	else
+	{
+		/*
+		 * If we're not otherwise authenticating, client_id is REQUIRED in the
+		 * request body.
+		 */
+		build_urlencoded(reqbody, "client_id", conn->oauth_client_id);
+
+		CHECK_SETOPT(actx, CURLOPT_HTTPAUTH, CURLAUTH_NONE, goto cleanup);
+		actx->used_basic_auth = false;
+	}
+
+	success = true;
+
+cleanup:
+	free(username);
+	free(password);
+
+	return success;
+}
+
+/*
+ * Queue a Device Authorization Request:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc8628#section-3.1
+ *
+ * This is the second step. We ask the provider to verify the end user out of
+ * band and authorize us to act on their behalf; it will give us the required
+ * nonces for us to later poll the request status, which we'll grab in
+ * finish_device_authz().
+ */
+static bool
+start_device_authz(struct async_ctx *actx, PGconn *conn)
+{
+	const char *device_authz_uri = actx->provider.device_authorization_endpoint;
+	PQExpBuffer work_buffer = &actx->work_data;
+
+	Assert(conn->oauth_client_id);	/* ensured by setup_oauth_parameters() */
+	Assert(device_authz_uri);	/* ensured by check_for_device_flow() */
+
+	/* Construct our request body. */
+	resetPQExpBuffer(work_buffer);
+	if (conn->oauth_scope && conn->oauth_scope[0])
+		build_urlencoded(work_buffer, "scope", conn->oauth_scope);
+
+	if (!add_client_identification(actx, work_buffer, conn))
+		return false;
+
+	if (PQExpBufferBroken(work_buffer))
+	{
+		actx_error(actx, "out of memory");
+		return false;
+	}
+
+	/* Make our request. */
+	CHECK_SETOPT(actx, CURLOPT_URL, device_authz_uri, return false);
+	CHECK_SETOPT(actx, CURLOPT_COPYPOSTFIELDS, work_buffer->data, return false);
+
+	return start_request(actx);
+}
+
+static bool
+finish_device_authz(struct async_ctx *actx)
+{
+	long		response_code;
+
+	CHECK_GETINFO(actx, CURLINFO_RESPONSE_CODE, &response_code, return false);
+
+	/*
+	 * Per RFC 8628, Section 3, a successful device authorization response
+	 * uses 200 OK.
+	 */
+	if (response_code == 200)
+	{
+		actx->errctx = "failed to parse device authorization";
+		if (!parse_device_authz(actx, &actx->authz))
+			return false;		/* error message already set */
+
+		return true;
+	}
+
+	/*
+	 * The device authorization endpoint uses the same error response as the
+	 * token endpoint, so the error handling roughly follows
+	 * finish_token_request(). The key difference is that an error here is
+	 * immediately fatal.
+	 */
+	if (response_code == 400 || response_code == 401)
+	{
+		struct token_error err = {0};
+
+		if (!parse_token_error(actx, &err))
+		{
+			free_token_error(&err);
+			return false;
+		}
+
+		record_token_error(actx, &err);
+
+		free_token_error(&err);
+		return false;
+	}
+
+	/* Any other response codes are considered invalid */
+	actx_error(actx, "unexpected response code %ld", response_code);
+	return false;
+}
+
+/*
+ * Queue an Access Token Request:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc6749#section-4.1.3
+ *
+ * This is the final step. We continually poll the token endpoint to see if the
+ * user has authorized us yet. finish_token_request() will pull either the token
+ * or a (ideally temporary) error status from the provider.
+ */
+static bool
+start_token_request(struct async_ctx *actx, PGconn *conn)
+{
+	const char *token_uri = actx->provider.token_endpoint;
+	const char *device_code = actx->authz.device_code;
+	PQExpBuffer work_buffer = &actx->work_data;
+
+	Assert(conn->oauth_client_id);	/* ensured by setup_oauth_parameters() */
+	Assert(token_uri);			/* ensured by parse_provider() */
+	Assert(device_code);		/* ensured by parse_device_authz() */
+
+	/* Construct our request body. */
+	resetPQExpBuffer(work_buffer);
+	build_urlencoded(work_buffer, "device_code", device_code);
+	build_urlencoded(work_buffer, "grant_type", OAUTH_GRANT_TYPE_DEVICE_CODE);
+
+	if (!add_client_identification(actx, work_buffer, conn))
+		return false;
+
+	if (PQExpBufferBroken(work_buffer))
+	{
+		actx_error(actx, "out of memory");
+		return false;
+	}
+
+	/* Make our request. */
+	CHECK_SETOPT(actx, CURLOPT_URL, token_uri, return false);
+	CHECK_SETOPT(actx, CURLOPT_COPYPOSTFIELDS, work_buffer->data, return false);
+
+	return start_request(actx);
+}
+
+static bool
+finish_token_request(struct async_ctx *actx, struct token *tok)
+{
+	long		response_code;
+
+	CHECK_GETINFO(actx, CURLINFO_RESPONSE_CODE, &response_code, return false);
+
+	/*
+	 * Per RFC 6749, Section 5, a successful response uses 200 OK.
+	 */
+	if (response_code == 200)
+	{
+		actx->errctx = "failed to parse access token response";
+		if (!parse_access_token(actx, tok))
+			return false;		/* error message already set */
+
+		return true;
+	}
+
+	/*
+	 * An error response uses either 400 Bad Request or 401 Unauthorized.
+	 * There are references online to implementations using 403 for error
+	 * return which would violate the specification. For now we stick to the
+	 * specification but we might have to revisit this.
+	 */
+	if (response_code == 400 || response_code == 401)
+	{
+		if (!parse_token_error(actx, &tok->err))
+			return false;
+
+		return true;
+	}
+
+	/* Any other response codes are considered invalid */
+	actx_error(actx, "unexpected response code %ld", response_code);
+	return false;
+}
+
+/*
+ * Finishes the token request and examines the response. If the flow has
+ * completed, a valid token will be returned via the parameter list. Otherwise,
+ * the token parameter remains unchanged, and the caller needs to wait for
+ * another interval (which will have been increased in response to a slow_down
+ * message from the server) before starting a new token request.
+ *
+ * False is returned only for permanent error conditions.
+ */
+static bool
+handle_token_response(struct async_ctx *actx, char **token)
+{
+	bool		success = false;
+	struct token tok = {0};
+	const struct token_error *err;
+
+	if (!finish_token_request(actx, &tok))
+		goto token_cleanup;
+
+	/* A successful token request gives either a token or an in-band error. */
+	Assert(tok.access_token || tok.err.error);
+
+	if (tok.access_token)
+	{
+		*token = tok.access_token;
+		tok.access_token = NULL;
+
+		success = true;
+		goto token_cleanup;
+	}
+
+	/*
+	 * authorization_pending and slow_down are the only acceptable errors;
+	 * anything else and we bail. These are defined in RFC 8628, Sec. 3.5.
+	 */
+	err = &tok.err;
+	if (strcmp(err->error, "authorization_pending") != 0 &&
+		strcmp(err->error, "slow_down") != 0)
+	{
+		record_token_error(actx, err);
+		goto token_cleanup;
+	}
+
+	/*
+	 * A slow_down error requires us to permanently increase our retry
+	 * interval by five seconds.
+	 */
+	if (strcmp(err->error, "slow_down") == 0)
+	{
+		int			prev_interval = actx->authz.interval;
+
+		actx->authz.interval += 5;
+		if (actx->authz.interval < prev_interval)
+		{
+			actx_error(actx, "slow_down interval overflow");
+			goto token_cleanup;
+		}
+	}
+
+	success = true;
+
+token_cleanup:
+	free_token(&tok);
+	return success;
+}
+
+/*
+ * Displays a device authorization prompt for action by the end user, either via
+ * the PQauthDataHook, or by a message on standard error if no hook is set.
+ */
+static bool
+prompt_user(struct async_ctx *actx, PGconn *conn)
+{
+	int			res;
+	PGpromptOAuthDevice prompt = {
+		.verification_uri = actx->authz.verification_uri,
+		.user_code = actx->authz.user_code,
+		/* TODO: optional fields */
+	};
+
+	res = PQauthDataHook(PQAUTHDATA_PROMPT_OAUTH_DEVICE, conn, &prompt);
+
+	if (!res)
+	{
+		/*
+		 * translator: The first %s is a URL for the user to visit in a
+		 * browser, and the second %s is a code to be copy-pasted there.
+		 */
+		fprintf(stderr, libpq_gettext("Visit %s and enter the code: %s\n"),
+				prompt.verification_uri, prompt.user_code);
+	}
+	else if (res < 0)
+	{
+		actx_error(actx, "device prompt failed");
+		return false;
+	}
+
+	return true;
+}
+
+
+/*
+ * The core nonblocking libcurl implementation. This will be called several
+ * times to pump the async engine.
+ *
+ * The architecture is based on PQconnectPoll(). The first half drives the
+ * connection state forward as necessary, returning if we're not ready to
+ * proceed to the next step yet. The second half performs the actual transition
+ * between states.
+ *
+ * You can trace the overall OAuth flow through the second half. It's linear
+ * until we get to the end, where we flip back and forth between
+ * OAUTH_STEP_TOKEN_REQUEST and OAUTH_STEP_WAIT_INTERVAL to regularly ping the
+ * provider.
+ */
+static PostgresPollingStatusType
+pg_fe_run_oauth_flow_impl(PGconn *conn)
+{
+	fe_oauth_state *state = conn->sasl_state;
+	struct async_ctx *actx;
+
+	/*
+	 * XXX This is not safe. libcurl has stringent requirements for the thread
+	 * context in which you call curl_global_init(), because it's going to try
+	 * initializing a bunch of other libraries (OpenSSL, Winsock...). And we
+	 * probably need to consider both the TLS backend libcurl is compiled
+	 * against and what the user has asked us to do via PQinit[Open]SSL.
+	 *
+	 * Recent versions of libcurl have improved the thread-safety situation,
+	 * but you apparently can't check at compile time whether the
+	 * implementation is thread-safe, and there's a chicken-and-egg problem
+	 * where you can't check the thread safety until you've initialized
+	 * libcurl, which you can't do before you've made sure it's thread-safe...
+	 *
+	 * We know we've already initialized Winsock by this point, so we should
+	 * be able to safely skip that bit. But we have to tell libcurl to
+	 * initialize everything else, because other pieces of our client
+	 * executable may already be using libcurl for their own purposes. If we
+	 * initialize libcurl first, with only a subset of its features, we could
+	 * break those other clients nondeterministically, and that would probably
+	 * be a nightmare to debug.
+	 */
+	curl_global_init(CURL_GLOBAL_ALL
+					 & ~CURL_GLOBAL_WIN32); /* we already initialized Winsock */
+
+	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;
+#ifdef HAVE_SYS_EPOLL_H
+		actx->timerfd = -1;
+#endif
+
+		/* 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. */
+		conn->altsock = actx->mux;
+
+		switch (actx->step)
+		{
+			case OAUTH_STEP_INIT:
+				break;
+
+			case OAUTH_STEP_DISCOVERY:
+			case OAUTH_STEP_DEVICE_AUTHORIZATION:
+			case OAUTH_STEP_TOKEN_REQUEST:
+				{
+					PostgresPollingStatusType status;
+
+					status = drive_request(actx);
+
+					if (status == PGRES_POLLING_FAILED)
+						goto error_return;
+					else if (status != PGRES_POLLING_OK)
+					{
+						/* not done yet */
+						return status;
+					}
+				}
+
+			case OAUTH_STEP_WAIT_INTERVAL:
+				/* TODO check that the timer has expired */
+				break;
+		}
+
+		/*
+		 * Each case here must ensure that actx->running is set while we're
+		 * waiting on some asynchronous work. Most cases rely on
+		 * start_request() to do that for them.
+		 */
+		switch (actx->step)
+		{
+			case OAUTH_STEP_INIT:
+				actx->errctx = "failed to fetch OpenID discovery document";
+				if (!start_discovery(actx, conn->oauth_discovery_uri))
+					goto error_return;
+
+				actx->step = OAUTH_STEP_DISCOVERY;
+				break;
+
+			case OAUTH_STEP_DISCOVERY:
+				if (!finish_discovery(actx))
+					goto error_return;
+
+				if (!check_issuer(actx, conn))
+					goto error_return;
+
+				actx->errctx = "cannot run OAuth device authorization";
+				if (!check_for_device_flow(actx))
+					goto error_return;
+
+				actx->errctx = "failed to obtain device authorization";
+				if (!start_device_authz(actx, conn))
+					goto error_return;
+
+				actx->step = OAUTH_STEP_DEVICE_AUTHORIZATION;
+				break;
+
+			case OAUTH_STEP_DEVICE_AUTHORIZATION:
+				if (!finish_device_authz(actx))
+					goto error_return;
+
+				actx->errctx = "failed to obtain access token";
+				if (!start_token_request(actx, conn))
+					goto error_return;
+
+				actx->step = OAUTH_STEP_TOKEN_REQUEST;
+				break;
+
+			case OAUTH_STEP_TOKEN_REQUEST:
+				if (!handle_token_response(actx, &conn->oauth_token))
+					goto error_return;
+
+				if (!actx->user_prompted)
+				{
+					/*
+					 * Now that we know the token endpoint isn't broken, give
+					 * the user the login instructions.
+					 */
+					if (!prompt_user(actx, conn))
+						goto error_return;
+
+					actx->user_prompted = true;
+				}
+
+				if (conn->oauth_token)
+					break;		/* done! */
+
+				/*
+				 * Wait for the required interval before issuing the next
+				 * request.
+				 */
+				if (!set_timer(actx, actx->authz.interval * 1000))
+					goto error_return;
+
+#ifdef HAVE_SYS_EPOLL_H
+
+				/*
+				 * No Curl requests are running, so we can simplify by having
+				 * the client wait directly on the timerfd rather than the
+				 * multiplexer. (This isn't possible for kqueue.)
+				 */
+				conn->altsock = actx->timerfd;
+#endif
+
+				actx->step = OAUTH_STEP_WAIT_INTERVAL;
+				actx->running = 1;
+				break;
+
+			case OAUTH_STEP_WAIT_INTERVAL:
+				actx->errctx = "failed to obtain access token";
+				if (!start_token_request(actx, conn))
+					goto error_return;
+
+				actx->step = OAUTH_STEP_TOKEN_REQUEST;
+				break;
+		}
+
+		/*
+		 * The vast majority of the time, if we don't have a token at this
+		 * point, actx->running will be set. But there are some corner cases
+		 * where we can immediately loop back around; see start_request().
+		 */
+	} while (!conn->oauth_token && !actx->running);
+
+	/* If we've stored a token, we're done. Otherwise come back later. */
+	return conn->oauth_token ? PGRES_POLLING_OK : PGRES_POLLING_READING;
+
+error_return:
+
+	/*
+	 * Assemble the three parts of our error: context, body, and detail. See
+	 * also the documentation for struct async_ctx.
+	 */
+	if (actx->errctx)
+	{
+		appendPQExpBufferStr(&conn->errorMessage,
+							 libpq_gettext(actx->errctx));
+		appendPQExpBufferStr(&conn->errorMessage, ": ");
+	}
+
+	if (PQExpBufferDataBroken(actx->errbuf))
+		appendPQExpBufferStr(&conn->errorMessage,
+							 libpq_gettext("out of memory"));
+	else
+		appendPQExpBufferStr(&conn->errorMessage, actx->errbuf.data);
+
+	if (actx->curl_err[0])
+	{
+		size_t		len;
+
+		appendPQExpBuffer(&conn->errorMessage, " (%s)", actx->curl_err);
+
+		/* Sometimes libcurl adds a newline to the error buffer. :( */
+		len = conn->errorMessage.len;
+		if (len >= 2 && conn->errorMessage.data[len - 2] == '\n')
+		{
+			conn->errorMessage.data[len - 2] = ')';
+			conn->errorMessage.data[len - 1] = '\0';
+			conn->errorMessage.len--;
+		}
+	}
+
+	appendPQExpBufferStr(&conn->errorMessage, "\n");
+
+	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.
+ */
+PostgresPollingStatusType
+pg_fe_run_oauth_flow(PGconn *conn)
+{
+	PostgresPollingStatusType result;
+#ifndef WIN32
+	sigset_t	osigset;
+	bool		sigpipe_pending;
+	bool		masked;
+
+	/*---
+	 * Ignore SIGPIPE on this thread during all Curl processing.
+	 *
+	 * Because we support multiple threads, we have to set up libcurl with
+	 * CURLOPT_NOSIGNAL, which disables its default global handling of
+	 * SIGPIPE. From the Curl docs:
+	 *
+	 *     libcurl makes an effort to never cause such SIGPIPE signals to
+	 *     trigger, but some operating systems have no way to avoid them and
+	 *     even on those that have there are some corner cases when they may
+	 *     still happen, contrary to our desire.
+	 *
+	 * Note that libcurl is also at the mercy of its DNS resolution and SSL
+	 * libraries; if any of them forget a MSG_NOSIGNAL then we're in trouble.
+	 * Modern platforms and libraries seem to get it right, so this is a
+	 * difficult corner case to exercise in practice, and unfortunately it's
+	 * not really clear whether it's necessary in all cases.
+	 */
+	masked = (pq_block_sigpipe(&osigset, &sigpipe_pending) == 0);
+#endif
+
+	result = pg_fe_run_oauth_flow_impl(conn);
+
+#ifndef WIN32
+	if (masked)
+	{
+		/*
+		 * Undo the SIGPIPE mask. Assume we may have gotten EPIPE (we have no
+		 * way of knowing at this level).
+		 */
+		pq_reset_sigpipe(&osigset, sigpipe_pending, true /* EPIPE, maybe */ );
+	}
+#endif
+
+	return result;
+}
diff --git a/src/interfaces/libpq/fe-auth-oauth.c b/src/interfaces/libpq/fe-auth-oauth.c
new file mode 100644
index 00000000000..cc53e2bdd1a
--- /dev/null
+++ b/src/interfaces/libpq/fe-auth-oauth.c
@@ -0,0 +1,1141 @@
+/*-------------------------------------------------------------------------
+ *
+ * fe-auth-oauth.c
+ *	   The front-end (client) implementation of OAuth/OIDC authentication
+ *	   using the SASL OAUTHBEARER mechanism.
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/interfaces/libpq/fe-auth-oauth.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres_fe.h"
+
+#include "common/base64.h"
+#include "common/hmac.h"
+#include "common/jsonapi.h"
+#include "common/oauth-common.h"
+#include "fe-auth.h"
+#include "fe-auth-oauth.h"
+#include "mb/pg_wchar.h"
+
+/* The exported OAuth callback mechanism. */
+static void *oauth_init(PGconn *conn, const char *password,
+						const char *sasl_mechanism);
+static SASLStatus oauth_exchange(void *opaq, bool final,
+								 char *input, int inputlen,
+								 char **output, int *outputlen);
+static bool oauth_channel_bound(void *opaq);
+static void oauth_free(void *opaq);
+
+const pg_fe_sasl_mech pg_oauth_mech = {
+	oauth_init,
+	oauth_exchange,
+	oauth_channel_bound,
+	oauth_free,
+};
+
+/*
+ * Initializes mechanism state for OAUTHBEARER.
+ *
+ * For a full description of the API, see libpq/fe-auth-sasl.h.
+ */
+static void *
+oauth_init(PGconn *conn, const char *password,
+		   const char *sasl_mechanism)
+{
+	fe_oauth_state *state;
+
+	/*
+	 * We only support one SASL mechanism here; anything else is programmer
+	 * error.
+	 */
+	Assert(sasl_mechanism != NULL);
+	Assert(strcmp(sasl_mechanism, OAUTHBEARER_NAME) == 0);
+
+	state = calloc(1, sizeof(*state));
+	if (!state)
+		return NULL;
+
+	state->step = FE_OAUTH_INIT;
+	state->conn = conn;
+
+	return state;
+}
+
+/*
+ * Frees the state allocated by oauth_init().
+ *
+ * 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().
+ */
+static void
+oauth_free(void *opaq)
+{
+	fe_oauth_state *state = opaq;
+
+	/* Any async authentication state should have been cleaned up already. */
+	Assert(!state->async_ctx);
+
+	free(state);
+}
+
+#define kvsep "\x01"
+
+/*
+ * Constructs an OAUTHBEARER client initial response (RFC 7628, Sec. 3.1).
+ *
+ * If discover is true, the initial response will contain a request for the
+ * server's required OAuth parameters (Sec. 4.3). Otherwise, conn->token must
+ * be set; it will be sent as the connection's bearer token.
+ *
+ * Returns the response as a null-terminated string, or NULL on error.
+ */
+static char *
+client_initial_response(PGconn *conn, bool discover)
+{
+	static const char *const resp_format = "n,," kvsep "auth=%s%s" kvsep kvsep;
+
+	PQExpBufferData buf;
+	const char *authn_scheme;
+	char	   *response = NULL;
+	const char *token = conn->oauth_token;
+
+	if (discover)
+	{
+		/* Parameter discovery uses a completely empty auth value. */
+		authn_scheme = token = "";
+	}
+	else
+	{
+		/*
+		 * Use a Bearer authentication scheme (RFC 6750, Sec. 2.1). A trailing
+		 * space is used as a separator.
+		 */
+		authn_scheme = "Bearer ";
+
+		/* conn->token must have been set in this case. */
+		if (!token)
+		{
+			Assert(false);
+			libpq_append_conn_error(conn,
+									"internal error: no OAuth token was set for the connection");
+			return NULL;
+		}
+	}
+
+	initPQExpBuffer(&buf);
+	appendPQExpBuffer(&buf, resp_format, authn_scheme, token);
+
+	if (!PQExpBufferDataBroken(buf))
+		response = strdup(buf.data);
+	termPQExpBuffer(&buf);
+
+	if (!response)
+		libpq_append_conn_error(conn, "out of memory");
+
+	return response;
+}
+
+/*
+ * JSON Parser (for the OAUTHBEARER error result)
+ */
+
+/* Relevant JSON fields in the error result object. */
+#define ERROR_STATUS_FIELD "status"
+#define ERROR_SCOPE_FIELD "scope"
+#define ERROR_OPENID_CONFIGURATION_FIELD "openid-configuration"
+
+struct json_ctx
+{
+	char	   *errmsg;			/* any non-NULL value stops all processing */
+	PQExpBufferData errbuf;		/* backing memory for errmsg */
+	int			nested;			/* nesting level (zero is the top) */
+
+	const char *target_field_name;	/* points to a static allocation */
+	char	  **target_field;	/* see below */
+
+	/* target_field, if set, points to one of the following: */
+	char	   *status;
+	char	   *scope;
+	char	   *discovery_uri;
+};
+
+#define oauth_json_has_error(ctx) \
+	(PQExpBufferDataBroken((ctx)->errbuf) || (ctx)->errmsg)
+
+#define oauth_json_set_error(ctx, ...) \
+	do { \
+		appendPQExpBuffer(&(ctx)->errbuf, __VA_ARGS__); \
+		(ctx)->errmsg = (ctx)->errbuf.data; \
+	} while (0)
+
+static JsonParseErrorType
+oauth_json_object_start(void *state)
+{
+	struct json_ctx *ctx = state;
+
+	if (ctx->target_field)
+	{
+		Assert(ctx->nested == 1);
+
+		oauth_json_set_error(ctx,
+							 libpq_gettext("field \"%s\" must be a string"),
+							 ctx->target_field_name);
+	}
+
+	++ctx->nested;
+	return oauth_json_has_error(ctx) ? JSON_SEM_ACTION_FAILED : JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_object_end(void *state)
+{
+	struct json_ctx *ctx = state;
+
+	--ctx->nested;
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_object_field_start(void *state, char *name, bool isnull)
+{
+	struct json_ctx *ctx = state;
+
+	if (ctx->nested == 1)
+	{
+		if (strcmp(name, ERROR_STATUS_FIELD) == 0)
+		{
+			ctx->target_field_name = ERROR_STATUS_FIELD;
+			ctx->target_field = &ctx->status;
+		}
+		else if (strcmp(name, ERROR_SCOPE_FIELD) == 0)
+		{
+			ctx->target_field_name = ERROR_SCOPE_FIELD;
+			ctx->target_field = &ctx->scope;
+		}
+		else if (strcmp(name, ERROR_OPENID_CONFIGURATION_FIELD) == 0)
+		{
+			ctx->target_field_name = ERROR_OPENID_CONFIGURATION_FIELD;
+			ctx->target_field = &ctx->discovery_uri;
+		}
+	}
+
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_array_start(void *state)
+{
+	struct json_ctx *ctx = state;
+
+	if (!ctx->nested)
+	{
+		ctx->errmsg = libpq_gettext("top-level element must be an object");
+	}
+	else if (ctx->target_field)
+	{
+		Assert(ctx->nested == 1);
+
+		oauth_json_set_error(ctx,
+							 libpq_gettext("field \"%s\" must be a string"),
+							 ctx->target_field_name);
+	}
+
+	return oauth_json_has_error(ctx) ? JSON_SEM_ACTION_FAILED : JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_scalar(void *state, char *token, JsonTokenType type)
+{
+	struct json_ctx *ctx = state;
+
+	if (!ctx->nested)
+	{
+		ctx->errmsg = libpq_gettext("top-level element must be an object");
+		return JSON_SEM_ACTION_FAILED;
+	}
+
+	if (ctx->target_field)
+	{
+		Assert(ctx->nested == 1);
+
+		/*
+		 * We don't allow duplicate field names; error out if the target has
+		 * already been set.
+		 */
+		if (*ctx->target_field)
+		{
+			oauth_json_set_error(ctx,
+								 libpq_gettext("field \"%s\" is duplicated"),
+								 ctx->target_field_name);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		/* The only fields we support are strings. */
+		if (type != JSON_TOKEN_STRING)
+		{
+			oauth_json_set_error(ctx,
+								 libpq_gettext("field \"%s\" must be a string"),
+								 ctx->target_field_name);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		*ctx->target_field = strdup(token);
+		if (!*ctx->target_field)
+			return JSON_OUT_OF_MEMORY;
+
+		ctx->target_field = NULL;
+		ctx->target_field_name = NULL;
+	}
+	else
+	{
+		/* otherwise we just ignore it */
+	}
+
+	return JSON_SUCCESS;
+}
+
+#define HTTPS_SCHEME "https://"
+#define HTTP_SCHEME "http://"
+
+/* We support both well-known suffixes defined by RFC 8414. */
+#define WK_PREFIX "/.well-known/"
+#define OPENID_WK_SUFFIX "openid-configuration"
+#define OAUTH_WK_SUFFIX "oauth-authorization-server"
+
+/*
+ * Derives an issuer identifier from one of our recognized .well-known URIs,
+ * using the rules in RFC 8414.
+ */
+static char *
+issuer_from_well_known_uri(PGconn *conn, const char *wkuri)
+{
+	const char *authority_start = NULL;
+	const char *wk_start;
+	const char *wk_end;
+	char	   *issuer;
+	ptrdiff_t	start_offset,
+				end_offset;
+	size_t		end_len;
+
+	/*
+	 * https:// is required for issuer identifiers (RFC 8414, Sec. 2; OIDC
+	 * Discovery 1.0, Sec. 3). This is a case-insensitive comparison at this
+	 * level (but issuer identifier comparison at the level above this is
+	 * case-sensitive, so in practice it's probably moot).
+	 */
+	if (pg_strncasecmp(wkuri, HTTPS_SCHEME, strlen(HTTPS_SCHEME)) == 0)
+		authority_start = wkuri + strlen(HTTPS_SCHEME);
+
+	if (!authority_start
+		&& oauth_unsafe_debugging_enabled()
+		&& pg_strncasecmp(wkuri, HTTP_SCHEME, strlen(HTTP_SCHEME)) == 0)
+	{
+		/* Allow http:// for testing only. */
+		authority_start = wkuri + strlen(HTTP_SCHEME);
+	}
+
+	if (!authority_start)
+	{
+		libpq_append_conn_error(conn,
+								"OAuth discovery URI \"%s\" must use HTTPS",
+								wkuri);
+		return NULL;
+	}
+
+	/*
+	 * Well-known URIs in general may support queries and fragments, but the
+	 * two types we support here do not. (They must be constructed from the
+	 * components of issuer identifiers, which themselves may not contain any
+	 * queries or fragments.)
+	 *
+	 * It's important to check this first, to avoid getting tricked later by a
+	 * prefix buried inside a query or fragment.
+	 */
+	if (strpbrk(authority_start, "?#") != NULL)
+	{
+		libpq_append_conn_error(conn,
+								"OAuth discovery URI \"%s\" must not contain query or fragment components",
+								wkuri);
+		return NULL;
+	}
+
+	/*
+	 * Find the start of the .well-known prefix. IETF rules (RFC 8615) state
+	 * this must be at the beginning of the path component, but OIDC defined
+	 * it at the end instead (OIDC Discovery 1.0, Sec. 4), so we have to
+	 * search for it anywhere.
+	 */
+	wk_start = strstr(authority_start, WK_PREFIX);
+	if (!wk_start)
+	{
+		libpq_append_conn_error(conn,
+								"OAuth discovery URI \"%s\" is not a .well-known URI",
+								wkuri);
+		return NULL;
+	}
+
+	/*
+	 * Now find the suffix type. We only support the two defined in OIDC
+	 * Discovery 1.0 and RFC 8414.
+	 */
+	wk_end = wk_start + strlen(WK_PREFIX);
+
+	if (strncmp(wk_end, OPENID_WK_SUFFIX, strlen(OPENID_WK_SUFFIX)) == 0)
+		wk_end += strlen(OPENID_WK_SUFFIX);
+	else if (strncmp(wk_end, OAUTH_WK_SUFFIX, strlen(OAUTH_WK_SUFFIX)) == 0)
+		wk_end += strlen(OAUTH_WK_SUFFIX);
+	else
+		wk_end = NULL;
+
+	/*
+	 * Even if there's a match, we still need to check to make sure the suffix
+	 * takes up the entire path segment, to weed out constructions like
+	 * "/.well-known/openid-configuration-bad".
+	 */
+	if (!wk_end || (*wk_end != '/' && *wk_end != '\0'))
+	{
+		libpq_append_conn_error(conn,
+								"OAuth discovery URI \"%s\" uses an unsupported .well-known suffix",
+								wkuri);
+		return NULL;
+	}
+
+	/*
+	 * Finally, make sure the .well-known components are provided either as a
+	 * prefix (IETF style) or as a postfix (OIDC style). In other words,
+	 * "https://localhost/a/.well-known/openid-configuration/b" is not allowed
+	 * to claim association with "https://localhost/a/b".
+	 */
+	if (*wk_end != '\0')
+	{
+		/*
+		 * It's not at the end, so it's required to be at the beginning at the
+		 * path. Find the starting slash.
+		 */
+		const char *path_start;
+
+		path_start = strchr(authority_start, '/');
+		Assert(path_start);		/* otherwise we wouldn't have found WK_PREFIX */
+
+		if (wk_start != path_start)
+		{
+			libpq_append_conn_error(conn,
+									"OAuth discovery URI \"%s\" uses an invalid format",
+									wkuri);
+			return NULL;
+		}
+	}
+
+	/* Checks passed! Now build the issuer. */
+	issuer = strdup(wkuri);
+	if (!issuer)
+	{
+		libpq_append_conn_error(conn, "out of memory");
+		return NULL;
+	}
+
+	/*
+	 * The .well-known components are from [wk_start, wk_end). Remove those to
+	 * form the issuer ID, by shifting the path suffix (which may be empty)
+	 * leftwards.
+	 */
+	start_offset = wk_start - wkuri;
+	end_offset = wk_end - wkuri;
+	end_len = strlen(wk_end) + 1;	/* move the NULL terminator too */
+
+	memmove(issuer + start_offset, issuer + end_offset, end_len);
+
+	return issuer;
+}
+
+/*
+ * Parses the server error result (RFC 7628, Sec. 3.2.2) contained in msg and
+ * stores any discovered openid_configuration and scope settings for the
+ * connection.
+ */
+static bool
+handle_oauth_sasl_error(PGconn *conn, const char *msg, int msglen)
+{
+	JsonLexContext lex = {0};
+	JsonSemAction sem = {0};
+	JsonParseErrorType err;
+	struct json_ctx ctx = {0};
+	char	   *errmsg = NULL;
+	bool		success = false;
+
+	Assert(conn->oauth_issuer_id);	/* ensured by setup_oauth_parameters() */
+
+	/* Sanity check. */
+	if (strlen(msg) != msglen)
+	{
+		libpq_append_conn_error(conn,
+								"server's error message contained an embedded NULL, and was discarded");
+		return false;
+	}
+
+	/*
+	 * pg_parse_json doesn't validate the incoming UTF-8, so we have to check
+	 * that up front.
+	 */
+	if (pg_encoding_verifymbstr(PG_UTF8, msg, msglen) != msglen)
+	{
+		libpq_append_conn_error(conn,
+								"server's error response is not valid UTF-8");
+		return false;
+	}
+
+	makeJsonLexContextCstringLen(&lex, msg, msglen, PG_UTF8, true);
+	setJsonLexContextOwnsTokens(&lex, true);	/* must not leak on error */
+
+	initPQExpBuffer(&ctx.errbuf);
+	sem.semstate = &ctx;
+
+	sem.object_start = oauth_json_object_start;
+	sem.object_end = oauth_json_object_end;
+	sem.object_field_start = oauth_json_object_field_start;
+	sem.array_start = oauth_json_array_start;
+	sem.scalar = oauth_json_scalar;
+
+	err = pg_parse_json(&lex, &sem);
+
+	if (err == JSON_SEM_ACTION_FAILED)
+	{
+		if (PQExpBufferDataBroken(ctx.errbuf))
+			errmsg = libpq_gettext("out of memory");
+		else if (ctx.errmsg)
+			errmsg = ctx.errmsg;
+		else
+		{
+			/*
+			 * Developer error: one of the action callbacks didn't call
+			 * oauth_json_set_error() before erroring out.
+			 */
+			Assert(oauth_json_has_error(&ctx));
+			errmsg = "<unexpected empty error>";
+		}
+	}
+	else if (err != JSON_SUCCESS)
+		errmsg = json_errdetail(err, &lex);
+
+	if (errmsg)
+		libpq_append_conn_error(conn,
+								"failed to parse server's error response: %s",
+								errmsg);
+
+	/* Don't need the error buffer or the JSON lexer anymore. */
+	termPQExpBuffer(&ctx.errbuf);
+	freeJsonLexContext(&lex);
+
+	if (errmsg)
+		goto cleanup;
+
+	if (ctx.discovery_uri)
+	{
+		char	   *discovery_issuer;
+
+		/*
+		 * The URI MUST correspond to our existing issuer, to avoid mix-ups.
+		 *
+		 * Issuer comparison is done byte-wise, rather than performing any URL
+		 * normalization; this follows the suggestions for issuer comparison
+		 * in RFC 9207 Sec. 2.4 (which requires simple string comparison) and
+		 * vastly simplifies things. Since this is the key protection against
+		 * a rogue server sending the client to an untrustworthy location,
+		 * simpler is better.
+		 */
+		discovery_issuer = issuer_from_well_known_uri(conn, ctx.discovery_uri);
+		if (!discovery_issuer)
+			goto cleanup;		/* error message already set */
+
+		if (strcmp(conn->oauth_issuer_id, discovery_issuer) != 0)
+		{
+			libpq_append_conn_error(conn,
+									"server's discovery document at %s (issuer \"%s\") is incompatible with oauth_issuer (%s)",
+									ctx.discovery_uri, discovery_issuer,
+									conn->oauth_issuer_id);
+
+			free(discovery_issuer);
+			goto cleanup;
+		}
+
+		free(discovery_issuer);
+
+		if (!conn->oauth_discovery_uri)
+		{
+			conn->oauth_discovery_uri = ctx.discovery_uri;
+			ctx.discovery_uri = NULL;
+		}
+		else
+		{
+			/* This must match the URI we'd previously determined. */
+			if (strcmp(conn->oauth_discovery_uri, ctx.discovery_uri) != 0)
+			{
+				libpq_append_conn_error(conn,
+										"server's discovery document has moved to %s (previous location was %s)",
+										ctx.discovery_uri,
+										conn->oauth_discovery_uri);
+				goto cleanup;
+			}
+		}
+	}
+
+	if (ctx.scope)
+	{
+		/* Servers may not override a previously set oauth_scope. */
+		if (!conn->oauth_scope)
+		{
+			conn->oauth_scope = ctx.scope;
+			ctx.scope = NULL;
+		}
+	}
+
+	if (!ctx.status)
+	{
+		libpq_append_conn_error(conn,
+								"server sent error response without a status");
+		goto cleanup;
+	}
+
+	if (strcmp(ctx.status, "invalid_token") != 0)
+	{
+		/*
+		 * invalid_token is the only error code we'll automatically retry for;
+		 * otherwise, just bail out now.
+		 */
+		libpq_append_conn_error(conn,
+								"server rejected OAuth bearer token: %s",
+								ctx.status);
+		goto cleanup;
+	}
+
+	success = true;
+
+cleanup:
+	free(ctx.status);
+	free(ctx.scope);
+	free(ctx.discovery_uri);
+
+	return success;
+}
+
+/*
+ * Callback implementation of conn->async_auth() for a user-defined OAuth flow.
+ * Delegates the retrieval of the token to the application's 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_*
+ * statuses for use by PQconnectPoll().
+ */
+static PostgresPollingStatusType
+run_user_oauth_flow(PGconn *conn)
+{
+	fe_oauth_state *state = conn->sasl_state;
+	PGoauthBearerRequest *request = state->async_ctx;
+	PostgresPollingStatusType status;
+
+	if (!request->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);
+	if (status == PGRES_POLLING_FAILED)
+	{
+		libpq_append_conn_error(conn, "user-defined OAuth flow failed");
+		return status;
+	}
+	else if (status == PGRES_POLLING_OK)
+	{
+		/*
+		 * 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.)
+		 */
+		if (!request->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);
+		if (!conn->oauth_token)
+		{
+			libpq_append_conn_error(conn, "out of memory");
+			return PGRES_POLLING_FAILED;
+		}
+
+		return PGRES_POLLING_OK;
+	}
+
+	/* The hook wants the client to poll the altsock. Make sure it set one. */
+	if (conn->altsock == PGINVALID_SOCKET)
+	{
+		libpq_append_conn_error(conn,
+								"user-defined OAuth flow did not provide a socket for polling");
+		return PGRES_POLLING_FAILED;
+	}
+
+	return status;
+}
+
+/*
+ * Cleanup callback for the async user flow. Delegates most of its job to the
+ * user-provided cleanup implementation, then disconnects the altsock.
+ */
+static void
+cleanup_user_oauth_flow(PGconn *conn)
+{
+	fe_oauth_state *state = conn->sasl_state;
+	PGoauthBearerRequest *request = state->async_ctx;
+
+	Assert(request);
+
+	if (request->cleanup)
+		request->cleanup(conn, request);
+	conn->altsock = PGINVALID_SOCKET;
+
+	free(request);
+	state->async_ctx = NULL;
+}
+
+/*
+ * Chooses an OAuth client flow for the connection, which will retrieve a Bearer
+ * 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
+ * asynchronous callbacks which will be managed by run_user_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.)
+ *
+ * If neither a custom handler nor the builtin flow is available, the connection
+ * fails here.
+ */
+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,
+	};
+
+	Assert(request.openid_configuration);
+
+	/* The client may have overridden the OAuth flow. */
+	res = PQauthDataHook(PQAUTHDATA_OAUTH_BEARER_TOKEN, conn, &request);
+	if (res > 0)
+	{
+		PGoauthBearerRequest *request_copy;
+
+		if (request.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);
+			if (!conn->oauth_token)
+			{
+				libpq_append_conn_error(conn, "out of memory");
+				goto fail;
+			}
+
+			/* short-circuit */
+			if (request.cleanup)
+				request.cleanup(conn, &request);
+			return true;
+		}
+
+		request_copy = malloc(sizeof(*request_copy));
+		if (!request_copy)
+		{
+			libpq_append_conn_error(conn, "out of memory");
+			goto fail;
+		}
+
+		memcpy(request_copy, &request, sizeof(request));
+
+		conn->async_auth = run_user_oauth_flow;
+		conn->cleanup_async_auth = cleanup_user_oauth_flow;
+		state->async_ctx = request_copy;
+	}
+	else if (res < 0)
+	{
+		libpq_append_conn_error(conn, "user-defined OAuth flow failed");
+		goto fail;
+	}
+	else
+	{
+#if USE_LIBCURL
+		/* Hand off to our built-in OAuth flow. */
+		conn->async_auth = pg_fe_run_oauth_flow;
+		conn->cleanup_async_auth = pg_fe_cleanup_oauth_flow;
+
+#else
+		libpq_append_conn_error(conn, "no custom OAuth flows are available, and libpq was not built with libcurl support");
+		goto fail;
+
+#endif
+	}
+
+	return true;
+
+fail:
+	if (request.cleanup)
+		request.cleanup(conn, &request);
+	return false;
+}
+
+/*
+ * Fill in our issuer identifier and discovery URI, if possible, using the
+ * connection parameters. If conn->oauth_discovery_uri can't be populated in
+ * this function, it will be requested from the server.
+ */
+static bool
+setup_oauth_parameters(PGconn *conn)
+{
+	/*---
+	 * To talk to a server, we require the user to provide issuer and client
+	 * identifiers.
+	 *
+	 * While it's possible for an OAuth client to support multiple issuers, it
+	 * requires additional effort to make sure the flows in use are safe -- to
+	 * quote RFC 9207,
+	 *
+	 *     OAuth clients that interact with only one authorization server are
+	 *     not vulnerable to mix-up attacks. However, when such clients decide
+	 *     to add support for a second authorization server in the future, they
+	 *     become vulnerable and need to apply countermeasures to mix-up
+	 *     attacks.
+	 *
+	 * For now, we allow only one.
+	 */
+	if (!conn->oauth_issuer || !conn->oauth_client_id)
+	{
+		libpq_append_conn_error(conn,
+								"server requires OAuth authentication, but oauth_issuer and oauth_client_id are not both set");
+		return false;
+	}
+
+	/*
+	 * oauth_issuer is interpreted differently if it's a well-known discovery
+	 * URI rather than just an issuer identifier.
+	 */
+	if (strstr(conn->oauth_issuer, WK_PREFIX) != NULL)
+	{
+		/*
+		 * Convert the URI back to an issuer identifier. (This also performs
+		 * validation of the URI format.)
+		 */
+		conn->oauth_issuer_id = issuer_from_well_known_uri(conn,
+														   conn->oauth_issuer);
+		if (!conn->oauth_issuer_id)
+			return false;		/* error message already set */
+
+		conn->oauth_discovery_uri = strdup(conn->oauth_issuer);
+		if (!conn->oauth_discovery_uri)
+		{
+			libpq_append_conn_error(conn, "out of memory");
+			return false;
+		}
+	}
+	else
+	{
+		/*
+		 * Treat oauth_issuer as an issuer identifier. We'll ask the server
+		 * for the discovery URI.
+		 */
+		conn->oauth_issuer_id = strdup(conn->oauth_issuer);
+		if (!conn->oauth_issuer_id)
+		{
+			libpq_append_conn_error(conn, "out of memory");
+			return false;
+		}
+	}
+
+	return true;
+}
+
+/*
+ * Implements the OAUTHBEARER SASL exchange (RFC 7628, Sec. 3.2).
+ *
+ * If the necessary OAuth parameters are set up on the connection, this will run
+ * the client flow asynchronously and present the resulting token to the server.
+ * Otherwise, an empty discovery response will be sent and any parameters sent
+ * back by the server will be stored for a second attempt.
+ *
+ * For a full description of the API, see libpq/sasl.h.
+ */
+static SASLStatus
+oauth_exchange(void *opaq, bool final,
+			   char *input, int inputlen,
+			   char **output, int *outputlen)
+{
+	fe_oauth_state *state = opaq;
+	PGconn	   *conn = state->conn;
+	bool		discover = false;
+
+	*output = NULL;
+	*outputlen = 0;
+
+	switch (state->step)
+	{
+		case FE_OAUTH_INIT:
+			/* We begin in the initial response phase. */
+			Assert(inputlen == -1);
+
+			if (!setup_oauth_parameters(conn))
+				return SASL_FAILED;
+
+			if (conn->oauth_token)
+			{
+				/*
+				 * A previous connection already fetched the token; we'll use
+				 * it below.
+				 */
+			}
+			else if (conn->oauth_discovery_uri)
+			{
+				/*
+				 * We don't have a token, but we have a discovery URI already
+				 * stored. Decide whether we're using a user-provided OAuth
+				 * flow or the one we have built in.
+				 */
+				if (!setup_token_request(conn, state))
+					return SASL_FAILED;
+
+				if (conn->oauth_token)
+				{
+					/*
+					 * A really smart user implementation may have already
+					 * given us the token (e.g. if there was an unexpired copy
+					 * already cached), and we can use it immediately.
+					 */
+				}
+				else
+				{
+					/*
+					 * Otherwise, we'll have to hand the connection over to
+					 * our OAuth implementation.
+					 *
+					 * This could take a while, since it generally involves a
+					 * user in the loop. To avoid consuming the server's
+					 * authentication timeout, we'll continue this handshake
+					 * to the end, so that the server can close its side of
+					 * the connection. We'll open a second connection later
+					 * once we've retrieved a token.
+					 */
+					discover = true;
+				}
+			}
+			else
+			{
+				/*
+				 * If we don't have a token, and we don't have a discovery URI
+				 * to be able to request a token, we ask the server for one
+				 * explicitly.
+				 */
+				discover = true;
+			}
+
+			/*
+			 * Generate an initial response. This either contains a token, if
+			 * we have one, or an empty discovery response which is doomed to
+			 * fail.
+			 */
+			*output = client_initial_response(conn, discover);
+			if (!*output)
+				return SASL_FAILED;
+
+			*outputlen = strlen(*output);
+			state->step = FE_OAUTH_BEARER_SENT;
+
+			if (conn->oauth_token)
+			{
+				/*
+				 * For the purposes of require_auth, our side of
+				 * authentication is done at this point; the server will
+				 * either accept the connection or send an error. Unlike
+				 * SCRAM, there is no additional server data to check upon
+				 * success.
+				 */
+				conn->client_finished_auth = true;
+			}
+
+			return SASL_CONTINUE;
+
+		case FE_OAUTH_BEARER_SENT:
+			if (final)
+			{
+				/*
+				 * OAUTHBEARER does not make use of additional data with a
+				 * successful SASL exchange, so we shouldn't get an
+				 * AuthenticationSASLFinal message.
+				 */
+				libpq_append_conn_error(conn,
+										"server sent unexpected additional OAuth data");
+				return SASL_FAILED;
+			}
+
+			/*
+			 * An error message was sent by the server. Respond with the
+			 * required dummy message (RFC 7628, sec. 3.2.3).
+			 */
+			*output = strdup(kvsep);
+			if (unlikely(!*output))
+			{
+				libpq_append_conn_error(conn, "out of memory");
+				return SASL_FAILED;
+			}
+			*outputlen = strlen(*output);	/* == 1 */
+
+			/* Grab the settings from discovery. */
+			if (!handle_oauth_sasl_error(conn, input, inputlen))
+				return SASL_FAILED;
+
+			if (conn->oauth_token)
+			{
+				/*
+				 * The server rejected our token. Continue onwards towards the
+				 * expected FATAL message, but mark our state to catch any
+				 * unexpected "success" from the server.
+				 */
+				state->step = FE_OAUTH_SERVER_ERROR;
+				return SASL_CONTINUE;
+			}
+
+			if (!conn->async_auth)
+			{
+				/*
+				 * No OAuth flow is set up yet. Did we get enough information
+				 * from the server to create one?
+				 */
+				if (!conn->oauth_discovery_uri)
+				{
+					libpq_append_conn_error(conn,
+											"server requires OAuth authentication, but no discovery metadata was provided");
+					return SASL_FAILED;
+				}
+
+				/* Yes. Set up the flow now. */
+				if (!setup_token_request(conn, state))
+					return SASL_FAILED;
+
+				if (conn->oauth_token)
+				{
+					/*
+					 * A token was available in a custom flow's cache. Skip
+					 * the asynchronous processing.
+					 */
+					goto reconnect;
+				}
+			}
+
+			/*
+			 * Time to retrieve a token. This involves a number of HTTP
+			 * connections and timed waits, so we escape the synchronous auth
+			 * processing and tell PQconnectPoll to transfer control to our
+			 * async implementation.
+			 */
+			Assert(conn->async_auth);	/* should have been set already */
+			state->step = FE_OAUTH_REQUESTING_TOKEN;
+			return SASL_ASYNC;
+
+		case FE_OAUTH_REQUESTING_TOKEN:
+
+			/*
+			 * We've returned successfully from token retrieval. Double-check
+			 * that we have what we need for the next connection.
+			 */
+			if (!conn->oauth_token)
+			{
+				Assert(false);	/* should have failed before this point! */
+				libpq_append_conn_error(conn,
+										"internal error: OAuth flow did not set a token");
+				return SASL_FAILED;
+			}
+
+			goto reconnect;
+
+		case FE_OAUTH_SERVER_ERROR:
+
+			/*
+			 * After an error, the server should send an error response to
+			 * fail the SASL handshake, which is handled in higher layers.
+			 *
+			 * If we get here, the server either sent *another* challenge
+			 * which isn't defined in the RFC, or completed the handshake
+			 * successfully after telling us it was going to fail. Neither is
+			 * acceptable.
+			 */
+			libpq_append_conn_error(conn,
+									"server sent additional OAuth data after error");
+			return SASL_FAILED;
+
+		default:
+			libpq_append_conn_error(conn, "invalid OAuth exchange state");
+			break;
+	}
+
+	Assert(false);				/* should never get here */
+	return SASL_FAILED;
+
+reconnect:
+
+	/*
+	 * Despite being a failure from the point of view of SASL, we have enough
+	 * information to restart with a new connection.
+	 */
+	libpq_append_conn_error(conn, "retrying connection with new bearer token");
+	conn->oauth_want_retry = true;
+	return SASL_FAILED;
+}
+
+static bool
+oauth_channel_bound(void *opaq)
+{
+	/* This mechanism does not support channel binding. */
+	return false;
+}
+
+/*
+ * Fully clears out any stored OAuth token. This is done proactively upon
+ * successful connection as well as during pqClosePGconn().
+ */
+void
+pqClearOAuthToken(PGconn *conn)
+{
+	if (!conn->oauth_token)
+		return;
+
+	explicit_bzero(conn->oauth_token, strlen(conn->oauth_token));
+	free(conn->oauth_token);
+	conn->oauth_token = NULL;
+}
+
+/*
+ * Returns true if the PGOAUTHDEBUG=UNSAFE flag is set in the environment.
+ */
+bool
+oauth_unsafe_debugging_enabled(void)
+{
+	const char *env = getenv("PGOAUTHDEBUG");
+
+	return (env && strcmp(env, "UNSAFE") == 0);
+}
diff --git a/src/interfaces/libpq/fe-auth-oauth.h b/src/interfaces/libpq/fe-auth-oauth.h
new file mode 100644
index 00000000000..32598721686
--- /dev/null
+++ b/src/interfaces/libpq/fe-auth-oauth.h
@@ -0,0 +1,45 @@
+/*-------------------------------------------------------------------------
+ *
+ * fe-auth-oauth.h
+ *
+ *	  Definitions for OAuth authentication implementations
+ *
+ * Portions Copyright (c) 2024, PostgreSQL Global Development Group
+ *
+ * src/interfaces/libpq/fe-auth-oauth.h
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#ifndef FE_AUTH_OAUTH_H
+#define FE_AUTH_OAUTH_H
+
+#include "libpq-fe.h"
+#include "libpq-int.h"
+
+
+enum fe_oauth_step
+{
+	FE_OAUTH_INIT,
+	FE_OAUTH_BEARER_SENT,
+	FE_OAUTH_REQUESTING_TOKEN,
+	FE_OAUTH_SERVER_ERROR,
+};
+
+typedef struct
+{
+	enum fe_oauth_step step;
+
+	PGconn	   *conn;
+	void	   *async_ctx;
+} fe_oauth_state;
+
+extern PostgresPollingStatusType pg_fe_run_oauth_flow(PGconn *conn);
+extern void pg_fe_cleanup_oauth_flow(PGconn *conn);
+extern void pqClearOAuthToken(PGconn *conn);
+extern bool oauth_unsafe_debugging_enabled(void);
+
+/* Mechanisms in fe-auth-oauth.c */
+extern const pg_fe_sasl_mech pg_oauth_mech;
+
+#endif							/* FE_AUTH_OAUTH_H */
diff --git a/src/interfaces/libpq/fe-auth.c b/src/interfaces/libpq/fe-auth.c
index 761ee8f88f7..ec7a9236044 100644
--- a/src/interfaces/libpq/fe-auth.c
+++ b/src/interfaces/libpq/fe-auth.c
@@ -40,9 +40,11 @@
 #endif
 
 #include "common/md5.h"
+#include "common/oauth-common.h"
 #include "common/scram-common.h"
 #include "fe-auth.h"
 #include "fe-auth-sasl.h"
+#include "fe-auth-oauth.h"
 #include "libpq-fe.h"
 
 #ifdef ENABLE_GSS
@@ -535,6 +537,13 @@ pg_SASL_init(PGconn *conn, int payloadlen, bool *async)
 			conn->sasl = &pg_scram_mech;
 			conn->password_needed = true;
 		}
+		else if (strcmp(mechanism_buf.data, OAUTHBEARER_NAME) == 0 &&
+				 !selected_mechanism)
+		{
+			selected_mechanism = OAUTHBEARER_NAME;
+			conn->sasl = &pg_oauth_mech;
+			conn->password_needed = false;
+		}
 	}
 
 	if (!selected_mechanism)
@@ -559,13 +568,6 @@ pg_SASL_init(PGconn *conn, int payloadlen, bool *async)
 
 		if (!allowed)
 		{
-			/*
-			 * TODO: this is dead code until a second SASL mechanism is added;
-			 * the connection can't have proceeded past check_expected_areq()
-			 * if no SASL methods are allowed.
-			 */
-			Assert(false);
-
 			libpq_append_conn_error(conn, "authentication method requirement \"%s\" failed: server requested %s authentication",
 									conn->require_auth, selected_mechanism);
 			goto error;
@@ -1580,3 +1582,23 @@ PQchangePassword(PGconn *conn, const char *user, const char *passwd)
 		}
 	}
 }
+
+PQauthDataHook_type PQauthDataHook = PQdefaultAuthDataHook;
+
+PQauthDataHook_type
+PQgetAuthDataHook(void)
+{
+	return PQauthDataHook;
+}
+
+void
+PQsetAuthDataHook(PQauthDataHook_type hook)
+{
+	PQauthDataHook = hook ? hook : PQdefaultAuthDataHook;
+}
+
+int
+PQdefaultAuthDataHook(PGauthData type, PGconn *conn, void *data)
+{
+	return 0;					/* handle nothing */
+}
diff --git a/src/interfaces/libpq/fe-auth.h b/src/interfaces/libpq/fe-auth.h
index 1d4991f8996..de98e0d20c4 100644
--- a/src/interfaces/libpq/fe-auth.h
+++ b/src/interfaces/libpq/fe-auth.h
@@ -18,6 +18,9 @@
 #include "libpq-int.h"
 
 
+extern PQauthDataHook_type PQauthDataHook;
+
+
 /* Prototypes for functions in fe-auth.c */
 extern int	pg_fe_sendauth(AuthRequest areq, int payloadlen, PGconn *conn,
 						   bool *async);
diff --git a/src/interfaces/libpq/fe-connect.c b/src/interfaces/libpq/fe-connect.c
index 85ebf9f6d87..b0669a2dbfd 100644
--- a/src/interfaces/libpq/fe-connect.c
+++ b/src/interfaces/libpq/fe-connect.c
@@ -28,6 +28,7 @@
 #include "common/scram-common.h"
 #include "common/string.h"
 #include "fe-auth.h"
+#include "fe-auth-oauth.h"
 #include "libpq-fe.h"
 #include "libpq-int.h"
 #include "mb/pg_wchar.h"
@@ -373,6 +374,23 @@ static const internalPQconninfoOption PQconninfoOptions[] = {
 	{"scram_server_key", NULL, NULL, NULL, "SCRAM-Server-Key", "D", SCRAM_MAX_KEY_LEN * 2,
 	offsetof(struct pg_conn, scram_server_key)},
 
+	/* OAuth v2 */
+	{"oauth_issuer", NULL, NULL, NULL,
+		"OAuth-Issuer", "", 40,
+	offsetof(struct pg_conn, oauth_issuer)},
+
+	{"oauth_client_id", NULL, NULL, NULL,
+		"OAuth-Client-ID", "", 40,
+	offsetof(struct pg_conn, oauth_client_id)},
+
+	{"oauth_client_secret", NULL, NULL, NULL,
+		"OAuth-Client-Secret", "", 40,
+	offsetof(struct pg_conn, oauth_client_secret)},
+
+	{"oauth_scope", NULL, NULL, NULL,
+		"OAuth-Scope", "", 15,
+	offsetof(struct pg_conn, oauth_scope)},
+
 	/* Terminating entry --- MUST BE LAST */
 	{NULL, NULL, NULL, NULL,
 	NULL, NULL, 0}
@@ -649,6 +667,7 @@ pqDropServerData(PGconn *conn)
 	conn->write_failed = false;
 	free(conn->write_err_msg);
 	conn->write_err_msg = NULL;
+	conn->oauth_want_retry = false;
 
 	/*
 	 * Cancel connections need to retain their be_pid and be_key across
@@ -1138,7 +1157,7 @@ static inline void
 fill_allowed_sasl_mechs(PGconn *conn)
 {
 	/*---
-	 * We only support one mechanism at the moment, so rather than deal with a
+	 * We only support two mechanisms at the moment, so rather than deal with a
 	 * linked list, conn->allowed_sasl_mechs is an array of static length. We
 	 * rely on the compile-time assertion here to keep us honest.
 	 *
@@ -1148,10 +1167,11 @@ fill_allowed_sasl_mechs(PGconn *conn)
 	 * - handle the new mechanism name in the require_auth portion of
 	 *   pqConnectOptions2(), below.
 	 */
-	StaticAssertDecl(lengthof(conn->allowed_sasl_mechs) == 1,
+	StaticAssertDecl(lengthof(conn->allowed_sasl_mechs) == 2,
 					 "fill_allowed_sasl_mechs() must be updated when resizing conn->allowed_sasl_mechs[]");
 
 	conn->allowed_sasl_mechs[0] = &pg_scram_mech;
+	conn->allowed_sasl_mechs[1] = &pg_oauth_mech;
 }
 
 /*
@@ -1513,6 +1533,10 @@ pqConnectOptions2(PGconn *conn)
 			{
 				mech = &pg_scram_mech;
 			}
+			else if (strcmp(method, "oauth") == 0)
+			{
+				mech = &pg_oauth_mech;
+			}
 
 			/*
 			 * Final group: meta-options.
@@ -4105,7 +4129,19 @@ keep_going:						/* We will come back to here until there is
 				conn->inStart = conn->inCursor;
 
 				if (res != STATUS_OK)
+				{
+					/*
+					 * OAuth connections may perform two-step discovery, where
+					 * the first connection is a dummy.
+					 */
+					if (conn->sasl == &pg_oauth_mech && conn->oauth_want_retry)
+					{
+						need_new_connection = true;
+						goto keep_going;
+					}
+
 					goto error_return;
+				}
 
 				/*
 				 * Just make sure that any data sent by pg_fe_sendauth is
@@ -4384,6 +4420,9 @@ keep_going:						/* We will come back to here until there is
 					}
 				}
 
+				/* Don't hold onto any OAuth tokens longer than necessary. */
+				pqClearOAuthToken(conn);
+
 				/*
 				 * For non cancel requests we can release the address list
 				 * now. For cancel requests we never actually resolve
@@ -4996,6 +5035,12 @@ freePGconn(PGconn *conn)
 	free(conn->load_balance_hosts);
 	free(conn->scram_client_key);
 	free(conn->scram_server_key);
+	free(conn->oauth_issuer);
+	free(conn->oauth_issuer_id);
+	free(conn->oauth_discovery_uri);
+	free(conn->oauth_client_id);
+	free(conn->oauth_client_secret);
+	free(conn->oauth_scope);
 	termPQExpBuffer(&conn->errorMessage);
 	termPQExpBuffer(&conn->workBuffer);
 
@@ -5149,6 +5194,7 @@ pqClosePGconn(PGconn *conn)
 	conn->asyncStatus = PGASYNC_IDLE;
 	conn->xactStatus = PQTRANS_IDLE;
 	conn->pipelineStatus = PQ_PIPELINE_OFF;
+	pqClearOAuthToken(conn);
 	pqClearAsyncResult(conn);	/* deallocate result */
 	pqClearConnErrorState(conn);
 
diff --git a/src/interfaces/libpq/libpq-fe.h b/src/interfaces/libpq/libpq-fe.h
index a3491faf0c3..5f8d608261e 100644
--- a/src/interfaces/libpq/libpq-fe.h
+++ b/src/interfaces/libpq/libpq-fe.h
@@ -59,6 +59,8 @@ extern "C"
 /* Features added in PostgreSQL v18: */
 /* Indicates presence of PQfullProtocolVersion */
 #define LIBPQ_HAS_FULL_PROTOCOL_VERSION 1
+/* Indicates presence of the PQAUTHDATA_PROMPT_OAUTH_DEVICE authdata hook */
+#define LIBPQ_HAS_PROMPT_OAUTH_DEVICE 1
 
 /*
  * Option flags for PQcopyResult
@@ -186,6 +188,13 @@ typedef enum
 	PQ_PIPELINE_ABORTED
 } PGpipelineStatus;
 
+typedef enum
+{
+	PQAUTHDATA_PROMPT_OAUTH_DEVICE, /* user must visit a device-authorization
+									 * URL */
+	PQAUTHDATA_OAUTH_BEARER_TOKEN,	/* server requests an OAuth Bearer token */
+} PGauthData;
+
 /* PGconn encapsulates a connection to the backend.
  * The contents of this struct are not supposed to be known to applications.
  */
@@ -720,10 +729,83 @@ extern int	PQenv2encoding(void);
 
 /* === in fe-auth.c === */
 
+typedef struct _PGpromptOAuthDevice
+{
+	const char *verification_uri;	/* verification URI to visit */
+	const char *user_code;		/* user code to enter */
+} PGpromptOAuthDevice;
+
+/* for PGoauthBearerRequest.async() */
+#ifdef _WIN32
+#define SOCKTYPE uintptr_t		/* avoids depending on winsock2.h for SOCKET */
+#else
+#define SOCKTYPE int
+#endif
+
+typedef struct _PGoauthBearerRequest
+{
+	/* Hook inputs (constant across all calls) */
+	const char *const openid_configuration; /* OIDC discovery URI */
+	const char *const scope;	/* required scope(s), or NULL */
+
+	/* Hook outputs */
+
+	/*---------
+	 * Callback implementing a custom asynchronous OAuth flow.
+	 *
+	 * The callback may return
+	 * - PGRES_POLLING_READING/WRITING, to indicate that a socket descriptor
+	 *   has been stored in *altsock and libpq should wait until it is
+	 *   readable or writable before calling back;
+	 * - PGRES_POLLING_OK, to indicate that the flow is complete and
+	 *   request->token has been set; or
+	 * - PGRES_POLLING_FAILED, to indicate that token retrieval has failed.
+	 *
+	 * This callback is optional. If the token can be obtained without
+	 * 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.
+	 */
+	PostgresPollingStatusType (*async) (PGconn *conn,
+										struct _PGoauthBearerRequest *request,
+										SOCKTYPE * altsock);
+
+	/*
+	 * Callback to clean up custom allocations. A hook implementation may use
+	 * this to free request->token and any resources in request->user.
+	 *
+	 * This is technically optional, but highly recommended, because there is
+	 * no other indication as to when it is safe to free the token.
+	 */
+	void		(*cleanup) (PGconn *conn, struct _PGoauthBearerRequest *request);
+
+	/*
+	 * The hook should set this to the Bearer token contents for the
+	 * connection, once the flow is completed.  The token contents must remain
+	 * available to libpq until the hook's cleanup callback is called.
+	 */
+	char	   *token;
+
+	/*
+	 * Hook-defined data. libpq will not modify this pointer across calls to
+	 * the async callback, so it can be used to keep track of
+	 * application-specific state. Resources allocated here should be freed by
+	 * the cleanup callback.
+	 */
+	void	   *user;
+} PGoauthBearerRequest;
+
+#undef SOCKTYPE
+
 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);
 
+typedef int (*PQauthDataHook_type) (PGauthData type, PGconn *conn, void *data);
+extern void PQsetAuthDataHook(PQauthDataHook_type hook);
+extern PQauthDataHook_type PQgetAuthDataHook(void);
+extern int	PQdefaultAuthDataHook(PGauthData type, PGconn *conn, void *data);
+
 /* === in encnames.c === */
 
 extern int	pg_char_to_encoding(const char *name);
diff --git a/src/interfaces/libpq/libpq-int.h b/src/interfaces/libpq/libpq-int.h
index 2546f9f8a50..f36f7f19d58 100644
--- a/src/interfaces/libpq/libpq-int.h
+++ b/src/interfaces/libpq/libpq-int.h
@@ -437,6 +437,17 @@ struct pg_conn
 								 * cancel request, instead of being a normal
 								 * connection that's used for queries */
 
+	/* OAuth v2 */
+	char	   *oauth_issuer;	/* token issuer/URL */
+	char	   *oauth_issuer_id;	/* token issuer identifier */
+	char	   *oauth_discovery_uri;	/* URI of the issuer's discovery
+										 * document */
+	char	   *oauth_client_id;	/* client identifier */
+	char	   *oauth_client_secret;	/* client secret */
+	char	   *oauth_scope;	/* access token scope */
+	char	   *oauth_token;	/* access token */
+	bool		oauth_want_retry;	/* should we retry on failure? */
+
 	/* Optional file to write trace info to */
 	FILE	   *Pfdebug;
 	int			traceFlags;
@@ -505,7 +516,7 @@ struct pg_conn
 								 * the server? */
 	uint32		allowed_auth_methods;	/* bitmask of acceptable AuthRequest
 										 * codes */
-	const pg_fe_sasl_mech *allowed_sasl_mechs[1];	/* and acceptable SASL
+	const pg_fe_sasl_mech *allowed_sasl_mechs[2];	/* and acceptable SASL
 													 * mechanisms */
 	bool		client_finished_auth;	/* have we finished our half of the
 										 * authentication exchange? */
diff --git a/src/interfaces/libpq/meson.build b/src/interfaces/libpq/meson.build
index 1a5a223e1af..4180e35f8cf 100644
--- a/src/interfaces/libpq/meson.build
+++ b/src/interfaces/libpq/meson.build
@@ -4,6 +4,7 @@
 # args for executables (which depend on libpq).
 
 libpq_sources = files(
+  'fe-auth-oauth.c',
   'fe-auth-scram.c',
   'fe-auth.c',
   'fe-cancel.c',
@@ -40,6 +41,10 @@ if gssapi.found()
   )
 endif
 
+if libcurl.found()
+  libpq_sources += files('fe-auth-oauth-curl.c')
+endif
+
 export_file = custom_target('libpq.exports',
   kwargs: gen_export_kwargs,
 )
diff --git a/src/makefiles/meson.build b/src/makefiles/meson.build
index d49b2079a44..60e13d50235 100644
--- a/src/makefiles/meson.build
+++ b/src/makefiles/meson.build
@@ -229,6 +229,7 @@ pgxs_deps = {
   'gssapi': gssapi,
   'icu': icu,
   'ldap': ldap,
+  'libcurl': libcurl,
   'libxml': libxml,
   'libxslt': libxslt,
   'llvm': llvm,
diff --git a/src/test/authentication/t/001_password.pl b/src/test/authentication/t/001_password.pl
index 1357f806b6f..4ce22ccbdf2 100644
--- a/src/test/authentication/t/001_password.pl
+++ b/src/test/authentication/t/001_password.pl
@@ -404,11 +404,11 @@ $node->connect_fails(
 $node->connect_fails(
 	"user=scram_role require_auth=!scram-sha-256",
 	"SCRAM authentication forbidden, fails with SCRAM auth",
-	expected_stderr => qr/server requested SASL authentication/);
+	expected_stderr => qr/server requested SCRAM-SHA-256 authentication/);
 $node->connect_fails(
 	"user=scram_role require_auth=!password,!md5,!scram-sha-256",
 	"multiple authentication types forbidden, fails with SCRAM auth",
-	expected_stderr => qr/server requested SASL authentication/);
+	expected_stderr => qr/server requested SCRAM-SHA-256 authentication/);
 
 # Test that bad passwords are rejected.
 $ENV{"PGPASSWORD"} = 'badpass';
@@ -465,13 +465,13 @@ $node->connect_fails(
 	"user=scram_role require_auth=!scram-sha-256",
 	"password authentication forbidden, fails with SCRAM auth",
 	expected_stderr =>
-	  qr/authentication method requirement "!scram-sha-256" failed: server requested SASL authentication/
+	  qr/authentication method requirement "!scram-sha-256" failed: server requested SCRAM-SHA-256 authentication/
 );
 $node->connect_fails(
 	"user=scram_role require_auth=!password,!md5,!scram-sha-256",
 	"multiple authentication types forbidden, fails with SCRAM auth",
 	expected_stderr =>
-	  qr/authentication method requirement "!password,!md5,!scram-sha-256" failed: server requested SASL authentication/
+	  qr/authentication method requirement "!password,!md5,!scram-sha-256" failed: server requested SCRAM-SHA-256 authentication/
 );
 
 # Test SYSTEM_USER <> NULL with parallel workers.
diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile
index c0d3cf0e14b..bdfd5f1f8de 100644
--- a/src/test/modules/Makefile
+++ b/src/test/modules/Makefile
@@ -11,6 +11,7 @@ SUBDIRS = \
 		  dummy_index_am \
 		  dummy_seclabel \
 		  libpq_pipeline \
+		  oauth_validator \
 		  plsample \
 		  spgist_name_ops \
 		  test_bloomfilter \
diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build
index 4f544a042d4..0c2ccc75a63 100644
--- a/src/test/modules/meson.build
+++ b/src/test/modules/meson.build
@@ -9,6 +9,7 @@ subdir('gin')
 subdir('injection_points')
 subdir('ldap_password_func')
 subdir('libpq_pipeline')
+subdir('oauth_validator')
 subdir('plsample')
 subdir('spgist_name_ops')
 subdir('ssl_passphrase_callback')
diff --git a/src/test/modules/oauth_validator/.gitignore b/src/test/modules/oauth_validator/.gitignore
new file mode 100644
index 00000000000..5dcb3ff9723
--- /dev/null
+++ b/src/test/modules/oauth_validator/.gitignore
@@ -0,0 +1,4 @@
+# Generated subdirectories
+/log/
+/results/
+/tmp_check/
diff --git a/src/test/modules/oauth_validator/Makefile b/src/test/modules/oauth_validator/Makefile
new file mode 100644
index 00000000000..f297ed5c968
--- /dev/null
+++ b/src/test/modules/oauth_validator/Makefile
@@ -0,0 +1,40 @@
+#-------------------------------------------------------------------------
+#
+# Makefile for src/test/modules/oauth_validator
+#
+# Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/test/modules/oauth_validator/Makefile
+#
+#-------------------------------------------------------------------------
+
+MODULES = validator fail_validator
+PGFILEDESC = "validator - test OAuth validator module"
+
+PROGRAM = oauth_hook_client
+PGAPPICON = win32
+OBJS = $(WIN32RES) oauth_hook_client.o
+
+PG_CPPFLAGS = -I$(libpq_srcdir)
+PG_LIBS_INTERNAL += $(libpq_pgport)
+
+NO_INSTALLCHECK = 1
+
+TAP_TESTS = 1
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = src/test/modules/oauth_validator
+top_builddir = ../../../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+
+export PYTHON
+export with_libcurl
+export with_python
+
+endif
diff --git a/src/test/modules/oauth_validator/README b/src/test/modules/oauth_validator/README
new file mode 100644
index 00000000000..138a8104622
--- /dev/null
+++ b/src/test/modules/oauth_validator/README
@@ -0,0 +1,13 @@
+Test programs and libraries for OAuth
+-------------------------------------
+
+This folder contains tests for the client- and server-side OAuth
+implementations. Most tests are run end-to-end to test both simultaneously. The
+tests in t/001_server use a mock OAuth authorization server, implemented jointly
+by t/OAuth/Server.pm and t/oauth_server.py, to run the libpq Device
+Authorization flow. The tests in t/002_client exercise custom OAuth flows and
+don't need an authorization server.
+
+Tests in this folder generally require 'oauth' to be present in PG_TEST_EXTRA,
+since localhost HTTP servers will be started. A Python installation is required
+to run the mock authorization server.
diff --git a/src/test/modules/oauth_validator/fail_validator.c b/src/test/modules/oauth_validator/fail_validator.c
new file mode 100644
index 00000000000..f77a3e115c6
--- /dev/null
+++ b/src/test/modules/oauth_validator/fail_validator.c
@@ -0,0 +1,42 @@
+/*-------------------------------------------------------------------------
+ *
+ * fail_validator.c
+ *	  Test module for serverside OAuth token validation callbacks, which always
+ *	  fails
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/test/modules/oauth_validator/fail_validator.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "fmgr.h"
+#include "libpq/oauth.h"
+
+PG_MODULE_MAGIC;
+
+static ValidatorModuleResult *fail_token(ValidatorModuleState *state,
+										 const char *token,
+										 const char *role);
+
+/* Callback implementations (we only need the main one) */
+static const OAuthValidatorCallbacks validator_callbacks = {
+	.validate_cb = fail_token,
+};
+
+const OAuthValidatorCallbacks *
+_PG_oauth_validator_module_init(void)
+{
+	return &validator_callbacks;
+}
+
+static ValidatorModuleResult *
+fail_token(ValidatorModuleState *state, const char *token, const char *role)
+{
+	elog(FATAL, "fail_validator: sentinel error");
+	pg_unreachable();
+}
diff --git a/src/test/modules/oauth_validator/meson.build b/src/test/modules/oauth_validator/meson.build
new file mode 100644
index 00000000000..4b78c90557c
--- /dev/null
+++ b/src/test/modules/oauth_validator/meson.build
@@ -0,0 +1,69 @@
+# Copyright (c) 2024, PostgreSQL Global Development Group
+
+validator_sources = files(
+  'validator.c',
+)
+
+if host_system == 'windows'
+  validator_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'validator',
+    '--FILEDESC', 'validator - test OAuth validator module',])
+endif
+
+validator = shared_module('validator',
+  validator_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += validator
+
+fail_validator_sources = files(
+  'fail_validator.c',
+)
+
+if host_system == 'windows'
+  fail_validator_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'fail_validator',
+    '--FILEDESC', 'fail_validator - failing OAuth validator module',])
+endif
+
+fail_validator = shared_module('fail_validator',
+  fail_validator_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += fail_validator
+
+oauth_hook_client_sources = files(
+  'oauth_hook_client.c',
+)
+
+if host_system == 'windows'
+  oauth_hook_client_sources += rc_bin_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'oauth_hook_client',
+    '--FILEDESC', 'oauth_hook_client - test program for libpq OAuth hooks',])
+endif
+
+oauth_hook_client = executable('oauth_hook_client',
+  oauth_hook_client_sources,
+  dependencies: [frontend_code, libpq],
+  kwargs: default_bin_args + {
+    'install': false,
+  },
+)
+testprep_targets += oauth_hook_client
+
+tests += {
+  'name': 'oauth_validator',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'tap': {
+    'tests': [
+      't/001_server.pl',
+      't/002_client.pl',
+    ],
+    'env': {
+      'PYTHON': python.path(),
+      'with_libcurl': libcurl.found() ? 'yes' : 'no',
+      'with_python': 'yes',
+    },
+  },
+}
diff --git a/src/test/modules/oauth_validator/oauth_hook_client.c b/src/test/modules/oauth_validator/oauth_hook_client.c
new file mode 100644
index 00000000000..12fe70c990b
--- /dev/null
+++ b/src/test/modules/oauth_validator/oauth_hook_client.c
@@ -0,0 +1,264 @@
+/*-------------------------------------------------------------------------
+ *
+ * oauth_hook_client.c
+ *		Test driver for t/002_client.pl, which verifies OAuth hook
+ *		functionality in libpq.
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ *
+ * IDENTIFICATION
+ *		src/test/modules/oauth_validator/oauth_hook_client.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres_fe.h"
+
+#include <sys/socket.h>
+
+#include "getopt_long.h"
+#include "libpq-fe.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(" --expected-scope SCOPE	fail if received scopes do not match SCOPE\n");
+	printf(" --expected-uri URI		fail if received configuration link does not match URI\n");
+	printf(" --misbehave=MODE		have the hook fail required postconditions\n"
+		   "						(MODEs: no-hook, fail-async, no-token, no-socket)\n");
+	printf(" --no-hook				don't install OAuth hooks (connection will fail)\n");
+	printf(" --hang-forever			don't ever return a token (combine with connect_timeout)\n");
+	printf(" --token TOKEN			use the provided TOKEN value\n");
+}
+
+/* --options */
+static bool no_hook = false;
+static bool hang_forever = false;
+static const char *expected_uri = NULL;
+static const char *expected_scope = NULL;
+static const char *misbehave_mode = NULL;
+static char *token = NULL;
+
+int
+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},
+		{0}
+	};
+
+	const char *conninfo;
+	PGconn	   *conn;
+	int			c;
+
+	while ((c = getopt_long(argc, argv, "h", long_options, NULL)) != -1)
+	{
+		switch (c)
+		{
+			case 'h':
+				usage(argv);
+				return 0;
+
+			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;
+
+			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);
+
+	/* Connect. (All the actual work is in the hook.) */
+	conn = PQconnectdb(conninfo);
+	if (PQstatus(conn) != CONNECTION_OK)
+	{
+		fprintf(stderr, "Connection to database failed: %s\n",
+				PQerrorMessage(conn));
+		PQfinish(conn);
+		return 1;
+	}
+
+	printf("connection succeeded\n");
+	PQfinish(conn);
+	return 0;
+}
+
+/*
+ * PQauthDataHook implementation. Replaces the default client flow by handling
+ * PQAUTHDATA_OAUTH_BEARER_TOKEN.
+ */
+static int
+handle_auth_data(PGauthData type, PGconn *conn, void *data)
+{
+	PGoauthBearerRequest *req = data;
+
+	if (no_hook || (type != PQAUTHDATA_OAUTH_BEARER_TOKEN))
+		return 0;
+
+	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;
+		}
+	}
+
+	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". */
+		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/001_server.pl b/src/test/modules/oauth_validator/t/001_server.pl
new file mode 100644
index 00000000000..80f52585896
--- /dev/null
+++ b/src/test/modules/oauth_validator/t/001_server.pl
@@ -0,0 +1,551 @@
+
+#
+# Tests the libpq builtin OAuth flow, as well as server-side HBA and validator
+# setup.
+#
+# Copyright (c) 2021-2024, PostgreSQL Global Development Group
+#
+
+use strict;
+use warnings FATAL => 'all';
+
+use JSON::PP     qw(encode_json);
+use MIME::Base64 qw(encode_base64);
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+use FindBin;
+use lib $FindBin::RealBin;
+
+use OAuth::Server;
+
+if (!$ENV{PG_TEST_EXTRA} || $ENV{PG_TEST_EXTRA} !~ /\boauth\b/)
+{
+	plan skip_all =>
+	  'Potentially unsafe test oauth not enabled in PG_TEST_EXTRA';
+}
+
+if ($ENV{with_libcurl} ne 'yes')
+{
+	plan skip_all => 'client-side OAuth not supported by this build';
+}
+
+if ($ENV{with_python} ne 'yes')
+{
+	plan skip_all => 'OAuth tests require --with-python to run';
+}
+
+my $node = PostgreSQL::Test::Cluster->new('primary');
+$node->init;
+$node->append_conf('postgresql.conf', "log_connections = on\n");
+$node->append_conf('postgresql.conf',
+	"oauth_validator_libraries = 'validator'\n");
+$node->start;
+
+$node->safe_psql('postgres', 'CREATE USER test;');
+$node->safe_psql('postgres', 'CREATE USER testalt;');
+$node->safe_psql('postgres', 'CREATE USER testparam;');
+
+# Save a background connection for later configuration changes.
+my $bgconn = $node->background_psql('postgres');
+
+my $webserver = OAuth::Server->new();
+$webserver->run();
+
+END
+{
+	my $exit_code = $?;
+
+	$webserver->stop() if defined $webserver;    # might have been SKIP'd
+
+	$? = $exit_code;
+}
+
+my $port = $webserver->port();
+my $issuer = "http://localhost:$port";
+
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test      oauth issuer="$issuer"       scope="openid postgres"
+local all testalt   oauth issuer="$issuer/.well-known/oauth-authorization-server/alternate" scope="openid postgres alt"
+local all testparam oauth issuer="$issuer/param" scope="openid postgres"
+});
+$node->reload;
+
+my $log_start = $node->wait_for_log(qr/reloading configuration files/);
+
+# Check pg_hba_file_rules() support.
+my $contents = $bgconn->query_safe(
+	qq(SELECT rule_number, auth_method, options
+		 FROM pg_hba_file_rules
+		 ORDER BY rule_number;));
+is( $contents,
+	qq{1|oauth|\{issuer=$issuer,"scope=openid postgres",validator=validator\}
+2|oauth|\{issuer=$issuer/.well-known/oauth-authorization-server/alternate,"scope=openid postgres alt",validator=validator\}
+3|oauth|\{issuer=$issuer/param,"scope=openid postgres",validator=validator\}},
+	"pg_hba_file_rules recreates OAuth HBA settings");
+
+# To test against HTTP rather than HTTPS, we need to enable PGOAUTHDEBUG. But
+# first, check to make sure the client refuses such connections by default.
+$node->connect_fails(
+	"user=test dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635",
+	"HTTPS is required without debug mode",
+	expected_stderr =>
+	  qr@OAuth discovery URI "\Q$issuer\E/.well-known/openid-configuration" must use HTTPS@
+);
+
+$ENV{PGOAUTHDEBUG} = "UNSAFE";
+
+my $user = "test";
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635",
+	"connect as test",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@,
+	log_like => [
+		qr/oauth_validator: token="9243959234", role="$user"/,
+		qr/oauth_validator: issuer="\Q$issuer\E", scope="openid postgres"/,
+		qr/connection authenticated: identity="test" method=oauth/,
+		qr/connection authorized/,
+	]);
+
+# The /alternate issuer uses slightly different parameters, along with an
+# OAuth-style discovery document.
+$user = "testalt";
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer/alternate oauth_client_id=f02c6361-0636",
+	"connect as testalt",
+	expected_stderr =>
+	  qr@Visit https://example\.org/ and enter the code: postgresuser@,
+	log_like => [
+		qr/oauth_validator: token="9243959234-alt", role="$user"/,
+		qr|oauth_validator: issuer="\Q$issuer/.well-known/oauth-authorization-server/alternate\E", scope="openid postgres alt"|,
+		qr/connection authenticated: identity="testalt" method=oauth/,
+		qr/connection authorized/,
+	]);
+
+# The issuer linked by the server must match the client's oauth_issuer setting.
+$node->connect_fails(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0636",
+	"oauth_issuer must match discovery",
+	expected_stderr =>
+	  qr@server's discovery document at \Q$issuer/.well-known/oauth-authorization-server/alternate\E \(issuer "\Q$issuer/alternate\E"\) is incompatible with oauth_issuer \(\Q$issuer\E\)@
+);
+
+# Test require_auth settings against OAUTHBEARER.
+my @cases = (
+	{ require_auth => "oauth" },
+	{ require_auth => "oauth,scram-sha-256" },
+	{ require_auth => "password,oauth" },
+	{ require_auth => "none,oauth" },
+	{ require_auth => "!scram-sha-256" },
+	{ require_auth => "!none" },
+
+	{
+		require_auth => "!oauth",
+		failure => qr/server requested OAUTHBEARER authentication/
+	},
+	{
+		require_auth => "scram-sha-256",
+		failure => qr/server requested OAUTHBEARER authentication/
+	},
+	{
+		require_auth => "!password,!oauth",
+		failure => qr/server requested OAUTHBEARER authentication/
+	},
+	{
+		require_auth => "none",
+		failure => qr/server requested SASL authentication/
+	},
+	{
+		require_auth => "!oauth,!scram-sha-256",
+		failure => qr/server requested SASL authentication/
+	});
+
+$user = "test";
+foreach my $c (@cases)
+{
+	my $connstr =
+	  "user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635 require_auth=$c->{'require_auth'}";
+
+	if (defined $c->{'failure'})
+	{
+		$node->connect_fails(
+			$connstr,
+			"require_auth=$c->{'require_auth'} fails",
+			expected_stderr => $c->{'failure'});
+	}
+	else
+	{
+		$node->connect_ok(
+			$connstr,
+			"require_auth=$c->{'require_auth'} succeeds",
+			expected_stderr =>
+			  qr@Visit https://example\.com/ and enter the code: postgresuser@
+		);
+	}
+}
+
+# Make sure the client_id and secret are correctly encoded. $vschars contains
+# every allowed character for a client_id/_secret (the "VSCHAR" class).
+# $vschars_esc is additionally backslash-escaped for inclusion in a
+# single-quoted connection string.
+my $vschars =
+  " !\"#\$%&'()*+,-./0123456789:;<=>?\@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~";
+my $vschars_esc =
+  " !\"#\$%&\\'()*+,-./0123456789:;<=>?\@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\\\]^_`abcdefghijklmnopqrstuvwxyz{|}~";
+
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id='$vschars_esc'",
+	"escapable characters: client_id",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id='$vschars_esc' oauth_client_secret='$vschars_esc'",
+	"escapable characters: client_id and secret",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+#
+# Further tests rely on support for specific behaviors in oauth_server.py. To
+# trigger these behaviors, we ask for the special issuer .../param (which is set
+# up in HBA for the testparam user) and encode magic instructions into the
+# oauth_client_id.
+#
+
+my $common_connstr =
+  "user=testparam dbname=postgres oauth_issuer=$issuer/param ";
+my $base_connstr = $common_connstr;
+
+sub connstr
+{
+	my (%params) = @_;
+
+	my $json = encode_json(\%params);
+	my $encoded = encode_base64($json, "");
+
+	return "$base_connstr oauth_client_id=$encoded";
+}
+
+# Make sure the param system works end-to-end first.
+$node->connect_ok(
+	connstr(),
+	"connect to /param",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$node->connect_ok(
+	connstr(stage => 'token', retries => 1),
+	"token retry",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(stage => 'token', retries => 2),
+	"token retry (twice)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(stage => 'all', retries => 1, interval => 2),
+	"token retry (two second interval)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(stage => 'all', retries => 1, interval => JSON::PP::null),
+	"token retry (default interval)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$node->connect_ok(
+	connstr(stage => 'all', content_type => 'application/json;charset=utf-8'),
+	"content type with charset",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(
+		stage => 'all',
+		content_type => "application/json \t;\t charset=utf-8"),
+	"content type with charset (whitespace)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(stage => 'device', uri_spelling => "verification_url"),
+	"alternative spelling of verification_uri",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$node->connect_fails(
+	connstr(stage => 'device', huge_response => JSON::PP::true),
+	"bad device authz response: overlarge JSON",
+	expected_stderr =>
+	  qr/failed to obtain device authorization: response is too large/);
+$node->connect_fails(
+	connstr(stage => 'token', huge_response => JSON::PP::true),
+	"bad token response: overlarge JSON",
+	expected_stderr =>
+	  qr/failed to obtain access token: response is too large/);
+
+$node->connect_fails(
+	connstr(stage => 'device', content_type => 'text/plain'),
+	"bad device authz response: wrong content type",
+	expected_stderr =>
+	  qr/failed to parse device authorization: unexpected content type/);
+$node->connect_fails(
+	connstr(stage => 'token', content_type => 'text/plain'),
+	"bad token response: wrong content type",
+	expected_stderr =>
+	  qr/failed to parse access token response: unexpected content type/);
+$node->connect_fails(
+	connstr(stage => 'token', content_type => 'application/jsonx'),
+	"bad token response: wrong content type (correct prefix)",
+	expected_stderr =>
+	  qr/failed to parse access token response: unexpected content type/);
+
+$node->connect_fails(
+	connstr(
+		stage => 'all',
+		interval => ~0,
+		retries => 1,
+		retry_code => "slow_down"),
+	"bad token response: server overflows the device authz interval",
+	expected_stderr =>
+	  qr/failed to obtain access token: slow_down interval overflow/);
+
+$node->connect_fails(
+	connstr(stage => 'token', error_code => "invalid_grant"),
+	"bad token response: invalid_grant, no description",
+	expected_stderr => qr/failed to obtain access token: \(invalid_grant\)/);
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_grant",
+		error_desc => "grant expired"),
+	"bad token response: expired grant",
+	expected_stderr =>
+	  qr/failed to obtain access token: grant expired \(invalid_grant\)/);
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_client",
+		error_status => 401),
+	"bad token response: client authentication failure, default description",
+	expected_stderr =>
+	  qr/failed to obtain access token: provider requires client authentication, and no oauth_client_secret is set \(invalid_client\)/
+);
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_client",
+		error_status => 401,
+		error_desc => "authn failure"),
+	"bad token response: client authentication failure, provided description",
+	expected_stderr =>
+	  qr/failed to obtain access token: authn failure \(invalid_client\)/);
+
+$node->connect_fails(
+	connstr(stage => 'token', token => ""),
+	"server rejects access: empty token",
+	expected_stderr => qr/bearer authentication failed/);
+$node->connect_fails(
+	connstr(stage => 'token', token => "****"),
+	"server rejects access: invalid token contents",
+	expected_stderr => qr/bearer authentication failed/);
+
+# Test behavior of the oauth_client_secret.
+$base_connstr = "$common_connstr oauth_client_secret=''";
+
+$node->connect_ok(
+	connstr(stage => 'all', expected_secret => ''),
+	"empty oauth_client_secret",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$base_connstr = "$common_connstr oauth_client_secret='$vschars_esc'";
+
+$node->connect_ok(
+	connstr(stage => 'all', expected_secret => $vschars),
+	"nonempty oauth_client_secret",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_client",
+		error_status => 401),
+	"bad token response: client authentication failure, default description with oauth_client_secret",
+	expected_stderr =>
+	  qr/failed to obtain access token: provider rejected the oauth_client_secret \(invalid_client\)/
+);
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_client",
+		error_status => 401,
+		error_desc => "mutual TLS required for client"),
+	"bad token response: client authentication failure, provided description with oauth_client_secret",
+	expected_stderr =>
+	  qr/failed to obtain access token: mutual TLS required for client \(invalid_client\)/
+);
+
+#
+# This section of tests reconfigures the validator module itself, rather than
+# the OAuth server.
+#
+
+# Searching the logs is easier if OAuth parameter discovery isn't cluttering
+# things up; hardcode the discovery URI. (Scope is hardcoded to empty to cover
+# that case as well.)
+$common_connstr =
+  "dbname=postgres oauth_issuer=$issuer/.well-known/openid-configuration oauth_scope='' oauth_client_id=f02c6361-0635";
+
+# Misbehaving validators must fail shut.
+$bgconn->query_safe("ALTER SYSTEM SET oauth_validator.authn_id TO ''");
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+$node->connect_fails(
+	"$common_connstr user=test",
+	"validator must set authn_id",
+	expected_stderr => qr/OAuth bearer authentication failed/,
+	log_like => [
+		qr/connection authenticated: identity=""/,
+		qr/DETAIL:\s+Validator provided no identity/,
+		qr/FATAL:\s+OAuth bearer authentication failed/,
+	]);
+
+# Even if a validator authenticates the user, if the token isn't considered
+# valid, the connection fails.
+$bgconn->query_safe(
+	"ALTER SYSTEM SET oauth_validator.authn_id TO 'test\@example.org'");
+$bgconn->query_safe(
+	"ALTER SYSTEM SET oauth_validator.authorize_tokens TO false");
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+$node->connect_fails(
+	"$common_connstr user=test",
+	"validator must authorize token explicitly",
+	expected_stderr => qr/OAuth bearer authentication failed/,
+	log_like => [
+		qr/connection authenticated: identity="test\@example\.org"/,
+		qr/DETAIL:\s+Validator failed to authorize the provided token/,
+		qr/FATAL:\s+OAuth bearer authentication failed/,
+	]);
+
+#
+# Test user mapping.
+#
+
+# Allow "[email protected]" to log in under the test role.
+unlink($node->data_dir . '/pg_ident.conf');
+$node->append_conf(
+	'pg_ident.conf', qq{
+oauthmap	user\@example.com	test
+});
+
+# test and testalt use the map; testparam uses ident delegation.
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test      oauth issuer="$issuer" scope="" map=oauthmap
+local all testalt   oauth issuer="$issuer" scope="" map=oauthmap
+local all testparam oauth issuer="$issuer" scope="" delegate_ident_mapping=1
+});
+
+# To start, have the validator use the role names as authn IDs.
+$bgconn->query_safe("ALTER SYSTEM RESET oauth_validator.authn_id");
+$bgconn->query_safe("ALTER SYSTEM RESET oauth_validator.authorize_tokens");
+
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+# The test and testalt roles should no longer map correctly.
+$node->connect_fails(
+	"$common_connstr user=test",
+	"mismatched username map (test)",
+	expected_stderr => qr/OAuth bearer authentication failed/);
+$node->connect_fails(
+	"$common_connstr user=testalt",
+	"mismatched username map (testalt)",
+	expected_stderr => qr/OAuth bearer authentication failed/);
+
+# Have the validator identify the end user as [email protected].
+$bgconn->query_safe(
+	"ALTER SYSTEM SET oauth_validator.authn_id TO 'user\@example.com'");
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+# Now the test role can be logged into. (testalt still can't be mapped.)
+$node->connect_ok(
+	"$common_connstr user=test",
+	"matched username map (test)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_fails(
+	"$common_connstr user=testalt",
+	"mismatched username map (testalt)",
+	expected_stderr => qr/OAuth bearer authentication failed/);
+
+# testparam ignores the map entirely.
+$node->connect_ok(
+	"$common_connstr user=testparam",
+	"delegated ident (testparam)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$bgconn->query_safe("ALTER SYSTEM RESET oauth_validator.authn_id");
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+#
+# Test multiple validators.
+#
+
+$node->append_conf('postgresql.conf',
+	"oauth_validator_libraries = 'validator, fail_validator'\n");
+
+# With multiple validators, every HBA line must explicitly declare one.
+my $result = $node->restart(fail_ok => 1);
+is($result, 0,
+	'restart fails without explicit validators in oauth HBA entries');
+
+$log_start = $node->wait_for_log(
+	qr/authentication method "oauth" requires argument "validator" to be set/,
+	$log_start);
+
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test    oauth validator=validator      issuer="$issuer"           scope="openid postgres"
+local all testalt oauth validator=fail_validator issuer="$issuer/.well-known/oauth-authorization-server/alternate" scope="openid postgres alt"
+});
+$node->restart;
+
+$log_start = $node->wait_for_log(qr/ready to accept connections/, $log_start);
+
+# The test user should work as before.
+$user = "test";
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635",
+	"validator is used for $user",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@,
+	log_like => [qr/connection authorized/]);
+
+# testalt should be routed through the fail_validator.
+$user = "testalt";
+$node->connect_fails(
+	"user=$user dbname=postgres oauth_issuer=$issuer/.well-known/oauth-authorization-server/alternate oauth_client_id=f02c6361-0636",
+	"fail_validator is used for $user",
+	expected_stderr => qr/FATAL:\s+fail_validator: sentinel error/);
+
+$node->stop;
+
+done_testing();
diff --git a/src/test/modules/oauth_validator/t/002_client.pl b/src/test/modules/oauth_validator/t/002_client.pl
new file mode 100644
index 00000000000..95cccf90dd8
--- /dev/null
+++ b/src/test/modules/oauth_validator/t/002_client.pl
@@ -0,0 +1,154 @@
+#
+# Exercises the API for custom OAuth client flows, using the oauth_hook_client
+# test driver.
+#
+# Copyright (c) 2021-2024, PostgreSQL Global Development Group
+#
+
+use strict;
+use warnings FATAL => 'all';
+
+use JSON::PP     qw(encode_json);
+use MIME::Base64 qw(encode_base64);
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+if (!$ENV{PG_TEST_EXTRA} || $ENV{PG_TEST_EXTRA} !~ /\boauth\b/)
+{
+	plan skip_all =>
+	  'Potentially unsafe test oauth not enabled in PG_TEST_EXTRA';
+}
+
+#
+# Cluster Setup
+#
+
+my $node = PostgreSQL::Test::Cluster->new('primary');
+$node->init;
+$node->append_conf('postgresql.conf', "log_connections = on\n");
+$node->append_conf('postgresql.conf',
+	"oauth_validator_libraries = 'validator'\n");
+$node->start;
+
+$node->safe_psql('postgres', 'CREATE USER test;');
+
+# These tests don't use the builtin flow, and we don't have an authorization
+# server running, so the address used here shouldn't matter. Use an invalid IP
+# address, so if there's some cascade of errors that causes the client to
+# attempt a connection, we'll fail noisily.
+my $issuer = "https://256.256.256.256";
+my $scope = "openid postgres";
+
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test oauth issuer="$issuer" scope="$scope"
+});
+$node->reload;
+
+my ($log_start, $log_end);
+$log_start = $node->wait_for_log(qr/reloading configuration files/);
+
+$ENV{PGOAUTHDEBUG} = "UNSAFE";
+
+#
+# Tests
+#
+
+my $user = "test";
+my $base_connstr = $node->connstr() . " user=$user";
+my $common_connstr =
+  "$base_connstr oauth_issuer=$issuer oauth_client_id=myID";
+
+sub test
+{
+	my ($test_name, %params) = @_;
+
+	my $flags = [];
+	if (defined($params{flags}))
+	{
+		$flags = $params{flags};
+	}
+
+	my @cmd = ("oauth_hook_client", @{$flags}, $common_connstr);
+	note "running '" . join("' '", @cmd) . "'";
+
+	my ($stdout, $stderr) = run_command(\@cmd);
+
+	if (defined($params{expected_stdout}))
+	{
+		like($stdout, $params{expected_stdout}, "$test_name: stdout matches");
+	}
+
+	if (defined($params{expected_stderr}))
+	{
+		like($stderr, $params{expected_stderr}, "$test_name: stderr matches");
+	}
+	else
+	{
+		is($stderr, "", "$test_name: no stderr");
+	}
+}
+
+test(
+	"basic synchronous hook can provide a token",
+	flags => [
+		"--token", "my-token",
+		"--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"/, ]);
+
+if ($ENV{with_libcurl} ne 'yes')
+{
+	# libpq should help users out if no OAuth support is built in.
+	test(
+		"fails without custom hook installed",
+		flags => ["--no-hook"],
+		expected_stderr =>
+		  qr/no custom OAuth flows are available, and libpq was not built with libcurl support/
+	);
+}
+
+# connect_timeout should work if the flow doesn't respond.
+$common_connstr = "$common_connstr connect_timeout=1";
+test(
+	"connect_timeout interrupts hung client flow",
+	flags => ["--hang-forever"],
+	expected_stderr => qr/failed: timeout expired/);
+
+# Test various misbehaviors of the client hook.
+my @cases = (
+	{
+		flag => "--misbehave=no-hook",
+		expected_error =>
+		  qr/user-defined OAuth flow provided neither a token nor an async callback/,
+	},
+	{
+		flag => "--misbehave=fail-async",
+		expected_error => qr/user-defined OAuth flow failed/,
+	},
+	{
+		flag => "--misbehave=no-token",
+		expected_error => qr/user-defined OAuth flow did not provide a token/,
+	},
+	{
+		flag => "--misbehave=no-socket",
+		expected_error =>
+		  qr/user-defined OAuth flow did not provide a socket for polling/,
+	});
+
+foreach my $c (@cases)
+{
+	test(
+		"hook misbehavior: $c->{'flag'}",
+		flags => [ $c->{'flag'} ],
+		expected_stderr => $c->{'expected_error'});
+}
+
+done_testing();
diff --git a/src/test/modules/oauth_validator/t/OAuth/Server.pm b/src/test/modules/oauth_validator/t/OAuth/Server.pm
new file mode 100644
index 00000000000..f0f23d1d1a8
--- /dev/null
+++ b/src/test/modules/oauth_validator/t/OAuth/Server.pm
@@ -0,0 +1,140 @@
+
+# Copyright (c) 2024, PostgreSQL Global Development Group
+
+=pod
+
+=head1 NAME
+
+OAuth::Server - runs a mock OAuth authorization server for testing
+
+=head1 SYNOPSIS
+
+  use OAuth::Server;
+
+  my $server = OAuth::Server->new();
+  $server->run;
+
+  my $port = $server->port;
+  my $issuer = "http://localhost:$port";
+
+  # test against $issuer...
+
+  $server->stop;
+
+=head1 DESCRIPTION
+
+This is glue API between the Perl tests and the Python authorization server
+daemon implemented in t/oauth_server.py. (Python has a fairly usable HTTP server
+in its standard library, so the implementation was ported from Perl.)
+
+This authorization server does not use TLS (it implements a nonstandard, unsafe
+issuer at "http://localhost:<port>"), so libpq in particular will need to set
+PGOAUTHDEBUG=UNSAFE to be able to talk to it.
+
+=cut
+
+package OAuth::Server;
+
+use warnings;
+use strict;
+use Scalar::Util;
+use Test::More;
+
+=pod
+
+=head1 METHODS
+
+=over
+
+=item SSL::Server->new()
+
+Create a new OAuth Server object.
+
+=cut
+
+sub new
+{
+	my $class = shift;
+
+	my $self = {};
+	bless($self, $class);
+
+	return $self;
+}
+
+=pod
+
+=item $server->port()
+
+Returns the port in use by the server.
+
+=cut
+
+sub port
+{
+	my $self = shift;
+
+	return $self->{'port'};
+}
+
+=pod
+
+=item $server->run()
+
+Runs the authorization server daemon in t/oauth_server.py.
+
+=cut
+
+sub run
+{
+	my $self = shift;
+	my $port;
+
+	my $pid = open(my $read_fh, "-|", $ENV{PYTHON}, "t/oauth_server.py")
+	  or die "failed to start OAuth server: $!";
+
+	# Get the port number from the daemon. It closes stdout afterwards; that way
+	# we can slurp in the entire contents here rather than worrying about the
+	# number of bytes to read.
+	$port = do { local $/ = undef; <$read_fh> }
+	  // die "failed to read port number: $!";
+	chomp $port;
+	die "server did not advertise a valid port"
+	  unless Scalar::Util::looks_like_number($port);
+
+	$self->{'pid'} = $pid;
+	$self->{'port'} = $port;
+	$self->{'child'} = $read_fh;
+
+	note("OAuth provider (PID $pid) is listening on port $port\n");
+}
+
+=pod
+
+=item $server->stop()
+
+Sends SIGTERM to the authorization server and waits for it to exit.
+
+=cut
+
+sub stop
+{
+	my $self = shift;
+
+	note("Sending SIGTERM to OAuth provider PID: $self->{'pid'}\n");
+
+	kill(15, $self->{'pid'});
+	$self->{'pid'} = undef;
+
+	# Closing the popen() handle waits for the process to exit.
+	close($self->{'child'});
+	$self->{'child'} = undef;
+}
+
+=pod
+
+=back
+
+=cut
+
+1;
diff --git a/src/test/modules/oauth_validator/t/oauth_server.py b/src/test/modules/oauth_validator/t/oauth_server.py
new file mode 100755
index 00000000000..8ec09102027
--- /dev/null
+++ b/src/test/modules/oauth_validator/t/oauth_server.py
@@ -0,0 +1,391 @@
+#! /usr/bin/env python3
+#
+# A mock OAuth authorization server, designed to be invoked from
+# OAuth/Server.pm. This listens on an ephemeral port number (printed to stdout
+# so that the Perl tests can contact it) and runs as a daemon until it is
+# signaled.
+#
+
+import base64
+import http.server
+import json
+import os
+import sys
+import time
+import urllib.parse
+from collections import defaultdict
+
+
+class OAuthHandler(http.server.BaseHTTPRequestHandler):
+    """
+    Core implementation of the authorization server. The API is
+    inheritance-based, with entry points at do_GET() and do_POST(). See the
+    documentation for BaseHTTPRequestHandler.
+    """
+
+    JsonObject = dict[str, object]  # TypeAlias is not available until 3.10
+
+    def _check_issuer(self):
+        """
+        Switches the behavior of the provider depending on the issuer URI.
+        """
+        self._alt_issuer = (
+            self.path.startswith("/alternate/")
+            or self.path == "/.well-known/oauth-authorization-server/alternate"
+        )
+        self._parameterized = self.path.startswith("/param/")
+
+        if self._alt_issuer:
+            # The /alternate issuer uses IETF-style .well-known URIs.
+            if self.path.startswith("/.well-known/"):
+                self.path = self.path.removesuffix("/alternate")
+            else:
+                self.path = self.path.removeprefix("/alternate")
+        elif self._parameterized:
+            self.path = self.path.removeprefix("/param")
+
+    def _check_authn(self):
+        """
+        Checks the expected value of the Authorization header, if any.
+        """
+        secret = self._get_param("expected_secret", None)
+        if secret is None:
+            return
+
+        assert "Authorization" in self.headers
+        method, creds = self.headers["Authorization"].split()
+
+        if method != "Basic":
+            raise RuntimeError(f"client used {method} auth; expected Basic")
+
+        username = urllib.parse.quote_plus(self.client_id)
+        password = urllib.parse.quote_plus(secret)
+        expected_creds = f"{username}:{password}"
+
+        if creds.encode() != base64.b64encode(expected_creds.encode()):
+            raise RuntimeError(
+                f"client sent '{creds}'; expected b64encode('{expected_creds}')"
+            )
+
+    def do_GET(self):
+        self._response_code = 200
+        self._check_issuer()
+
+        config_path = "/.well-known/openid-configuration"
+        if self._alt_issuer:
+            config_path = "/.well-known/oauth-authorization-server"
+
+        if self.path == config_path:
+            resp = self.config()
+        else:
+            self.send_error(404, "Not Found")
+            return
+
+        self._send_json(resp)
+
+    def _parse_params(self) -> dict[str, str]:
+        """
+        Parses apart the form-urlencoded request body and returns the resulting
+        dict. For use by do_POST().
+        """
+        size = int(self.headers["Content-Length"])
+        form = self.rfile.read(size)
+
+        assert self.headers["Content-Type"] == "application/x-www-form-urlencoded"
+        return urllib.parse.parse_qs(
+            form.decode("utf-8"),
+            strict_parsing=True,
+            keep_blank_values=True,
+            encoding="utf-8",
+            errors="strict",
+        )
+
+    @property
+    def client_id(self) -> str:
+        """
+        Returns the client_id sent in the POST body or the Authorization header.
+        self._parse_params() must have been called first.
+        """
+        if "client_id" in self._params:
+            return self._params["client_id"][0]
+
+        if "Authorization" not in self.headers:
+            raise RuntimeError("client did not send any client_id")
+
+        _, creds = self.headers["Authorization"].split()
+
+        decoded = base64.b64decode(creds).decode("utf-8")
+        username, _ = decoded.split(":", 1)
+
+        return urllib.parse.unquote_plus(username)
+
+    def do_POST(self):
+        self._response_code = 200
+        self._check_issuer()
+
+        self._params = self._parse_params()
+        if self._parameterized:
+            # Pull encoded test parameters out of the peer's client_id field.
+            # This is expected to be Base64-encoded JSON.
+            js = base64.b64decode(self.client_id)
+            self._test_params = json.loads(js)
+
+        self._check_authn()
+
+        if self.path == "/authorize":
+            resp = self.authorization()
+        elif self.path == "/token":
+            resp = self.token()
+        else:
+            self.send_error(404)
+            return
+
+        self._send_json(resp)
+
+    def _should_modify(self) -> bool:
+        """
+        Returns True if the client has requested a modification to this stage of
+        the exchange.
+        """
+        if not hasattr(self, "_test_params"):
+            return False
+
+        stage = self._test_params.get("stage")
+
+        return (
+            stage == "all"
+            or (
+                stage == "discovery"
+                and self.path == "/.well-known/openid-configuration"
+            )
+            or (stage == "device" and self.path == "/authorize")
+            or (stage == "token" and self.path == "/token")
+        )
+
+    def _get_param(self, name, default):
+        """
+        If the client has requested a modification to this stage (see
+        _should_modify()), this method searches the provided test parameters for
+        a key of the given name, and returns it if found. Otherwise the provided
+        default is returned.
+        """
+        if self._should_modify() and name in self._test_params:
+            return self._test_params[name]
+
+        return default
+
+    @property
+    def _content_type(self) -> str:
+        """
+        Returns "application/json" unless the test has requested something
+        different.
+        """
+        return self._get_param("content_type", "application/json")
+
+    @property
+    def _interval(self) -> int:
+        """
+        Returns 0 unless the test has requested something different.
+        """
+        return self._get_param("interval", 0)
+
+    @property
+    def _retry_code(self) -> str:
+        """
+        Returns "authorization_pending" unless the test has requested something
+        different.
+        """
+        return self._get_param("retry_code", "authorization_pending")
+
+    @property
+    def _uri_spelling(self) -> str:
+        """
+        Returns "verification_uri" unless the test has requested something
+        different.
+        """
+        return self._get_param("uri_spelling", "verification_uri")
+
+    @property
+    def _response_padding(self):
+        """
+        If the huge_response test parameter is set to True, returns a dict
+        containing a gigantic string value, which can then be folded into a JSON
+        response.
+        """
+        if not self._get_param("huge_response", False):
+            return dict()
+
+        return {"_pad_": "x" * 1024 * 1024}
+
+    @property
+    def _access_token(self):
+        """
+        The actual Bearer token sent back to the client on success. Tests may
+        override this with the "token" test parameter.
+        """
+        token = self._get_param("token", None)
+        if token is not None:
+            return token
+
+        token = "9243959234"
+        if self._alt_issuer:
+            token += "-alt"
+
+        return token
+
+    def _send_json(self, js: JsonObject) -> None:
+        """
+        Sends the provided JSON dict as an application/json response.
+        self._response_code can be modified to send JSON error responses.
+        """
+        resp = json.dumps(js).encode("ascii")
+        self.log_message("sending JSON response: %s", resp)
+
+        self.send_response(self._response_code)
+        self.send_header("Content-Type", self._content_type)
+        self.send_header("Content-Length", str(len(resp)))
+        self.end_headers()
+
+        self.wfile.write(resp)
+
+    def config(self) -> JsonObject:
+        port = self.server.socket.getsockname()[1]
+
+        issuer = f"http://localhost:{port}"
+        if self._alt_issuer:
+            issuer += "/alternate"
+        elif self._parameterized:
+            issuer += "/param"
+
+        return {
+            "issuer": issuer,
+            "token_endpoint": issuer + "/token",
+            "device_authorization_endpoint": issuer + "/authorize",
+            "response_types_supported": ["token"],
+            "subject_types_supported": ["public"],
+            "id_token_signing_alg_values_supported": ["RS256"],
+            "grant_types_supported": [
+                "authorization_code",
+                "urn:ietf:params:oauth:grant-type:device_code",
+            ],
+        }
+
+    @property
+    def _token_state(self):
+        """
+        A cached _TokenState object for the connected client (as determined by
+        the request's client_id), or a new one if it doesn't already exist.
+
+        This relies on the existence of a defaultdict attached to the server;
+        see main() below.
+        """
+        return self.server.token_state[self.client_id]
+
+    def _remove_token_state(self):
+        """
+        Removes any cached _TokenState for the current client_id. Call this
+        after the token exchange ends to get rid of unnecessary state.
+        """
+        if self.client_id in self.server.token_state:
+            del self.server.token_state[self.client_id]
+
+    def authorization(self) -> JsonObject:
+        uri = "https://example.com/"
+        if self._alt_issuer:
+            uri = "https://example.org/"
+
+        resp = {
+            "device_code": "postgres",
+            "user_code": "postgresuser",
+            self._uri_spelling: uri,
+            "expires-in": 5,
+            **self._response_padding,
+        }
+
+        interval = self._interval
+        if interval is not None:
+            resp["interval"] = interval
+            self._token_state.min_delay = interval
+        else:
+            self._token_state.min_delay = 5  # default
+
+        # Check the scope.
+        if "scope" in self._params:
+            assert self._params["scope"][0], "empty scopes should be omitted"
+
+        return resp
+
+    def token(self) -> JsonObject:
+        if err := self._get_param("error_code", None):
+            self._response_code = self._get_param("error_status", 400)
+
+            resp = {"error": err}
+            if desc := self._get_param("error_desc", ""):
+                resp["error_description"] = desc
+
+            return resp
+
+        if self._should_modify() and "retries" in self._test_params:
+            retries = self._test_params["retries"]
+
+            # Check to make sure the token interval is being respected.
+            now = time.monotonic()
+            if self._token_state.last_try is not None:
+                delay = now - self._token_state.last_try
+                assert (
+                    delay > self._token_state.min_delay
+                ), f"client waited only {delay} seconds between token requests (expected {self._token_state.min_delay})"
+
+            self._token_state.last_try = now
+
+            # If we haven't reached the required number of retries yet, return a
+            # "pending" response.
+            if self._token_state.retries < retries:
+                self._token_state.retries += 1
+
+                self._response_code = 400
+                return {"error": self._retry_code}
+
+        # Clean up any retry tracking state now that the exchange is ending.
+        self._remove_token_state()
+
+        return {
+            "access_token": self._access_token,
+            "token_type": "bearer",
+            **self._response_padding,
+        }
+
+
+def main():
+    """
+    Starts the authorization server on localhost. The ephemeral port in use will
+    be printed to stdout.
+    """
+
+    s = http.server.HTTPServer(("127.0.0.1", 0), OAuthHandler)
+
+    # Attach a "cache" dictionary to the server to allow the OAuthHandlers to
+    # track state across token requests. The use of defaultdict ensures that new
+    # entries will be created automatically.
+    class _TokenState:
+        retries = 0
+        min_delay = None
+        last_try = None
+
+    s.token_state = defaultdict(_TokenState)
+
+    # Give the parent the port number to contact (this is also the signal that
+    # we're ready to receive requests).
+    port = s.socket.getsockname()[1]
+    print(port)
+
+    # stdout is closed to allow the parent to just "read to the end".
+    stdout = sys.stdout.fileno()
+    sys.stdout.close()
+    os.close(stdout)
+
+    s.serve_forever()  # we expect our parent to send a termination signal
+
+
+if __name__ == "__main__":
+    main()
diff --git a/src/test/modules/oauth_validator/validator.c b/src/test/modules/oauth_validator/validator.c
new file mode 100644
index 00000000000..bf94f091def
--- /dev/null
+++ b/src/test/modules/oauth_validator/validator.c
@@ -0,0 +1,135 @@
+/*-------------------------------------------------------------------------
+ *
+ * validator.c
+ *	  Test module for serverside OAuth token validation callbacks
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/test/modules/oauth_validator/validator.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "fmgr.h"
+#include "libpq/oauth.h"
+#include "miscadmin.h"
+#include "utils/guc.h"
+#include "utils/memutils.h"
+
+PG_MODULE_MAGIC;
+
+static void validator_startup(ValidatorModuleState *state);
+static void validator_shutdown(ValidatorModuleState *state);
+static ValidatorModuleResult *validate_token(ValidatorModuleState *state,
+											 const char *token,
+											 const char *role);
+
+/* Callback implementations (exercise all three) */
+static const OAuthValidatorCallbacks validator_callbacks = {
+	.startup_cb = validator_startup,
+	.shutdown_cb = validator_shutdown,
+	.validate_cb = validate_token
+};
+
+/* GUCs */
+static char *authn_id = NULL;
+static bool authorize_tokens = true;
+
+/*---
+ * Extension entry point. Sets up GUCs for use by tests:
+ *
+ * - oauth_validator.authn_id	Sets the user identifier to return during token
+ *								validation. Defaults to the username in the
+ *								startup packet.
+ *
+ * - oauth_validator.authorize_tokens
+ *								Sets whether to successfully validate incoming
+ *								tokens. Defaults to true.
+ */
+void
+_PG_init(void)
+{
+	DefineCustomStringVariable("oauth_validator.authn_id",
+							   "Authenticated identity to use for future connections",
+							   NULL,
+							   &authn_id,
+							   NULL,
+							   PGC_SIGHUP,
+							   0,
+							   NULL, NULL, NULL);
+	DefineCustomBoolVariable("oauth_validator.authorize_tokens",
+							 "Should tokens be marked valid?",
+							 NULL,
+							 &authorize_tokens,
+							 true,
+							 PGC_SIGHUP,
+							 0,
+							 NULL, NULL, NULL);
+
+	MarkGUCPrefixReserved("oauth_validator");
+}
+
+/*
+ * Validator module entry point.
+ */
+const OAuthValidatorCallbacks *
+_PG_oauth_validator_module_init(void)
+{
+	return &validator_callbacks;
+}
+
+#define PRIVATE_COOKIE ((void *) 13579)
+
+/*
+ * Startup callback, to set up private data for the validator.
+ */
+static void
+validator_startup(ValidatorModuleState *state)
+{
+	state->private_data = PRIVATE_COOKIE;
+}
+
+/*
+ * Shutdown callback, to tear down the validator.
+ */
+static void
+validator_shutdown(ValidatorModuleState *state)
+{
+	/* Check to make sure our private state still exists. */
+	if (state->private_data != PRIVATE_COOKIE)
+		elog(ERROR, "oauth_validator: private state cookie changed to %p in shutdown",
+			 state->private_data);
+}
+
+/*
+ * Validator implementation. Logs the incoming data and authorizes the token by
+ * default; the behavior can be modified via the module's GUC settings.
+ */
+static ValidatorModuleResult *
+validate_token(ValidatorModuleState *state, const char *token, const char *role)
+{
+	ValidatorModuleResult *res;
+
+	/* Check to make sure our private state still exists. */
+	if (state->private_data != PRIVATE_COOKIE)
+		elog(ERROR, "oauth_validator: private state cookie changed to %p in validate",
+			 state->private_data);
+
+	res = palloc(sizeof(ValidatorModuleResult));
+
+	elog(LOG, "oauth_validator: token=\"%s\", role=\"%s\"", token, role);
+	elog(LOG, "oauth_validator: issuer=\"%s\", scope=\"%s\"",
+		 MyProcPort->hba->oauth_issuer,
+		 MyProcPort->hba->oauth_scope);
+
+	res->authorized = authorize_tokens;
+	if (authn_id)
+		res->authn_id = pstrdup(authn_id);
+	else
+		res->authn_id = pstrdup(role);
+
+	return res;
+}
diff --git a/src/test/perl/PostgreSQL/Test/Cluster.pm b/src/test/perl/PostgreSQL/Test/Cluster.pm
index a92944e0d9c..1bfdbcca59f 100644
--- a/src/test/perl/PostgreSQL/Test/Cluster.pm
+++ b/src/test/perl/PostgreSQL/Test/Cluster.pm
@@ -2514,6 +2514,11 @@ instead of the default.
 
 If this regular expression is set, matches it with the output generated.
 
+=item expected_stderr => B<value>
+
+If this regular expression is set, matches it against the standard error
+stream; otherwise the stderr must be empty.
+
 =item log_like => [ qr/required message/ ]
 
 =item log_unlike => [ qr/prohibited message/ ]
@@ -2557,7 +2562,20 @@ sub connect_ok
 		like($stdout, $params{expected_stdout}, "$test_name: stdout matches");
 	}
 
-	is($stderr, "", "$test_name: no stderr");
+	if (defined($params{expected_stderr}))
+	{
+		if (like($stderr, $params{expected_stderr}, "$test_name: stderr matches")
+			&& ($ret != 0))
+		{
+			# In this case (failing test but matching stderr) we'll have
+			# swallowed the output needed to debug. Put it back into the logs.
+			diag("$test_name: full stderr:\n" . $stderr);
+		}
+	}
+	else
+	{
+		is($stderr, "", "$test_name: no stderr");
+	}
 
 	$self->log_check($test_name, $log_location, %params);
 }
diff --git a/src/tools/pgindent/pgindent b/src/tools/pgindent/pgindent
index d8acce7e929..7dccf4614aa 100755
--- a/src/tools/pgindent/pgindent
+++ b/src/tools/pgindent/pgindent
@@ -242,6 +242,14 @@ sub pre_indent
 	# Protect wrapping in CATALOG()
 	$source =~ s!^(CATALOG\(.*)$!/*$1*/!gm;
 
+	# Treat a CURL_IGNORE_DEPRECATION() as braces for the purposes of
+	# indentation. (The recursive regex comes from the perlre documentation; it
+	# matches balanced parentheses as group $1 and the contents as group $2.)
+	my $curlopen = '{ /* CURL_IGNORE_DEPRECATION */';
+	my $curlclose = '} /* CURL_IGNORE_DEPRECATION */';
+	$source =~
+	  s!^[ \t]+CURL_IGNORE_DEPRECATION(\(((?:(?>[^()]+)|(?1))*)\))!$curlopen$2$curlclose!gms;
+
 	return $source;
 }
 
@@ -256,6 +264,12 @@ sub post_indent
 	$source =~ s!^/\* Open extern "C" \*/$!{!gm;
 	$source =~ s!^/\* Close extern "C" \*/$!}!gm;
 
+	# Restore the CURL_IGNORE_DEPRECATION() macro, keeping in mind that our
+	# markers may have been re-indented.
+	$source =~
+	  s!{[ \t]+/\* CURL_IGNORE_DEPRECATION \*/!CURL_IGNORE_DEPRECATION(!gm;
+	$source =~ s!}[ \t]+/\* CURL_IGNORE_DEPRECATION \*/!)!gm;
+
 	## Comments
 
 	# Undo change of dash-protected block comments
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index d5aa5c295ae..245bbaabc78 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -371,6 +371,9 @@ CState
 CTECycleClause
 CTEMaterialize
 CTESearchClause
+CURL
+CURLM
+CURLoption
 CV
 CachedExpression
 CachedPlan
@@ -1724,6 +1727,7 @@ NumericDigit
 NumericSortSupport
 NumericSumAccum
 NumericVar
+OAuthValidatorCallbacks
 OM_uint32
 OP
 OSAPerGroupState
@@ -1832,6 +1836,7 @@ PGVerbosity
 PG_Locale_Strategy
 PG_Lock_Status
 PG_init_t
+PGauthData
 PGcancel
 PGcancelConn
 PGcmdQueueEntry
@@ -1839,7 +1844,9 @@ PGconn
 PGdataValue
 PGlobjfuncs
 PGnotify
+PGoauthBearerRequest
 PGpipelineStatus
+PGpromptOAuthDevice
 PGresAttDesc
 PGresAttValue
 PGresParamDesc
@@ -1951,6 +1958,7 @@ PQArgBlock
 PQEnvironmentOption
 PQExpBuffer
 PQExpBufferData
+PQauthDataHook_type
 PQcommMethods
 PQconninfoOption
 PQnoticeProcessor
@@ -3088,6 +3096,8 @@ VacuumRelation
 VacuumStmt
 ValidIOData
 ValidateIndexState
+ValidatorModuleState
+ValidatorModuleResult
 ValuesScan
 ValuesScanState
 Var
@@ -3482,6 +3492,7 @@ explain_get_index_name_hook_type
 f_smgr
 fasthash_state
 fd_set
+fe_oauth_state
 fe_scram_state
 fe_scram_state_enum
 fetch_range_request
-- 
2.34.1



  [application/octet-stream] v43-0005-XXX-fix-libcurl-link-error.patch (1.1K, ../../CAOYmi+=K3BBRXwvJpt5D_4nK_EimCJBj7a42maACszsQC3=t9w@mail.gmail.com/7-v43-0005-XXX-fix-libcurl-link-error.patch)
  download | inline diff:
From 66ef3b4b68756843735b320a71244dfa67658a5f Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Mon, 13 Jan 2025 12:31:59 -0800
Subject: [PATCH v43 5/6] XXX fix libcurl link error

The ftp/curl port appears to be missing a minimum version dependency on
libssh2, so the following starts showing up after upgrading to curl
8.11.1_1:

    libcurl.so.4: Undefined symbol "libssh2_session_callback_set2"

But 13.3 is EOL, so it's not clear if anyone would be interested in a
bug report, and a FreeBSD 14 Cirrus image is in progress. Hack past it
for now.
---
 .cirrus.tasks.yml | 1 +
 1 file changed, 1 insertion(+)

diff --git a/.cirrus.tasks.yml b/.cirrus.tasks.yml
index 8c518c317e7..97bb38c72c6 100644
--- a/.cirrus.tasks.yml
+++ b/.cirrus.tasks.yml
@@ -165,6 +165,7 @@ task:
     sysctl kern.corefile='/tmp/cores/%N.%P.core'
   setup_additional_packages_script: |
     pkg install -y curl
+    pkg upgrade -y libssh2 # XXX shouldn't be necessary. revisit w/ FreeBSD 14
 
   # NB: Intentionally build without -Dllvm. The freebsd image size is already
   # large enough to make VM startup slow, and even without llvm freebsd
-- 
2.34.1



  [application/octet-stream] v43-0006-DO-NOT-MERGE-Add-pytest-suite-for-OAuth.patch (212.7K, ../../CAOYmi+=K3BBRXwvJpt5D_4nK_EimCJBj7a42maACszsQC3=t9w@mail.gmail.com/8-v43-0006-DO-NOT-MERGE-Add-pytest-suite-for-OAuth.patch)
  download | inline diff:
From 4df1bc59638f3dfa25c0a3610d0835acb5f5f2fd Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Fri, 4 Jun 2021 09:06:38 -0700
Subject: [PATCH v43 6/6] DO NOT MERGE: Add pytest suite for OAuth

Requires Python 3. On the first run of `make installcheck` or `meson
test` the dependencies will be installed into a local virtualenv for
you. See the README for more details.

Cirrus has been updated to build OAuth support on Debian and FreeBSD.

The suite contains a --temp-instance option, analogous to pg_regress's
option of the same name, which allows an ephemeral server to be spun up
during a test run.

TODOs:
- The --tap-stream option to pytest-tap is slightly broken during test
  failures (it suppresses error information), which impedes debugging.
- pyca/cryptography is pinned at an old version. Since we use it for
  testing and not security, this isn't a critical problem yet, but it's
  not ideal. Newer versions require a Rust compiler to build, and while
  many platforms have precompiled wheels, some (FreeBSD) do not. Even
  with the Rust pieces bypassed, compilation on FreeBSD takes a while.
- The with_oauth test skip logic should probably be integrated into the
  Makefile side as well...
- See if 32-bit tests can be enabled with a 32-bit Python.
---
 .cirrus.tasks.yml                     |    6 +-
 meson.build                           |  103 +
 src/test/meson.build                  |    1 +
 src/test/python/.gitignore            |    2 +
 src/test/python/Makefile              |   38 +
 src/test/python/README                |   66 +
 src/test/python/client/__init__.py    |    0
 src/test/python/client/conftest.py    |  196 ++
 src/test/python/client/test_client.py |  186 ++
 src/test/python/client/test_oauth.py  | 2671 +++++++++++++++++++++++++
 src/test/python/conftest.py           |   34 +
 src/test/python/meson.build           |   47 +
 src/test/python/pq3.py                |  740 +++++++
 src/test/python/pytest.ini            |    4 +
 src/test/python/requirements.txt      |   11 +
 src/test/python/server/__init__.py    |    0
 src/test/python/server/conftest.py    |  141 ++
 src/test/python/server/meson.build    |   18 +
 src/test/python/server/oauthtest.c    |  118 ++
 src/test/python/server/test_oauth.py  | 1080 ++++++++++
 src/test/python/server/test_server.py |   21 +
 src/test/python/test_internals.py     |  138 ++
 src/test/python/test_pq3.py           |  574 ++++++
 src/test/python/tls.py                |  195 ++
 src/tools/make_venv                   |   56 +
 src/tools/testwrap                    |    7 +
 26 files changed, 6452 insertions(+), 1 deletion(-)
 create mode 100644 src/test/python/.gitignore
 create mode 100644 src/test/python/Makefile
 create mode 100644 src/test/python/README
 create mode 100644 src/test/python/client/__init__.py
 create mode 100644 src/test/python/client/conftest.py
 create mode 100644 src/test/python/client/test_client.py
 create mode 100644 src/test/python/client/test_oauth.py
 create mode 100644 src/test/python/conftest.py
 create mode 100644 src/test/python/meson.build
 create mode 100644 src/test/python/pq3.py
 create mode 100644 src/test/python/pytest.ini
 create mode 100644 src/test/python/requirements.txt
 create mode 100644 src/test/python/server/__init__.py
 create mode 100644 src/test/python/server/conftest.py
 create mode 100644 src/test/python/server/meson.build
 create mode 100644 src/test/python/server/oauthtest.c
 create mode 100644 src/test/python/server/test_oauth.py
 create mode 100644 src/test/python/server/test_server.py
 create mode 100644 src/test/python/test_internals.py
 create mode 100644 src/test/python/test_pq3.py
 create mode 100644 src/test/python/tls.py
 create mode 100755 src/tools/make_venv

diff --git a/.cirrus.tasks.yml b/.cirrus.tasks.yml
index 97bb38c72c6..a6fab60bfd8 100644
--- a/.cirrus.tasks.yml
+++ b/.cirrus.tasks.yml
@@ -20,7 +20,7 @@ env:
   MTEST_ARGS: --print-errorlogs --no-rebuild -C build
   PGCTLTIMEOUT: 120 # avoids spurious failures during parallel tests
   TEMP_CONFIG: ${CIRRUS_WORKING_DIR}/src/tools/ci/pg_ci_base.conf
-  PG_TEST_EXTRA: kerberos ldap ssl libpq_encryption load_balance oauth
+  PG_TEST_EXTRA: kerberos ldap ssl libpq_encryption load_balance oauth python
 
 
 # What files to preserve in case tests fail
@@ -318,6 +318,7 @@ task:
     DEBIAN_FRONTEND=noninteractive apt-get -y install \
       libcurl4-openssl-dev \
       libcurl4-openssl-dev:i386 \
+      python3-venv \
 
   matrix:
     - name: Linux - Debian Bookworm - Autoconf
@@ -402,8 +403,11 @@ task:
       # can easily provide some here by running one of the sets of tests that
       # way. Newer versions of python insist on changing the LC_CTYPE away
       # from C, prevent that with PYTHONCOERCECLOCALE.
+      # XXX 32-bit Python tests are currently disabled, as the system's 64-bit
+      # Python modules can't link against libpq.
       test_world_32_script: |
         su postgres <<-EOF
+          export PG_TEST_EXTRA="${PG_TEST_EXTRA//python}"
           ulimit -c unlimited
           PYTHONCOERCECLOCALE=0 LANG=C meson test $MTEST_ARGS -C build-32 --num-processes ${TEST_JOBS}
         EOF
diff --git a/meson.build b/meson.build
index 351f89a92dc..72552df0f27 100644
--- a/meson.build
+++ b/meson.build
@@ -3365,6 +3365,9 @@ else
 endif
 
 testwrap = files('src/tools/testwrap')
+make_venv = files('src/tools/make_venv')
+
+checked_working_venv = false
 
 foreach test_dir : tests
   testwrap_base = [
@@ -3531,6 +3534,106 @@ foreach test_dir : tests
         )
       endforeach
       install_suites += test_group
+    elif kind == 'pytest'
+      venv_name = test_dir['name'] + '_venv'
+      venv_path = meson.build_root() / venv_name
+
+      # The Python tests require a working venv module. This is part of the
+      # standard library, but some platforms disable it until a separate package
+      # is installed. Those same platforms don't provide an easy way to check
+      # whether the venv command will work until the first time you try it, so
+      # we decide whether or not to enable these tests on the fly.
+      if not checked_working_venv
+        cmd = run_command(python, '-m', 'venv', venv_path, check: false)
+
+        have_working_venv = (cmd.returncode() == 0)
+        if not have_working_venv
+          warning('A working Python venv module is required to run Python tests.')
+        endif
+
+        checked_working_venv = true
+      endif
+
+      if not have_working_venv
+        continue
+      endif
+
+      # Make sure the temporary installation is in PATH (necessary both for
+      # --temp-instance and for any pip modules compiling against libpq, like
+      # psycopg2).
+      env = test_env
+      env.prepend('PATH', temp_install_bindir, test_dir['bd'])
+
+      foreach name, value : t.get('env', {})
+        env.set(name, value)
+      endforeach
+
+      reqs = files(t['requirements'])
+      test('install_' + venv_name,
+        python,
+        args: [ make_venv, '--requirements', reqs, venv_path ],
+        env: env,
+        priority: setup_tests_priority - 1,  # must run after tmp_install
+        is_parallel: false,
+        suite: ['setup'],
+        timeout: 60,  # 30s is too short for the cryptography package compile
+      )
+
+      test_group = test_dir['name']
+      test_output = test_result_dir / test_group / kind
+      test_kwargs = {
+        #'protocol': 'tap',
+        'suite': test_group,
+        'timeout': 1000,
+        'depends': test_deps,
+        'env': env,
+      } + t.get('test_kwargs', {})
+
+      if fs.is_dir(venv_path / 'Scripts')
+        # Windows virtualenv layout
+        pytest = venv_path / 'Scripts' / 'py.test'
+      else
+        pytest = venv_path / 'bin' / 'py.test'
+      endif
+
+      test_command = [
+        pytest,
+        # Avoid running these tests against an existing database.
+        '--temp-instance', test_output / 'data',
+
+        # FIXME pytest-tap's stream feature accidentally suppresses errors that
+        # are critical for debugging:
+        #     https://github.com/python-tap/pytest-tap/issues/30
+        # Don't use the meson TAP protocol for now...
+        #'--tap-stream',
+      ]
+
+      foreach pyt : t['tests']
+        # Similarly to TAP, strip ./ and .py to make the names prettier
+        pyt_p = pyt
+        if pyt_p.startswith('./')
+          pyt_p = pyt_p.split('./')[1]
+        endif
+        if pyt_p.endswith('.py')
+          pyt_p = fs.stem(pyt_p)
+        endif
+
+        testwrap_pytest = testwrap_base + [
+          '--testgroup', test_group,
+          '--testname', pyt_p,
+          '--skip-without-extra', 'python',
+        ]
+
+        test(test_group / pyt_p,
+          python,
+          kwargs: test_kwargs,
+          args: testwrap_pytest + [
+            '--', test_command,
+            test_dir['sd'] / pyt,
+          ],
+        )
+      endforeach
+      install_suites += test_group
     else
       error('unknown kind @0@ of test in @1@'.format(kind, test_dir['sd']))
     endif
diff --git a/src/test/meson.build b/src/test/meson.build
index ccc31d6a86a..236057cd99e 100644
--- a/src/test/meson.build
+++ b/src/test/meson.build
@@ -8,6 +8,7 @@ subdir('postmaster')
 subdir('recovery')
 subdir('subscription')
 subdir('modules')
+subdir('python')
 
 if ssl.found()
   subdir('ssl')
diff --git a/src/test/python/.gitignore b/src/test/python/.gitignore
new file mode 100644
index 00000000000..0e8f027b2ec
--- /dev/null
+++ b/src/test/python/.gitignore
@@ -0,0 +1,2 @@
+__pycache__/
+/venv/
diff --git a/src/test/python/Makefile b/src/test/python/Makefile
new file mode 100644
index 00000000000..b0695b6287e
--- /dev/null
+++ b/src/test/python/Makefile
@@ -0,0 +1,38 @@
+#
+# Copyright 2021 VMware, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+# Only Python 3 is supported, but if it's named something different on your
+# system you can override it with the PYTHON3 variable.
+PYTHON3 := python3
+
+# All dependencies are placed into this directory. The default is .gitignored
+# for you, but you can override it if you'd like.
+VENV := ./venv
+
+override VBIN   := $(VENV)/bin
+override PIP    := $(VBIN)/pip
+override PYTEST := $(VBIN)/py.test
+override ISORT  := $(VBIN)/isort
+override BLACK  := $(VBIN)/black
+
+.PHONY: installcheck indent
+
+installcheck: $(PYTEST)
+	$(PYTEST) -v -rs
+
+indent: $(ISORT) $(BLACK)
+	$(ISORT) --profile black *.py client/*.py server/*.py
+	$(BLACK) *.py client/*.py server/*.py
+
+$(PYTEST) $(ISORT) $(BLACK) &: requirements.txt | $(PIP)
+	$(PIP) install --force-reinstall -r $<
+
+$(PIP):
+	$(PYTHON3) -m venv $(VENV)
+
+# A convenience recipe to rebuild psycopg2 against the local libpq.
+.PHONY: rebuild-psycopg2
+rebuild-psycopg2: | $(PIP)
+	$(PIP) install --force-reinstall --no-binary :all: $(shell grep psycopg2 requirements.txt)
diff --git a/src/test/python/README b/src/test/python/README
new file mode 100644
index 00000000000..acf339a5899
--- /dev/null
+++ b/src/test/python/README
@@ -0,0 +1,66 @@
+A test suite for exercising both the libpq client and the server backend at the
+protocol level, based on pytest and Construct.
+
+WARNING! This suite takes superuser-level control of the cluster under test,
+writing to the server config, creating and destroying databases, etc. It also
+spins up various ephemeral TCP services. This is not safe for production servers
+and therefore must be explicitly opted into by setting PG_TEST_EXTRA=python in
+the environment.
+
+The test suite currently assumes that the standard PG* environment variables
+point to the database under test and are sufficient to log in a superuser on
+that system. In other words, a bare `psql` needs to Just Work before the test
+suite can do its thing. For a newly built dev cluster, typically all that I need
+to do is a
+
+    export PGDATABASE=postgres
+
+but you can adjust as needed for your setup. See also 'Advanced Usage' below.
+
+## Requirements
+
+A supported version (3.6+) of Python.
+
+The first run of
+
+    make installcheck PG_TEST_EXTRA=python
+
+will install a local virtual environment and all needed dependencies. During
+development, if libpq changes incompatibly, you can issue
+
+    $ make rebuild-psycopg2
+
+to force a rebuild of the client library.
+
+## Hacking
+
+The code style is enforced by a _very_ opinionated autoformatter. Running the
+
+    make indent
+
+recipe will invoke it for you automatically. Don't fight the tool; part of the
+zen is in knowing that if the formatter makes your code ugly, there's probably a
+cleaner way to write your code.
+
+## Advanced Usage
+
+The Makefile is there for convenience, but you don't have to use it. Activate
+the virtualenv to be able to use pytest directly:
+
+    $ export PG_TEST_EXTRA=python
+    $ source venv/bin/activate
+    $ py.test -k oauth
+    ...
+    $ py.test ./server/test_server.py
+    ...
+    $ deactivate  # puts the PATH et al back the way it was before
+
+To make quick smoke tests possible, slow tests have been marked explicitly. You
+can skip them by saying e.g.
+
+    $ py.test -m 'not slow'
+
+If you'd rather not test against an existing server, you can have the suite spin
+up a temporary one using whatever pg_ctl it finds in PATH:
+
+    $ py.test --temp-instance=./tmp_check
diff --git a/src/test/python/client/__init__.py b/src/test/python/client/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/src/test/python/client/conftest.py b/src/test/python/client/conftest.py
new file mode 100644
index 00000000000..20e72a404aa
--- /dev/null
+++ b/src/test/python/client/conftest.py
@@ -0,0 +1,196 @@
+#
+# Copyright 2021 VMware, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import contextlib
+import datetime
+import functools
+import ipaddress
+import os
+import socket
+import sys
+import threading
+
+import psycopg2
+import psycopg2.extras
+import pytest
+from cryptography import x509
+from cryptography.hazmat.primitives import hashes, serialization
+from cryptography.hazmat.primitives.asymmetric import rsa
+from cryptography.x509.oid import NameOID
+
+import pq3
+
+BLOCKING_TIMEOUT = 2  # the number of seconds to wait for blocking calls
+
+
[email protected]
+def server_socket(unused_tcp_port_factory):
+    """
+    Returns a listening socket bound to an ephemeral port.
+    """
+    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+        s.bind(("127.0.0.1", unused_tcp_port_factory()))
+        s.listen(1)
+        s.settimeout(BLOCKING_TIMEOUT)
+        yield s
+
+
+class ClientHandshake(threading.Thread):
+    """
+    A thread that connects to a local Postgres server using psycopg2. Once the
+    opening handshake completes, the connection will be immediately closed.
+    """
+
+    def __init__(self, *, port, **kwargs):
+        super().__init__()
+
+        kwargs["port"] = port
+        self._kwargs = kwargs
+
+        self.exception = None
+
+    def run(self):
+        try:
+            conn = psycopg2.connect(host="127.0.0.1", **self._kwargs)
+            with contextlib.closing(conn):
+                self._pump_async(conn)
+        except Exception as e:
+            self.exception = e
+
+    def check_completed(self, timeout=BLOCKING_TIMEOUT):
+        """
+        Joins the client thread. Raises an exception if the thread could not be
+        joined, or if it threw an exception itself. (The exception will be
+        cleared, so future calls to check_completed will succeed.)
+        """
+        self.join(timeout)
+
+        if self.is_alive():
+            raise TimeoutError("client thread did not handshake within the timeout")
+        elif self.exception:
+            e = self.exception
+            self.exception = None
+            raise e
+
+    def _pump_async(self, conn):
+        """
+        Polls a psycopg2 connection until it's completed. (Synchronous
+        connections will work here too; they'll just immediately return OK.)
+        """
+        psycopg2.extras.wait_select(conn)
+
+
[email protected]
+def accept(server_socket):
+    """
+    Returns a factory function that, when called, returns a pair (sock, client)
+    where sock is a server socket that has accepted a connection from client,
+    and client is an instance of ClientHandshake. Clients will complete their
+    handshakes and cleanly disconnect.
+
+    The default connstring options may be extended or overridden by passing
+    arbitrary keyword arguments. Keep in mind that you generally should not
+    override the host or port, since they point to the local test server.
+
+    For situations where a client needs to connect more than once to complete a
+    handshake, the accept function may be called more than once. (The client
+    returned for subsequent calls will always be the same client that was
+    returned for the first call.)
+
+    Tests must either complete the handshake so that the client thread can be
+    automatically joined during teardown, or else call client.check_completed()
+    and manually handle any expected errors.
+    """
+    _, port = server_socket.getsockname()
+
+    client = None
+    default_opts = dict(
+        port=port,
+        user=pq3.pguser(),
+        sslmode="disable",
+    )
+
+    def factory(**kwargs):
+        nonlocal client
+
+        if client is None:
+            opts = dict(default_opts)
+            opts.update(kwargs)
+
+            # The server_socket is already listening, so the client thread can
+            # be safely started; it'll block on the connection until we accept.
+            client = ClientHandshake(**opts)
+            client.start()
+
+        sock, _ = server_socket.accept()
+        sock.settimeout(BLOCKING_TIMEOUT)
+        return sock, client
+
+    yield factory
+
+    if client is not None:
+        client.check_completed()
+
+
[email protected]
+def conn(accept):
+    """
+    Returns an accepted, wrapped pq3 connection to a psycopg2 client. The socket
+    will be closed when the test finishes, and the client will be checked for a
+    cleanly completed handshake.
+    """
+    sock, client = accept()
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            yield conn
+
+
[email protected](scope="session")
+def certpair(tmp_path_factory):
+    """
+    Yields a (cert, key) pair of file paths that can be used by a TLS server.
+    The certificate is issued for "localhost" and its standard IPv4/6 addresses.
+    """
+
+    tmpdir = tmp_path_factory.mktemp("certs")
+    now = datetime.datetime.now(datetime.timezone.utc)
+
+    # https://cryptography.io/en/latest/x509/tutorial/#creating-a-self-signed-certificate
+    key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
+
+    subject = issuer = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "localhost")])
+    altNames = [
+        x509.DNSName("localhost"),
+        x509.IPAddress(ipaddress.IPv4Address("127.0.0.1")),
+        x509.IPAddress(ipaddress.IPv6Address("::1")),
+    ]
+    cert = (
+        x509.CertificateBuilder()
+        .subject_name(subject)
+        .issuer_name(issuer)
+        .public_key(key.public_key())
+        .serial_number(x509.random_serial_number())
+        .not_valid_before(now)
+        .not_valid_after(now + datetime.timedelta(minutes=10))
+        .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
+        .add_extension(x509.SubjectAlternativeName(altNames), critical=False)
+    ).sign(key, hashes.SHA256())
+
+    # Writing the key with mode 0600 lets us use this from the server side, too.
+    keypath = str(tmpdir / "key.pem")
+    with open(keypath, "wb", opener=functools.partial(os.open, mode=0o600)) as f:
+        f.write(
+            key.private_bytes(
+                encoding=serialization.Encoding.PEM,
+                format=serialization.PrivateFormat.PKCS8,
+                encryption_algorithm=serialization.NoEncryption(),
+            )
+        )
+
+    certpath = str(tmpdir / "cert.pem")
+    with open(certpath, "wb") as f:
+        f.write(cert.public_bytes(serialization.Encoding.PEM))
+
+    return certpath, keypath
diff --git a/src/test/python/client/test_client.py b/src/test/python/client/test_client.py
new file mode 100644
index 00000000000..8372376ede4
--- /dev/null
+++ b/src/test/python/client/test_client.py
@@ -0,0 +1,186 @@
+#
+# Copyright 2021 VMware, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import base64
+import sys
+
+import psycopg2
+import pytest
+from cryptography.hazmat.primitives import hashes, hmac
+
+import pq3
+
+from .test_oauth import alt_patterns
+
+
+def finish_handshake(conn):
+    """
+    Sends the AuthenticationOK message and the standard opening salvo of server
+    messages, then asserts that the client immediately sends a Terminate message
+    to close the connection cleanly.
+    """
+    pq3.send(conn, pq3.types.AuthnRequest, type=pq3.authn.OK)
+    pq3.send(conn, pq3.types.ParameterStatus, name=b"client_encoding", value=b"UTF-8")
+    pq3.send(conn, pq3.types.ParameterStatus, name=b"DateStyle", value=b"ISO, MDY")
+    pq3.send(conn, pq3.types.ReadyForQuery, status=b"I")
+
+    pkt = pq3.recv1(conn)
+    assert pkt.type == pq3.types.Terminate
+
+
+def test_handshake(conn):
+    startup = pq3.recv1(conn, cls=pq3.Startup)
+    assert startup.proto == pq3.protocol(3, 0)
+
+    finish_handshake(conn)
+
+
+def test_aborted_connection(accept):
+    """
+    Make sure the client correctly reports an early close during handshakes.
+    """
+    sock, client = accept()
+    sock.close()
+
+    expected = alt_patterns(
+        "server closed the connection unexpectedly",
+        # On some platforms, ECONNABORTED gets set instead.
+        "Software caused connection abort",
+    )
+    with pytest.raises(psycopg2.OperationalError, match=expected):
+        client.check_completed()
+
+
+#
+# SCRAM-SHA-256 (see RFC 5802: https://tools.ietf.org/html/rfc5802)
+#
+
+
[email protected]
+def password():
+    """
+    Returns a password for use by both client and server.
+    """
+    # TODO: parameterize this with passwords that require SASLprep.
+    return "secret"
+
+
[email protected]
+def pwconn(accept, password):
+    """
+    Like the conn fixture, but uses a password in the connection.
+    """
+    sock, client = accept(password=password)
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            yield conn
+
+
+def sha256(data):
+    """The H(str) function from Section 2.2."""
+    digest = hashes.Hash(hashes.SHA256())
+    digest.update(data)
+    return digest.finalize()
+
+
+def hmac_256(key, data):
+    """The HMAC(key, str) function from Section 2.2."""
+    h = hmac.HMAC(key, hashes.SHA256())
+    h.update(data)
+    return h.finalize()
+
+
+def xor(a, b):
+    """The XOR operation from Section 2.2."""
+    res = bytearray(a)
+    for i, byte in enumerate(b):
+        res[i] ^= byte
+    return bytes(res)
+
+
+def h_i(data, salt, i):
+    """The Hi(str, salt, i) function from Section 2.2."""
+    assert i > 0
+
+    acc = hmac_256(data, salt + b"\x00\x00\x00\x01")
+    last = acc
+    i -= 1
+
+    while i:
+        u = hmac_256(data, last)
+        acc = xor(acc, u)
+
+        last = u
+        i -= 1
+
+    return acc
+
+
+def test_scram(pwconn, password):
+    startup = pq3.recv1(pwconn, cls=pq3.Startup)
+    assert startup.proto == pq3.protocol(3, 0)
+
+    pq3.send(
+        pwconn,
+        pq3.types.AuthnRequest,
+        type=pq3.authn.SASL,
+        body=[b"SCRAM-SHA-256", b""],
+    )
+
+    # Get the client-first-message.
+    pkt = pq3.recv1(pwconn)
+    assert pkt.type == pq3.types.PasswordMessage
+
+    initial = pq3.SASLInitialResponse.parse(pkt.payload)
+    assert initial.name == b"SCRAM-SHA-256"
+
+    c_bind, authzid, c_name, c_nonce = initial.data.split(b",")
+    assert c_bind == b"n"  # no channel bindings on a plaintext connection
+    assert authzid == b""  # we don't support authzid currently
+    assert c_name == b"n="  # libpq doesn't honor the GS2 username
+    assert c_nonce.startswith(b"r=")
+
+    # Send the server-first-message.
+    salt = b"12345"
+    iterations = 2
+
+    s_nonce = c_nonce + b"somenonce"
+    s_salt = b"s=" + base64.b64encode(salt)
+    s_iterations = b"i=%d" % iterations
+
+    msg = b",".join([s_nonce, s_salt, s_iterations])
+    pq3.send(pwconn, pq3.types.AuthnRequest, type=pq3.authn.SASLContinue, body=msg)
+
+    # Get the client-final-message.
+    pkt = pq3.recv1(pwconn)
+    assert pkt.type == pq3.types.PasswordMessage
+
+    c_bind_final, c_nonce_final, c_proof = pkt.payload.split(b",")
+    assert c_bind_final == b"c=" + base64.b64encode(c_bind + b"," + authzid + b",")
+    assert c_nonce_final == s_nonce
+
+    # Calculate what the client proof should be.
+    salted_password = h_i(password.encode("ascii"), salt, iterations)
+    client_key = hmac_256(salted_password, b"Client Key")
+    stored_key = sha256(client_key)
+
+    auth_message = b",".join(
+        [c_name, c_nonce, s_nonce, s_salt, s_iterations, c_bind_final, c_nonce_final]
+    )
+    client_signature = hmac_256(stored_key, auth_message)
+    client_proof = xor(client_key, client_signature)
+
+    expected = b"p=" + base64.b64encode(client_proof)
+    assert c_proof == expected
+
+    # Send the correct server signature.
+    server_key = hmac_256(salted_password, b"Server Key")
+    server_signature = hmac_256(server_key, auth_message)
+
+    s_verify = b"v=" + base64.b64encode(server_signature)
+    pq3.send(pwconn, pq3.types.AuthnRequest, type=pq3.authn.SASLFinal, body=s_verify)
+
+    # Done!
+    finish_handshake(pwconn)
diff --git a/src/test/python/client/test_oauth.py b/src/test/python/client/test_oauth.py
new file mode 100644
index 00000000000..a3cbafe843e
--- /dev/null
+++ b/src/test/python/client/test_oauth.py
@@ -0,0 +1,2671 @@
+#
+# Copyright 2021 VMware, Inc.
+# Portions Copyright 2023 Timescale, Inc.
+# Portions Copyright 2024 PostgreSQL Global Development Group
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import base64
+import collections
+import contextlib
+import ctypes
+import http.server
+import json
+import logging
+import os
+import platform
+import secrets
+import socket
+import ssl
+import sys
+import threading
+import time
+import traceback
+import types
+import urllib.parse
+from numbers import Number
+
+import psycopg2
+import pytest
+
+import pq3
+
+from .conftest import BLOCKING_TIMEOUT
+
+# The client tests need libpq to have been compiled with OAuth support; skip
+# them otherwise.
+pytestmark = pytest.mark.skipif(
+    os.getenv("with_libcurl") != "yes",
+    reason="OAuth client tests require --with-libcurl support",
+)
+
+if platform.system() == "Darwin":
+    libpq = ctypes.cdll.LoadLibrary("libpq.5.dylib")
+elif platform.system() == "Windows":
+    pass  # TODO
+else:
+    libpq = ctypes.cdll.LoadLibrary("libpq.so.5")
+
+
+def finish_handshake(conn):
+    """
+    Sends the AuthenticationOK message and the standard opening salvo of server
+    messages, then asserts that the client immediately sends a Terminate message
+    to close the connection cleanly.
+    """
+    pq3.send(conn, pq3.types.AuthnRequest, type=pq3.authn.OK)
+    pq3.send(conn, pq3.types.ParameterStatus, name=b"client_encoding", value=b"UTF-8")
+    pq3.send(conn, pq3.types.ParameterStatus, name=b"DateStyle", value=b"ISO, MDY")
+    pq3.send(conn, pq3.types.ReadyForQuery, status=b"I")
+
+    pkt = pq3.recv1(conn)
+    assert pkt.type == pq3.types.Terminate
+
+
+#
+# OAUTHBEARER (see RFC 7628: https://tools.ietf.org/html/rfc7628)
+#
+
+
+def start_oauth_handshake(conn):
+    """
+    Negotiates an OAUTHBEARER SASL challenge. Returns the client's initial
+    response data.
+    """
+    startup = pq3.recv1(conn, cls=pq3.Startup)
+    assert startup.proto == pq3.protocol(3, 0)
+
+    pq3.send(
+        conn, pq3.types.AuthnRequest, type=pq3.authn.SASL, body=[b"OAUTHBEARER", b""]
+    )
+
+    pkt = pq3.recv1(conn)
+    assert pkt.type == pq3.types.PasswordMessage
+
+    initial = pq3.SASLInitialResponse.parse(pkt.payload)
+    assert initial.name == b"OAUTHBEARER"
+
+    return initial.data
+
+
+def get_auth_value(initial):
+    """
+    Finds the auth value (e.g. "Bearer somedata..." in the client's initial SASL
+    response.
+    """
+    kvpairs = initial.split(b"\x01")
+    assert kvpairs[0] == b"n,,"  # no channel binding or authzid
+    assert kvpairs[2] == b""  # ends with an empty kvpair
+    assert kvpairs[3] == b""  # ...and there's nothing after it
+    assert len(kvpairs) == 4
+
+    key, value = kvpairs[1].split(b"=", 2)
+    assert key == b"auth"
+
+    return value
+
+
+def fail_oauth_handshake(conn, sasl_resp, *, errmsg="doesn't matter"):
+    """
+    Sends a failure response via the OAUTHBEARER mechanism, consumes the
+    client's dummy response, and issues a FATAL error to end the exchange.
+
+    sasl_resp is a dictionary which will be serialized as the OAUTHBEARER JSON
+    response. If provided, errmsg is used in the FATAL ErrorResponse.
+    """
+    resp = json.dumps(sasl_resp)
+    pq3.send(
+        conn,
+        pq3.types.AuthnRequest,
+        type=pq3.authn.SASLContinue,
+        body=resp.encode("utf-8"),
+    )
+
+    # Per RFC, the client is required to send a dummy ^A response.
+    pkt = pq3.recv1(conn)
+    assert pkt.type == pq3.types.PasswordMessage
+    assert pkt.payload == b"\x01"
+
+    # Now fail the SASL exchange.
+    pq3.send(
+        conn,
+        pq3.types.ErrorResponse,
+        fields=[
+            b"SFATAL",
+            b"C28000",
+            b"M" + errmsg.encode("utf-8"),
+            b"",
+        ],
+    )
+
+
+def handle_discovery_connection(sock, discovery=None, *, response=None):
+    """
+    Helper for all tests that expect an initial discovery connection from the
+    client. The provided discovery URI will be used in a standard error response
+    from the server (or response may be set, to provide a custom dictionary),
+    and the SASL exchange will be failed.
+
+    By default, the client is expected to complete the entire handshake. Set
+    finish to False if the client should immediately disconnect when it receives
+    the error response.
+    """
+    if response is None:
+        response = {"status": "invalid_token"}
+        if discovery is not None:
+            response["openid-configuration"] = discovery
+
+    with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+        # Initiate a handshake.
+        initial = start_oauth_handshake(conn)
+
+        # For discovery, the client should send an empty auth header. See RFC
+        # 7628, Sec. 4.3.
+        auth = get_auth_value(initial)
+        assert auth == b""
+
+        # The discovery handshake is doomed to fail.
+        fail_oauth_handshake(conn, response)
+
+
+class RawResponse(str):
+    """
+    Returned by registered endpoint callbacks to take full control of the
+    response. Usually, return values are converted to JSON; a RawResponse body
+    will be passed to the client as-is, allowing endpoint implementations to
+    issue invalid JSON.
+    """
+
+    pass
+
+
+class RawBytes(bytes):
+    """
+    Like RawResponse, but bypasses the UTF-8 encoding step as well, allowing
+    implementations to issue invalid encodings.
+    """
+
+    pass
+
+
+class OpenIDProvider(threading.Thread):
+    """
+    A thread that runs a mock OpenID provider server on an SSL-enabled socket.
+    """
+
+    def __init__(self, ssl_socket):
+        super().__init__()
+
+        self.exception = None
+
+        _, port = ssl_socket.getsockname()
+
+        oauth = self._OAuthState()
+        oauth.host = f"localhost:{port}"
+        oauth.issuer = f"https://localhost:{port}"
+
+        # The following endpoints are required to be advertised by providers,
+        # even though our chosen client implementation does not actually make
+        # use of them.
+        oauth.register_endpoint(
+            "authorization_endpoint", "POST", "/authorize", self._authorization_handler
+        )
+        oauth.register_endpoint("jwks_uri", "GET", "/keys", self._jwks_handler)
+
+        self.server = self._HTTPSServer(ssl_socket, self._Handler)
+        self.server.oauth = oauth
+
+    def run(self):
+        try:
+            # XXX socketserver.serve_forever() has a serious architectural
+            # issue: its select loop wakes up every `poll_interval` seconds to
+            # see if the server is shutting down. The default, 500 ms, only lets
+            # us run two tests every second. But the faster we go, the more CPU
+            # we burn unnecessarily...
+            self.server.serve_forever(poll_interval=0.01)
+        except Exception as e:
+            self.exception = e
+
+    def stop(self, timeout=BLOCKING_TIMEOUT):
+        """
+        Shuts down the server and joins its thread. Raises an exception if the
+        thread could not be joined, or if it threw an exception itself. Must
+        only be called once, after start().
+        """
+        self.server.shutdown()
+        self.join(timeout)
+
+        if self.is_alive():
+            raise TimeoutError("client thread did not handshake within the timeout")
+        elif self.exception:
+            e = self.exception
+            raise e
+
+    class _OAuthState(object):
+        def __init__(self):
+            self.endpoint_paths = {}
+            self._endpoints = {}
+
+            # Provide a standard discovery document by default; tests can
+            # override it.
+            self.register_endpoint(
+                None,
+                "GET",
+                "/.well-known/openid-configuration",
+                self._default_discovery_handler,
+            )
+
+            # Default content type unless overridden.
+            self.content_type = "application/json"
+
+        @property
+        def discovery_uri(self):
+            return f"{self.issuer}/.well-known/openid-configuration"
+
+        def register_endpoint(self, name, method, path, func):
+            if method not in self._endpoints:
+                self._endpoints[method] = {}
+
+            self._endpoints[method][path] = func
+
+            if name is not None:
+                self.endpoint_paths[name] = path
+
+        def endpoint(self, method, path):
+            if method not in self._endpoints:
+                return None
+
+            return self._endpoints[method].get(path)
+
+        def _default_discovery_handler(self, headers, params):
+            doc = {
+                "issuer": self.issuer,
+                "response_types_supported": ["token"],
+                "subject_types_supported": ["public"],
+                "id_token_signing_alg_values_supported": ["RS256"],
+                "grant_types_supported": [
+                    "authorization_code",
+                    "urn:ietf:params:oauth:grant-type:device_code",
+                ],
+            }
+
+            for name, path in self.endpoint_paths.items():
+                doc[name] = self.issuer + path
+
+            return 200, doc
+
+    class _HTTPSServer(http.server.HTTPServer):
+        def __init__(self, ssl_socket, handler_cls):
+            # Attach the SSL socket to the server. We don't bind/activate since
+            # the socket is already listening.
+            super().__init__(None, handler_cls, bind_and_activate=False)
+            self.socket = ssl_socket
+            self.server_address = self.socket.getsockname()
+
+        def shutdown_request(self, request):
+            # Cleanly unwrap the SSL socket before shutting down the connection;
+            # otherwise careful clients will complain about truncation.
+            try:
+                request = request.unwrap()
+            except (ssl.SSLEOFError, ConnectionResetError, BrokenPipeError):
+                # The client already closed (or aborted) the connection without
+                # a clean shutdown. This is seen on some platforms during tests
+                # that break the HTTP protocol. Just return and have the server
+                # close the socket.
+                return
+            except ssl.SSLError as err:
+                # FIXME OpenSSL 3.4 introduced an incompatibility with Python's
+                # TLS error handling, resulting in a bogus "[SYS] unknown error"
+                # on some platforms. Hopefully this is fixed in 2025's set of
+                # maintenance releases and this case can be removed.
+                #
+                #     https://github.com/python/cpython/issues/127257
+                #
+                if "[SYS] unknown error" in str(err):
+                    return
+                raise
+
+            super().shutdown_request(request)
+
+        def handle_error(self, request, addr):
+            self.shutdown_request(request)
+            raise
+
+    @staticmethod
+    def _jwks_handler(headers, params):
+        return 200, {"keys": []}
+
+    @staticmethod
+    def _authorization_handler(headers, params):
+        # We don't actually want this to be called during these tests -- we
+        # should be using the device authorization endpoint instead.
+        assert (
+            False
+        ), "authorization handler called instead of device authorization handler"
+
+    class _Handler(http.server.BaseHTTPRequestHandler):
+        timeout = BLOCKING_TIMEOUT
+
+        def _handle(self, *, params=None, handler=None):
+            oauth = self.server.oauth
+            assert self.headers["Host"] == oauth.host
+
+            # XXX: BaseHTTPRequestHandler collapses leading slashes in the path
+            # to work around an open redirection vuln (gh-87389) in
+            # SimpleHTTPServer. But we're not using SimpleHTTPServer, and we
+            # want to test repeating leading slashes, so that's not very
+            # helpful. Put them back.
+            orig_path = self.raw_requestline.split()[1]
+            orig_path = str(orig_path, "iso-8859-1")
+            assert orig_path.endswith(self.path)  # sanity check
+            self.path = orig_path
+
+            if handler is None:
+                handler = oauth.endpoint(self.command, self.path)
+                assert (
+                    handler is not None
+                ), f"no registered endpoint for {self.command} {self.path}"
+
+            result = handler(self.headers, params)
+
+            if len(result) == 2:
+                headers = {"Content-Type": oauth.content_type}
+                code, resp = result
+            else:
+                code, headers, resp = result
+
+            self.send_response(code)
+            for h, v in headers.items():
+                self.send_header(h, v)
+            self.end_headers()
+
+            if resp is not None:
+                if not isinstance(resp, RawBytes):
+                    if not isinstance(resp, RawResponse):
+                        resp = json.dumps(resp)
+                    resp = resp.encode("utf-8")
+                self.wfile.write(resp)
+
+            self.close_connection = True
+
+        def do_GET(self):
+            self._handle()
+
+        def _request_body(self):
+            length = self.headers["Content-Length"]
+
+            # Handle only an explicit content-length.
+            assert length is not None
+            length = int(length)
+
+            return self.rfile.read(length).decode("utf-8")
+
+        def do_POST(self):
+            assert self.headers["Content-Type"] == "application/x-www-form-urlencoded"
+
+            body = self._request_body()
+            if body:
+                # parse_qs() is understandably fairly lax when it comes to
+                # acceptable characters, but we're stricter. Spaces must be
+                # encoded, and they must use the '+' encoding rather than "%20".
+                assert " " not in body
+                assert "%20" not in body
+
+                params = urllib.parse.parse_qs(
+                    body,
+                    keep_blank_values=True,
+                    strict_parsing=True,
+                    encoding="utf-8",
+                    errors="strict",
+                )
+            else:
+                params = {}
+
+            self._handle(params=params)
+
+
[email protected](autouse=True)
+def enable_client_oauth_debugging(monkeypatch):
+    """
+    HTTP providers aren't allowed by default; enable them via envvar.
+    """
+    monkeypatch.setenv("PGOAUTHDEBUG", "UNSAFE")
+
+
[email protected](autouse=True)
+def trust_certpair_in_client(monkeypatch, certpair):
+    """
+    Set a trusted CA file for OAuth client connections.
+    """
+    monkeypatch.setenv("PGOAUTHCAFILE", certpair[0])
+
+
[email protected](scope="session")
+def ssl_socket(certpair):
+    """
+    A listening server-side socket for SSL connections, using the certpair
+    fixture.
+    """
+    sock = socket.create_server(("", 0))
+
+    # The TLS connections we're making are incredibly sensitive to delayed ACKs
+    # from the client. (Without TCP_NODELAY, test performance degrades 4-5x.)
+    sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
+
+    with contextlib.closing(sock):
+        # Wrap the server socket for TLS.
+        ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
+        ctx.load_cert_chain(*certpair)
+
+        yield ctx.wrap_socket(sock, server_side=True)
+
+
[email protected]
+def openid_provider(ssl_socket):
+    """
+    A fixture that returns the OAuth state of a running OpenID provider server. The
+    server will be stopped when the fixture is torn down.
+    """
+    thread = OpenIDProvider(ssl_socket)
+    thread.start()
+
+    try:
+        yield thread.server.oauth
+    finally:
+        thread.stop()
+
+
+#
+# PQAuthDataHook implementation, matching libpq.h
+#
+
+
+PQAUTHDATA_PROMPT_OAUTH_DEVICE = 0
+PQAUTHDATA_OAUTH_BEARER_TOKEN = 1
+
+PGRES_POLLING_FAILED = 0
+PGRES_POLLING_READING = 1
+PGRES_POLLING_WRITING = 2
+PGRES_POLLING_OK = 3
+
+
+class PGPromptOAuthDevice(ctypes.Structure):
+    _fields_ = [
+        ("verification_uri", ctypes.c_char_p),
+        ("user_code", ctypes.c_char_p),
+    ]
+
+
+class PGOAuthBearerRequest(ctypes.Structure):
+    pass
+
+
+PGOAuthBearerRequest._fields_ = [
+    ("openid_configuration", ctypes.c_char_p),
+    ("scope", ctypes.c_char_p),
+    (
+        "async_",
+        ctypes.CFUNCTYPE(
+            ctypes.c_int,
+            ctypes.c_void_p,
+            ctypes.POINTER(PGOAuthBearerRequest),
+            ctypes.POINTER(ctypes.c_int),
+        ),
+    ),
+    (
+        "cleanup",
+        ctypes.CFUNCTYPE(None, ctypes.c_void_p, ctypes.POINTER(PGOAuthBearerRequest)),
+    ),
+    ("token", ctypes.c_char_p),
+    ("user", ctypes.c_void_p),
+]
+
+
[email protected]
+def auth_data_cb():
+    """
+    Tracks calls to the libpq authdata hook. The yielded object contains a calls
+    member that records the data sent to the hook. If a test needs to perform
+    custom actions during a call, it can set the yielded object's impl callback;
+    beware that the callback takes place on a different thread.
+
+    This is done differently from the other callback implementations on purpose.
+    For the others, we can declare test-specific callbacks and have them perform
+    direct assertions on the data they receive. But that won't work for a C
+    callback, because there's no way for us to bubble up the assertion through
+    libpq. Instead, this mock-style approach is taken, where we just record the
+    calls and let the test examine them later.
+    """
+
+    class _Call:
+        pass
+
+    class _cb(object):
+        def __init__(self):
+            self.calls = []
+
+    cb = _cb()
+    cb.impl = None
+
+    # The callback will occur on a different thread, so protect the cb object.
+    cb_lock = threading.Lock()
+
+    @ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_byte, ctypes.c_void_p, ctypes.c_void_p)
+    def auth_data_cb(typ, pgconn, data):
+        handle_by_default = 0  # does an implementation have to be provided?
+
+        if typ == PQAUTHDATA_PROMPT_OAUTH_DEVICE:
+            cls = PGPromptOAuthDevice
+            handle_by_default = 1
+        elif typ == PQAUTHDATA_OAUTH_BEARER_TOKEN:
+            cls = PGOAuthBearerRequest
+        else:
+            return 0
+
+        call = _Call()
+        call.type = typ
+
+        # The lifetime of the underlying data being pointed to doesn't
+        # necessarily match the lifetime of the Python object, so we can't
+        # reference a Structure's fields after returning. Explicitly copy the
+        # contents over, field by field.
+        data = ctypes.cast(data, ctypes.POINTER(cls))
+        for name, _ in cls._fields_:
+            setattr(call, name, getattr(data.contents, name))
+
+        with cb_lock:
+            cb.calls.append(call)
+
+        if cb.impl:
+            # Pass control back to the test.
+            try:
+                return cb.impl(typ, pgconn, data.contents)
+            except Exception:
+                # This can't escape into the C stack, but we can fail the flow
+                # and hope the traceback gives us enough detail.
+                logging.error(
+                    "Exception during authdata hook callback:\n"
+                    + traceback.format_exc()
+                )
+                return -1
+
+        return handle_by_default
+
+    libpq.PQsetAuthDataHook(auth_data_cb)
+    try:
+        yield cb
+    finally:
+        # The callback is about to go out of scope, so make sure libpq is
+        # disconnected from it. (We wouldn't want to accidentally influence
+        # later tests anyway.)
+        libpq.PQsetAuthDataHook(None)
+
+
[email protected](
+    "success, abnormal_failure",
+    [
+        pytest.param(True, False, id="success"),
+        pytest.param(False, False, id="normal failure"),
+        pytest.param(False, True, id="abnormal failure"),
+    ],
+)
[email protected]("secret", [None, "", "hunter2"])
[email protected]("scope", [None, "", "openid email"])
[email protected]("retries", [0, 1])
[email protected](
+    "content_type",
+    [
+        pytest.param("application/json", id="standard"),
+        pytest.param("application/json;charset=utf-8", id="charset"),
+        pytest.param("application/json \t;\t charset=utf-8", id="charset (whitespace)"),
+    ],
+)
[email protected]("uri_spelling", ["verification_url", "verification_uri"])
[email protected](
+    "asynchronous",
+    [
+        pytest.param(False, id="synchronous"),
+        pytest.param(True, id="asynchronous"),
+    ],
+)
+def test_oauth_with_explicit_discovery_uri(
+    accept,
+    openid_provider,
+    asynchronous,
+    uri_spelling,
+    content_type,
+    retries,
+    scope,
+    secret,
+    auth_data_cb,
+    success,
+    abnormal_failure,
+):
+    client_id = secrets.token_hex()
+    openid_provider.content_type = content_type
+
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id=client_id,
+        oauth_client_secret=secret,
+        oauth_scope=scope,
+        async_=asynchronous,
+    )
+
+    device_code = secrets.token_hex()
+    user_code = f"{secrets.token_hex(2)}-{secrets.token_hex(2)}"
+    verification_url = "https://example.com/device"
+
+    access_token = secrets.token_urlsafe()
+
+    def check_client_authn(headers, params):
+        if secret is None:
+            assert "Authorization" not in headers
+            assert params["client_id"] == [client_id]
+            return
+
+        # Require the client to use Basic authn; request-body credentials are
+        # NOT RECOMMENDED (RFC 6749, Sec. 2.3.1).
+        assert "Authorization" in headers
+        assert "client_id" not in params
+
+        method, creds = headers["Authorization"].split()
+        assert method == "Basic"
+
+        expected = f"{client_id}:{secret}"
+        assert base64.b64decode(creds) == expected.encode("ascii")
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        check_client_authn(headers, params)
+
+        if scope:
+            assert params["scope"] == [scope]
+        else:
+            assert "scope" not in params
+
+        resp = {
+            "device_code": device_code,
+            "user_code": user_code,
+            "interval": 0,
+            uri_spelling: verification_url,
+            "expires_in": 5,
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    attempts = 0
+    retry_lock = threading.Lock()
+
+    def token_endpoint(headers, params):
+        check_client_authn(headers, params)
+
+        assert params["grant_type"] == ["urn:ietf:params:oauth:grant-type:device_code"]
+        assert params["device_code"] == [device_code]
+
+        now = time.monotonic()
+
+        with retry_lock:
+            nonlocal attempts
+
+            # If the test wants to force the client to retry, return an
+            # authorization_pending response and decrement the retry count.
+            if attempts < retries:
+                attempts += 1
+                return 400, {"error": "authorization_pending"}
+
+        # Successfully finish the request by sending the access bearer token.
+        resp = {
+            "access_token": access_token,
+            "token_type": "bearer",
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    # First connection is a discovery request, which should result in the above
+    # endpoints being called.
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    # Client should reconnect.
+    sock, _ = accept()
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            initial = start_oauth_handshake(conn)
+
+            # Validate and accept the token.
+            auth = get_auth_value(initial)
+            assert auth == f"Bearer {access_token}".encode("ascii")
+
+            if success:
+                finish_handshake(conn)
+
+            elif abnormal_failure:
+                # Send an empty error response, which should result in a
+                # mechanism-level failure in the client. This test ensures that
+                # the client doesn't try a third connection for this case.
+                expected_error = "server sent error response without a status"
+                fail_oauth_handshake(conn, {})
+
+            else:
+                # Simulate token validation failure.
+                resp = {
+                    "status": "invalid_token",
+                    "openid-configuration": openid_provider.discovery_uri,
+                }
+                expected_error = "test token validation failure"
+                fail_oauth_handshake(conn, resp, errmsg=expected_error)
+
+    if retries:
+        # Finally, make sure that the client prompted the user once with the
+        # expected authorization URL and user code.
+        assert len(auth_data_cb.calls) == 2
+
+        # First call should have been for a custom flow, which we ignored.
+        assert auth_data_cb.calls[0].type == PQAUTHDATA_OAUTH_BEARER_TOKEN
+
+        # Second call is for our user prompt.
+        call = auth_data_cb.calls[1]
+        assert call.type == PQAUTHDATA_PROMPT_OAUTH_DEVICE
+        assert call.verification_uri.decode() == verification_url
+        assert call.user_code.decode() == user_code
+
+    if not success:
+        # The client should not try to connect again.
+        with pytest.raises(psycopg2.OperationalError, match=expected_error):
+            client.check_completed()
+
+
[email protected](
+    "server_discovery",
+    [
+        pytest.param(True, id="server discovery"),
+        pytest.param(False, id="direct discovery"),
+    ],
+)
[email protected](
+    "issuer, path",
+    [
+        pytest.param(
+            "{issuer}",
+            "/.well-known/oauth-authorization-server",
+            id="oauth",
+        ),
+        pytest.param(
+            "{issuer}/alt",
+            "/.well-known/oauth-authorization-server/alt",
+            id="oauth with path, IETF style",
+        ),
+        pytest.param(
+            "{issuer}/alt",
+            "/alt/.well-known/oauth-authorization-server",
+            id="oauth with path, broken OIDC style",
+        ),
+        pytest.param(
+            "{issuer}/alt",
+            "/alt/.well-known/openid-configuration",
+            id="openid with path, OIDC style",
+        ),
+        pytest.param(
+            "{issuer}/alt",
+            "/.well-known/openid-configuration/alt",
+            id="openid with path, IETF style",
+        ),
+        pytest.param(
+            "{issuer}/",
+            "//.well-known/openid-configuration",
+            id="empty path segment, OIDC style",
+        ),
+        pytest.param(
+            "{issuer}/",
+            "/.well-known/openid-configuration/",
+            id="empty path segment, IETF style",
+        ),
+    ],
+)
+def test_alternate_well_known_paths(
+    accept, openid_provider, issuer, path, server_discovery
+):
+    issuer = issuer.format(issuer=openid_provider.issuer)
+    discovery_uri = openid_provider.issuer + path
+
+    client_id = secrets.token_hex()
+    access_token = secrets.token_urlsafe()
+
+    def discovery_handler(*args):
+        """
+        Pass-through implementation of the discovery handler. Modifies the
+        default document to contain this test's issuer identifier.
+        """
+        code, doc = openid_provider._default_discovery_handler(*args)
+        doc["issuer"] = issuer
+        return code, doc
+
+    openid_provider.register_endpoint(None, "GET", path, discovery_handler)
+
+    def authorization_endpoint(headers, params):
+        resp = {
+            "device_code": "12345",
+            "user_code": "ABCDE",
+            "interval": 0,
+            "verification_url": "https://example.com/device",
+            "expires_in": 5,
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    def token_endpoint(headers, params):
+        # Successfully finish the request by sending the access bearer token.
+        resp = {
+            "access_token": access_token,
+            "token_type": "bearer",
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    kwargs = dict(oauth_client_id=client_id)
+    if server_discovery:
+        kwargs.update(oauth_issuer=issuer)
+    else:
+        kwargs.update(oauth_issuer=discovery_uri)
+
+    sock, client = accept(**kwargs)
+
+    with sock:
+        handle_discovery_connection(sock, discovery_uri)
+
+    # Expect the client to connect again.
+    sock, _ = accept()
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            initial = start_oauth_handshake(conn)
+
+            # Validate the token.
+            auth = get_auth_value(initial)
+            assert auth == f"Bearer {access_token}".encode("ascii")
+
+            finish_handshake(conn)
+
+
[email protected](
+    "server_discovery",
+    [
+        pytest.param(True, id="server discovery"),
+        pytest.param(False, id="direct discovery"),
+    ],
+)
[email protected](
+    "issuer, path, expected_error",
+    [
+        pytest.param(
+            "{issuer}",
+            "/.well-known/oauth-authorization-server/",
+            None,
+            id="extra empty segment (no path)",
+        ),
+        pytest.param(
+            "{issuer}/path",
+            "/.well-known/oauth-authorization-server/path/",
+            None,
+            id="extra empty segment (with path)",
+        ),
+        pytest.param(
+            "{issuer}",
+            "?/.well-known/oauth-authorization-server",
+            r'OAuth discovery URI ".*" must not contain query or fragment components',
+            id="query",
+        ),
+        pytest.param(
+            "{issuer}",
+            "#/.well-known/oauth-authorization-server",
+            r'OAuth discovery URI ".*" must not contain query or fragment components',
+            id="fragment",
+        ),
+        pytest.param(
+            "{issuer}/sub/path",
+            "/sub/.well-known/oauth-authorization-server/path",
+            r'OAuth discovery URI ".*" uses an invalid format',
+            id="sandwiched prefix",
+        ),
+        pytest.param(
+            "{issuer}/path",
+            "/path/openid-configuration",
+            r'OAuth discovery URI ".*" is not a .well-known URI',
+            id="not .well-known",
+        ),
+        pytest.param(
+            "{issuer}",
+            "https://.well-known/oauth-authorization-server",
+            r'OAuth discovery URI ".*" is not a .well-known URI',
+            id=".well-known prefix buried in the authority",
+        ),
+        pytest.param(
+            "{issuer}",
+            "/.well-known/oauth-protected-resource",
+            r'OAuth discovery URI ".*" uses an unsupported .well-known suffix',
+            id="unknown well-known suffix",
+        ),
+        pytest.param(
+            "{issuer}/path",
+            "/path/.well-known/openid-configuration-2",
+            r'OAuth discovery URI ".*" uses an unsupported .well-known suffix',
+            id="unknown well-known suffix, OIDC style",
+        ),
+        pytest.param(
+            "{issuer}/path",
+            "/.well-known/oauth-authorization-server-2/path",
+            r'OAuth discovery URI ".*" uses an unsupported .well-known suffix',
+            id="unknown well-known suffix, IETF style",
+        ),
+        pytest.param(
+            "{issuer}",
+            "file:///.well-known/oauth-authorization-server",
+            r'OAuth discovery URI ".*" must use HTTPS',
+            id="unsupported scheme",
+        ),
+    ],
+)
+def test_bad_well_known_paths(
+    accept, openid_provider, issuer, path, expected_error, server_discovery
+):
+    if not server_discovery and "/.well-known/" not in path:
+        # An oauth_issuer without a /.well-known/ path segment is just a normal
+        # issuer identifier, so this isn't an interesting test.
+        pytest.skip("not interesting: direct discovery requires .well-known")
+
+    issuer = issuer.format(issuer=openid_provider.issuer)
+    discovery_uri = urllib.parse.urljoin(openid_provider.issuer, path)
+
+    client_id = secrets.token_hex()
+
+    def discovery_handler(*args):
+        """
+        Pass-through implementation of the discovery handler. Modifies the
+        default document to contain this test's issuer identifier.
+        """
+        code, doc = openid_provider._default_discovery_handler(*args)
+        doc["issuer"] = issuer
+        return code, doc
+
+    openid_provider.register_endpoint(None, "GET", path, discovery_handler)
+
+    def fail(*args):
+        """
+        No other endpoints should be contacted; fail if the client tries.
+        """
+        assert False, "endpoint unexpectedly called"
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", fail
+    )
+    openid_provider.register_endpoint("token_endpoint", "POST", "/token", fail)
+
+    kwargs = dict(oauth_client_id=client_id)
+    if server_discovery:
+        kwargs.update(oauth_issuer=issuer)
+    else:
+        kwargs.update(oauth_issuer=discovery_uri)
+
+    sock, client = accept(**kwargs)
+    with sock:
+        if expected_error and not server_discovery:
+            # If the client already knows the URL, it should disconnect as soon
+            # as it realizes it's not valid.
+            expect_disconnected_handshake(sock)
+        else:
+            # Otherwise, it should complete the connection.
+            handle_discovery_connection(sock, discovery_uri)
+
+    # The client should not reconnect.
+
+    if expected_error is None:
+        if server_discovery:
+            expected_error = rf"server's discovery document at {discovery_uri} \(issuer \".*\"\) is incompatible with oauth_issuer \({issuer}\)"
+        else:
+            expected_error = rf"the issuer identifier \({issuer}\) does not match oauth_issuer \(.*\)"
+
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
+def expect_disconnected_handshake(sock):
+    """
+    Helper for any tests that expect the client to disconnect immediately after
+    being sent the OAUTHBEARER SASL method. Generally speaking, this requires
+    the client to have an oauth_issuer set so that it doesn't try to go through
+    discovery.
+    """
+    with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+        # Initiate a handshake.
+        startup = pq3.recv1(conn, cls=pq3.Startup)
+        assert startup.proto == pq3.protocol(3, 0)
+
+        pq3.send(
+            conn,
+            pq3.types.AuthnRequest,
+            type=pq3.authn.SASL,
+            body=[b"OAUTHBEARER", b""],
+        )
+
+        # The client should disconnect at this point.
+        assert not conn.read(1), "client sent unexpected data"
+
+
[email protected](
+    "missing",
+    [
+        pytest.param(["oauth_issuer"], id="missing oauth_issuer"),
+        pytest.param(["oauth_client_id"], id="missing oauth_client_id"),
+        pytest.param(["oauth_client_id", "oauth_issuer"], id="missing both"),
+    ],
+)
+def test_oauth_requires_issuer_and_client_id(accept, openid_provider, missing):
+    params = dict(
+        oauth_issuer=openid_provider.issuer,
+        oauth_client_id="some-id",
+    )
+
+    # Remove required parameters. This should cause a client error after the
+    # server asks for OAUTHBEARER and the client tries to contact the issuer.
+    for k in missing:
+        del params[k]
+
+    sock, client = accept(**params)
+    with sock:
+        expect_disconnected_handshake(sock)
+
+    expected_error = "oauth_issuer and oauth_client_id are not both set"
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
+# See https://datatracker.ietf.org/doc/html/rfc6749#appendix-A for character
+# class definitions.
+all_vschars = "".join([chr(c) for c in range(0x20, 0x7F)])
+all_nqchars = "".join([chr(c) for c in range(0x21, 0x7F) if c not in (0x22, 0x5C)])
+
+
[email protected]("client_id", ["", ":", " + ", r'+=&"\/~', all_vschars])
[email protected]("secret", [None, "", ":", " + ", r'+=&"\/~', all_vschars])
[email protected]("device_code", ["", " + ", r'+=&"\/~', all_vschars])
[email protected]("scope", ["&", r"+=&/", all_nqchars])
+def test_url_encoding(accept, openid_provider, client_id, secret, device_code, scope):
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id=client_id,
+        oauth_client_secret=secret,
+        oauth_scope=scope,
+    )
+
+    user_code = f"{secrets.token_hex(2)}-{secrets.token_hex(2)}"
+    verification_url = "https://example.com/device"
+
+    access_token = secrets.token_urlsafe()
+
+    def check_client_authn(headers, params):
+        if secret is None:
+            assert "Authorization" not in headers
+            assert params["client_id"] == [client_id]
+            return
+
+        # Require the client to use Basic authn; request-body credentials are
+        # NOT RECOMMENDED (RFC 6749, Sec. 2.3.1).
+        assert "Authorization" in headers
+        assert "client_id" not in params
+
+        method, creds = headers["Authorization"].split()
+        assert method == "Basic"
+
+        decoded = base64.b64decode(creds).decode("utf-8")
+        username, password = decoded.split(":", 1)
+
+        expected_username = urllib.parse.quote_plus(client_id)
+        expected_password = urllib.parse.quote_plus(secret)
+
+        assert [username, password] == [expected_username, expected_password]
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        check_client_authn(headers, params)
+
+        if scope:
+            assert params["scope"] == [scope]
+        else:
+            assert "scope" not in params
+
+        resp = {
+            "device_code": device_code,
+            "user_code": user_code,
+            "interval": 0,
+            "verification_url": verification_url,
+            "expires_in": 5,
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    def token_endpoint(headers, params):
+        check_client_authn(headers, params)
+
+        assert params["grant_type"] == ["urn:ietf:params:oauth:grant-type:device_code"]
+        assert params["device_code"] == [device_code]
+
+        # Successfully finish the request by sending the access bearer token.
+        resp = {
+            "access_token": access_token,
+            "token_type": "bearer",
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    # First connection is a discovery request, which should result in the above
+    # endpoints being called.
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    # Second connection sends the token.
+    sock, _ = accept()
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            initial = start_oauth_handshake(conn)
+
+            # Validate and accept the token.
+            auth = get_auth_value(initial)
+            assert auth == f"Bearer {access_token}".encode("ascii")
+
+            finish_handshake(conn)
+
+
[email protected]
[email protected]("error_code", ["authorization_pending", "slow_down"])
[email protected]("retries", [1, 2])
[email protected]("omit_interval", [True, False])
+def test_oauth_retry_interval(
+    accept, openid_provider, omit_interval, retries, error_code
+):
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id="some-id",
+    )
+
+    expected_retry_interval = 5 if omit_interval else 1
+    access_token = secrets.token_urlsafe()
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        resp = {
+            "device_code": "my-device-code",
+            "user_code": "my-user-code",
+            "verification_uri": "https://example.com",
+            "expires_in": 5,
+        }
+
+        if not omit_interval:
+            resp["interval"] = expected_retry_interval
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    attempts = 0
+    last_retry = None
+    retry_lock = threading.Lock()
+    token_sent = threading.Event()
+
+    def token_endpoint(headers, params):
+        now = time.monotonic()
+
+        with retry_lock:
+            nonlocal attempts, last_retry, expected_retry_interval
+
+            # Make sure the retry interval is being respected by the client.
+            if last_retry is not None:
+                interval = now - last_retry
+                assert interval >= expected_retry_interval
+
+            last_retry = now
+
+            # If the test wants to force the client to retry, return the desired
+            # error response and decrement the retry count.
+            if attempts < retries:
+                attempts += 1
+
+                # A slow_down code requires the client to additionally increase
+                # its interval by five seconds.
+                if error_code == "slow_down":
+                    expected_retry_interval += 5
+
+                return 400, {"error": error_code}
+
+        # Successfully finish the request by sending the access bearer token,
+        # and signal the main thread to continue.
+        resp = {
+            "access_token": access_token,
+            "token_type": "bearer",
+        }
+        token_sent.set()
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    # First connection is a discovery request, which should result in the above
+    # endpoints being called.
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    # At this point the client is talking to the authorization server. Wait for
+    # that to succeed so we don't run into the accept() timeout.
+    token_sent.wait()
+
+    # Client should reconnect and send the token.
+    sock, _ = accept()
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            initial = start_oauth_handshake(conn)
+
+            # Validate and accept the token.
+            auth = get_auth_value(initial)
+            assert auth == f"Bearer {access_token}".encode("ascii")
+
+            finish_handshake(conn)
+
+
[email protected]
+def self_pipe():
+    """
+    Yields a pipe fd pair.
+    """
+
+    class _Pipe:
+        pass
+
+    p = _Pipe()
+    p.readfd, p.writefd = os.pipe()
+
+    try:
+        yield p
+    finally:
+        os.close(p.readfd)
+        os.close(p.writefd)
+
+
[email protected]("scope", [None, "", "openid email"])
[email protected](
+    "retries",
+    [
+        -1,  # no async callback
+        0,  # async callback immediately returns token
+        1,  # async callback waits on altsock once
+        2,  # async callback waits on altsock twice
+    ],
+)
[email protected](
+    "asynchronous",
+    [
+        pytest.param(False, id="synchronous"),
+        pytest.param(True, id="asynchronous"),
+    ],
+)
+def test_user_defined_flow(
+    accept, auth_data_cb, self_pipe, scope, retries, asynchronous
+):
+    issuer = "http://localhost"
+    discovery_uri = issuer + "/.well-known/openid-configuration"
+    access_token = secrets.token_urlsafe()
+
+    sock, client = accept(
+        oauth_issuer=discovery_uri,
+        oauth_client_id="some-id",
+        oauth_scope=scope,
+        async_=asynchronous,
+    )
+
+    # Track callbacks.
+    attempts = 0
+    wakeup_called = False
+    cleanup_calls = 0
+    lock = threading.Lock()
+
+    def wakeup():
+        """Writes a byte to the wakeup pipe."""
+        nonlocal wakeup_called
+        with lock:
+            wakeup_called = True
+            os.write(self_pipe.writefd, b"\0")
+
+    def get_token(pgconn, request, p_altsock):
+        """
+        Async token callback. While attempts < retries, libpq will be instructed
+        to wait on the self_pipe. When attempts == retries, the token will be
+        set.
+
+        Note that assertions and exceptions raised here are allowed but not very
+        helpful, since they can't bubble through the libpq stack to be collected
+        by the test suite. Try not to rely too heavily on them.
+        """
+        # Make sure libpq passed our user data through.
+        assert request.user == 42
+
+        with lock:
+            nonlocal attempts, wakeup_called
+
+            if attempts:
+                # If we've already started the timer, we shouldn't get a
+                # call back before it trips.
+                assert wakeup_called, "authdata hook was called before the timer"
+
+                # Drain the wakeup byte.
+                os.read(self_pipe.readfd, 1)
+
+            if attempts < retries:
+                attempts += 1
+
+                # Wake up the client in a little bit of time.
+                wakeup_called = False
+                threading.Timer(0.1, wakeup).start()
+
+                # Tell libpq to wait on the other end of the wakeup pipe.
+                p_altsock[0] = self_pipe.readfd
+                return PGRES_POLLING_READING
+
+        # Done!
+        request.token = access_token.encode()
+        return PGRES_POLLING_OK
+
+    @ctypes.CFUNCTYPE(
+        ctypes.c_int,
+        ctypes.c_void_p,
+        ctypes.POINTER(PGOAuthBearerRequest),
+        ctypes.POINTER(ctypes.c_int),
+    )
+    def get_token_wrapper(pgconn, p_request, p_altsock):
+        """
+        Translation layer between C and Python for the async callback.
+        Assertions and exceptions will be swallowed at the boundary, so make
+        sure they don't escape here.
+        """
+        try:
+            return get_token(pgconn, p_request.contents, p_altsock)
+        except Exception:
+            logging.error("Exception during async callback:\n" + traceback.format_exc())
+            return PGRES_POLLING_FAILED
+
+    @ctypes.CFUNCTYPE(None, ctypes.c_void_p, ctypes.POINTER(PGOAuthBearerRequest))
+    def cleanup(pgconn, p_request):
+        """
+        Should be called exactly once per connection.
+        """
+        nonlocal cleanup_calls
+        with lock:
+            cleanup_calls += 1
+
+    def bearer_hook(typ, pgconn, request):
+        """
+        Implementation of the PQAuthDataHook, which either sets up an async
+        callback or returns the token directly, depending on the value of
+        retries.
+
+        As above, try not to rely too much on assertions/exceptions here.
+        """
+        assert typ == PQAUTHDATA_OAUTH_BEARER_TOKEN
+        request.cleanup = cleanup
+
+        if retries < 0:
+            # Special case: return a token immediately without a callback.
+            request.token = access_token.encode()
+            return 1
+
+        # Tell libpq to call us back.
+        request.async_ = get_token_wrapper
+        request.user = ctypes.c_void_p(42)  # will be checked in the callback
+        return 1
+
+    auth_data_cb.impl = bearer_hook
+
+    # Now drive the server side.
+    if retries >= 0:
+        # First connection is a discovery request, which should result in the
+        # hook being invoked.
+        with sock:
+            handle_discovery_connection(sock, discovery_uri)
+
+        # Client should reconnect to send the token.
+        sock, _ = accept()
+
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            # Initiate a handshake, which should result in our custom callback
+            # being invoked to fetch the token.
+            initial = start_oauth_handshake(conn)
+
+            # Validate and accept the token.
+            auth = get_auth_value(initial)
+            assert auth == f"Bearer {access_token}".encode("ascii")
+
+            finish_handshake(conn)
+
+    # Check the data provided to the hook.
+    assert len(auth_data_cb.calls) == 1
+
+    call = auth_data_cb.calls[0]
+    assert call.type == PQAUTHDATA_OAUTH_BEARER_TOKEN
+    assert call.openid_configuration.decode() == discovery_uri
+    assert call.scope == (None if scope is None else scope.encode())
+
+    # Make sure we clean up after ourselves when the connection is finished.
+    client.check_completed()
+    assert cleanup_calls == 1
+
+
+def alt_patterns(*patterns):
+    """
+    Just combines multiple alternative regexes into one. It's not very efficient
+    but IMO it's easier to read and maintain.
+    """
+    pat = ""
+
+    for p in patterns:
+        if pat:
+            pat += "|"
+        pat += f"({p})"
+
+    return pat
+
+
[email protected](
+    "failure_mode, error_pattern",
+    [
+        pytest.param(
+            (
+                401,
+                {
+                    "error": "invalid_client",
+                    "error_description": "client authentication failed",
+                },
+            ),
+            r"failed to obtain device authorization: client authentication failed \(invalid_client\)",
+            id="authentication failure with description",
+        ),
+        pytest.param(
+            (400, {"error": "invalid_request"}),
+            r"failed to obtain device authorization: \(invalid_request\)",
+            id="invalid request without description",
+        ),
+        pytest.param(
+            (400, {"error": "invalid_request", "padding": "x" * 1024 * 1024}),
+            r"failed to obtain device authorization: response is too large",
+            id="gigantic authz response",
+        ),
+        pytest.param(
+            (400, {}),
+            r'failed to parse token error response: field "error" is missing',
+            id="broken error response",
+        ),
+        pytest.param(
+            (401, {"error": "invalid_client"}),
+            r"failed to obtain device authorization: provider requires client authentication, and no oauth_client_secret is set \(invalid_client\)",
+            id="failed authentication without description",
+        ),
+        pytest.param(
+            (200, RawResponse(r'{ "interval": 3.5.8 }')),
+            r"failed to parse device authorization: Token .* is invalid",
+            id="non-numeric interval",
+        ),
+        pytest.param(
+            (200, RawResponse(r'{ "interval": 08 }')),
+            r"failed to parse device authorization: Token .* is invalid",
+            id="invalid numeric interval",
+        ),
+    ],
+)
+def test_oauth_device_authorization_failures(
+    accept, openid_provider, failure_mode, error_pattern
+):
+    client_id = secrets.token_hex()
+
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id=client_id,
+    )
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        return failure_mode
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    def token_endpoint(headers, params):
+        assert False, "token endpoint was invoked unexpectedly"
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    # Now make sure the client correctly failed.
+    with pytest.raises(psycopg2.OperationalError, match=error_pattern):
+        client.check_completed()
+
+
+Missing = object()  # sentinel for test_oauth_device_authorization_bad_json()
+
+
[email protected](
+    "bad_value",
+    [
+        pytest.param({"device_code": 3}, id="object"),
+        pytest.param([1, 2, 3], id="array"),
+        pytest.param("some string", id="string"),
+        pytest.param(4, id="numeric"),
+        pytest.param(False, id="boolean"),
+        pytest.param(None, id="null"),
+        pytest.param(Missing, id="missing"),
+    ],
+)
[email protected](
+    "field_name,ok_type,required",
+    [
+        ("device_code", str, True),
+        ("user_code", str, True),
+        ("verification_uri", str, True),
+        ("interval", int, False),
+    ],
+)
+def test_oauth_device_authorization_bad_json_schema(
+    accept, openid_provider, field_name, ok_type, required, bad_value
+):
+    # To make the test matrix easy, just skip the tests that aren't actually
+    # interesting (field of the correct type, missing optional field).
+    if bad_value is Missing and not required:
+        pytest.skip("not interesting: optional field")
+    elif type(bad_value) == ok_type:  # not isinstance(), because bool is an int
+        pytest.skip("not interesting: correct type")
+
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id=secrets.token_hex(),
+    )
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        # Begin with an acceptable base response...
+        resp = {
+            "device_code": "my-device-code",
+            "user_code": "my-user-code",
+            "interval": 0,
+            "verification_uri": "https://example.com",
+            "expires_in": 5,
+        }
+
+        # ...then tweak it so the client fails.
+        if bad_value is Missing:
+            del resp[field_name]
+        else:
+            resp[field_name] = bad_value
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    def token_endpoint(headers, params):
+        assert False, "token endpoint was invoked unexpectedly"
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    # Now make sure the client correctly failed.
+    if bad_value is Missing:
+        error_pattern = f'field "{field_name}" is missing'
+    elif ok_type == str:
+        error_pattern = f'field "{field_name}" must be a string'
+    elif ok_type == int:
+        error_pattern = f'field "{field_name}" must be a number'
+    else:
+        assert False, "update error_pattern for new failure mode"
+
+    with pytest.raises(psycopg2.OperationalError, match=error_pattern):
+        client.check_completed()
+
+
[email protected](
+    "failure_mode, error_pattern",
+    [
+        pytest.param(
+            (
+                400,
+                {
+                    "error": "expired_token",
+                    "error_description": "the device code has expired",
+                },
+            ),
+            r"failed to obtain access token: the device code has expired \(expired_token\)",
+            id="expired token with description",
+        ),
+        pytest.param(
+            (400, {"error": "access_denied"}),
+            r"failed to obtain access token: \(access_denied\)",
+            id="access denied without description",
+        ),
+        pytest.param(
+            (400, {"error": "access_denied", "padding": "x" * 1024 * 1024}),
+            r"failed to obtain access token: response is too large",
+            id="gigantic token response",
+        ),
+        pytest.param(
+            (400, {}),
+            r'failed to parse token error response: field "error" is missing',
+            id="empty error response",
+        ),
+        pytest.param(
+            (401, {"error": "invalid_client"}),
+            r"failed to obtain access token: provider requires client authentication, and no oauth_client_secret is set \(invalid_client\)",
+            id="authentication failure without description",
+        ),
+        pytest.param(
+            (200, {}, {}),
+            r"failed to parse access token response: no content type was provided",
+            id="missing content type",
+        ),
+        pytest.param(
+            (200, {"Content-Type": "text/plain"}, {}),
+            r"failed to parse access token response: unexpected content type",
+            id="wrong content type",
+        ),
+        pytest.param(
+            (200, {"Content-Type": "application/jsonx"}, {}),
+            r"failed to parse access token response: unexpected content type",
+            id="wrong content type (correct prefix)",
+        ),
+    ],
+)
[email protected]("retries", [0, 1])
+def test_oauth_token_failures(
+    accept, openid_provider, retries, failure_mode, error_pattern
+):
+    client_id = secrets.token_hex()
+
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id=client_id,
+    )
+
+    device_code = secrets.token_hex()
+    user_code = f"{secrets.token_hex(2)}-{secrets.token_hex(2)}"
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        assert params["client_id"] == [client_id]
+
+        resp = {
+            "device_code": device_code,
+            "user_code": user_code,
+            "interval": 0,
+            "verification_uri": "https://example.com/device",
+            "expires_in": 5,
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    retry_lock = threading.Lock()
+    final_sent = False
+
+    def token_endpoint(headers, params):
+        with retry_lock:
+            nonlocal retries, final_sent
+
+            # If the test wants to force the client to retry, return an
+            # authorization_pending response and decrement the retry count.
+            if retries > 0:
+                retries -= 1
+                return 400, {"error": "authorization_pending"}
+
+            # We should only return our failure_mode response once; any further
+            # requests indicate that the client isn't correctly bailing out.
+            assert not final_sent, "client continued after token error"
+
+            final_sent = True
+
+        return failure_mode
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    # Now make sure the client correctly failed.
+    with pytest.raises(psycopg2.OperationalError, match=error_pattern):
+        client.check_completed()
+
+
[email protected](
+    "bad_value",
+    [
+        pytest.param({"device_code": 3}, id="object"),
+        pytest.param([1, 2, 3], id="array"),
+        pytest.param("some string", id="string"),
+        pytest.param(4, id="numeric"),
+        pytest.param(False, id="boolean"),
+        pytest.param(None, id="null"),
+        pytest.param(Missing, id="missing"),
+    ],
+)
[email protected](
+    "field_name,ok_type,required",
+    [
+        ("access_token", str, True),
+        ("token_type", str, True),
+    ],
+)
+def test_oauth_token_bad_json_schema(
+    accept, openid_provider, field_name, ok_type, required, bad_value
+):
+    # To make the test matrix easy, just skip the tests that aren't actually
+    # interesting (field of the correct type, missing optional field).
+    if bad_value is Missing and not required:
+        pytest.skip("not interesting: optional field")
+    elif type(bad_value) == ok_type:  # not isinstance(), because bool is an int
+        pytest.skip("not interesting: correct type")
+
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id=secrets.token_hex(),
+    )
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        resp = {
+            "device_code": "my-device-code",
+            "user_code": "my-user-code",
+            "interval": 0,
+            "verification_uri": "https://example.com",
+            "expires_in": 5,
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    def token_endpoint(headers, params):
+        # Begin with an acceptable base response...
+        resp = {
+            "access_token": secrets.token_urlsafe(),
+            "token_type": "bearer",
+        }
+
+        # ...then tweak it so the client fails.
+        if bad_value is Missing:
+            del resp[field_name]
+        else:
+            resp[field_name] = bad_value
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    # Now make sure the client correctly failed.
+    error_pattern = "failed to parse access token response: "
+    if bad_value is Missing:
+        error_pattern += f'field "{field_name}" is missing'
+    elif ok_type == str:
+        error_pattern += f'field "{field_name}" must be a string'
+    elif ok_type == int:
+        error_pattern += f'field "{field_name}" must be a number'
+    else:
+        assert False, "update error_pattern for new failure mode"
+
+    with pytest.raises(psycopg2.OperationalError, match=error_pattern):
+        client.check_completed()
+
+
[email protected]("success", [True, False])
[email protected]("scope", [None, "openid email"])
[email protected](
+    "base_response",
+    [
+        {"status": "invalid_token"},
+        {"extra_object": {"key": "value"}, "status": "invalid_token"},
+        {"extra_object": {"status": 1}, "status": "invalid_token"},
+    ],
+)
+def test_oauth_discovery(accept, openid_provider, base_response, scope, success):
+    sock, client = accept(
+        oauth_issuer=openid_provider.issuer,
+        oauth_client_id=secrets.token_hex(),
+    )
+
+    device_code = secrets.token_hex()
+    user_code = f"{secrets.token_hex(2)}-{secrets.token_hex(2)}"
+    verification_url = "https://example.com/device"
+
+    access_token = secrets.token_urlsafe()
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        if scope:
+            assert params["scope"] == [scope]
+        else:
+            assert "scope" not in params
+
+        resp = {
+            "device_code": device_code,
+            "user_code": user_code,
+            "interval": 0,
+            "verification_uri": verification_url,
+            "expires_in": 5,
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    def token_endpoint(headers, params):
+        assert params["grant_type"] == ["urn:ietf:params:oauth:grant-type:device_code"]
+        assert params["device_code"] == [device_code]
+
+        # Successfully finish the request by sending the access bearer token.
+        resp = {
+            "access_token": access_token,
+            "token_type": "bearer",
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    # Construct the response to use when failing the SASL exchange. Return a
+    # link to the discovery document, pointing to the test provider server.
+    fail_resp = {
+        **base_response,
+        "openid-configuration": openid_provider.discovery_uri,
+    }
+
+    if scope:
+        fail_resp["scope"] = scope
+
+    with sock:
+        handle_discovery_connection(sock, response=fail_resp)
+
+    # The client will connect to us a second time, using the parameters we sent
+    # it.
+    sock, _ = accept()
+
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            initial = start_oauth_handshake(conn)
+
+            # Validate the token.
+            auth = get_auth_value(initial)
+            assert auth == f"Bearer {access_token}".encode("ascii")
+
+            if success:
+                finish_handshake(conn)
+
+            else:
+                # Simulate token validation failure.
+                expected_error = "test token validation failure"
+                fail_oauth_handshake(conn, fail_resp, errmsg=expected_error)
+
+    if not success:
+        # The client should not try to connect again.
+        with pytest.raises(psycopg2.OperationalError, match=expected_error):
+            client.check_completed()
+
+
[email protected](
+    "response,expected_error",
+    [
+        pytest.param(
+            "abcde",
+            'Token "abcde" is invalid',
+            id="bad JSON: invalid syntax",
+        ),
+        pytest.param(
+            b"\xFF\xFF\xFF\xFF",
+            "server's error response is not valid UTF-8",
+            id="bad JSON: invalid encoding",
+        ),
+        pytest.param(
+            '"abcde"',
+            "top-level element must be an object",
+            id="bad JSON: top-level element is a string",
+        ),
+        pytest.param(
+            "[]",
+            "top-level element must be an object",
+            id="bad JSON: top-level element is an array",
+        ),
+        pytest.param(
+            "{}",
+            "server sent error response without a status",
+            id="bad JSON: no status member",
+        ),
+        pytest.param(
+            '{ "status": null }',
+            'field "status" must be a string',
+            id="bad JSON: null status member",
+        ),
+        pytest.param(
+            '{ "status": 0 }',
+            'field "status" must be a string',
+            id="bad JSON: int status member",
+        ),
+        pytest.param(
+            '{ "status": [ "bad" ] }',
+            'field "status" must be a string',
+            id="bad JSON: array status member",
+        ),
+        pytest.param(
+            '{ "status": { "bad": "bad" } }',
+            'field "status" must be a string',
+            id="bad JSON: object status member",
+        ),
+        pytest.param(
+            '{ "nested": { "status": "bad" } }',
+            "server sent error response without a status",
+            id="bad JSON: nested status",
+        ),
+        pytest.param(
+            '{ "status": "invalid_token" ',
+            "The input string ended unexpectedly",
+            id="bad JSON: unterminated object",
+        ),
+        pytest.param(
+            '{ "status": "invalid_token" } { }',
+            'Expected end of input, but found "{"',
+            id="bad JSON: trailing data",
+        ),
+        pytest.param(
+            '{ "status": "invalid_token", "openid-configuration": 1 }',
+            'field "openid-configuration" must be a string',
+            id="bad JSON: int openid-configuration member",
+        ),
+        pytest.param(
+            '{ "status": "invalid_token", "openid-configuration": 1 }',
+            'field "openid-configuration" must be a string',
+            id="bad JSON: int openid-configuration member",
+        ),
+        pytest.param(
+            '{ "status": "invalid_token", "openid-configuration": "", "openid-configuration": "" }',
+            'field "openid-configuration" is duplicated',
+            id="bad JSON: duplicated field",
+        ),
+        pytest.param(
+            '{ "status": "invalid_token", "scope": 1 }',
+            'field "scope" must be a string',
+            id="bad JSON: int scope member",
+        ),
+    ],
+)
+def test_oauth_discovery_server_error(accept, response, expected_error):
+    sock, client = accept(
+        oauth_issuer="https://example.com",
+        oauth_client_id=secrets.token_hex(),
+    )
+
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            initial = start_oauth_handshake(conn)
+
+            if isinstance(response, str):
+                response = response.encode("utf-8")
+
+            # Fail the SASL exchange with an invalid JSON response.
+            pq3.send(
+                conn,
+                pq3.types.AuthnRequest,
+                type=pq3.authn.SASLContinue,
+                body=response,
+            )
+
+            # The client should disconnect, so the socket is closed here. (If
+            # the client doesn't disconnect, it will report a different error
+            # below and the test will fail.)
+
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
+# All of these tests are expected to fail before libpq tries to actually attempt
+# a connection to any endpoint. To avoid hitting the network in the event that a
+# test fails, an invalid IPv4 address (256.256.256.256) is used as a hostname.
[email protected](
+    "bad_response,expected_error",
+    [
+        pytest.param(
+            (200, {"Content-Type": "text/plain"}, {}),
+            r'failed to parse OpenID discovery document: unexpected content type: "text/plain"',
+            id="not JSON",
+        ),
+        pytest.param(
+            (200, {}, {}),
+            r"failed to parse OpenID discovery document: no content type was provided",
+            id="no Content-Type",
+        ),
+        pytest.param(
+            (204, {}, None),
+            r"failed to fetch OpenID discovery document: unexpected response code 204",
+            id="no content",
+        ),
+        pytest.param(
+            (301, {"Location": "https://localhost/"}, None),
+            r"failed to fetch OpenID discovery document: unexpected response code 301",
+            id="redirection",
+        ),
+        pytest.param(
+            (404, {}),
+            r"failed to fetch OpenID discovery document: unexpected response code 404",
+            id="not found",
+        ),
+        pytest.param(
+            (200, RawResponse("blah\x00blah")),
+            r"failed to parse OpenID discovery document: response contains embedded NULLs",
+            id="NULL bytes in document",
+        ),
+        pytest.param(
+            (200, RawBytes(b"blah\xFFblah")),
+            r"failed to parse OpenID discovery document: response is not valid UTF-8",
+            id="document is not UTF-8",
+        ),
+        pytest.param(
+            (200, 123),
+            r"failed to parse OpenID discovery document: top-level element must be an object",
+            id="scalar at top level",
+        ),
+        pytest.param(
+            (200, []),
+            r"failed to parse OpenID discovery document: top-level element must be an object",
+            id="array at top level",
+        ),
+        pytest.param(
+            (200, RawResponse("{")),
+            r"failed to parse OpenID discovery document.* input string ended unexpectedly",
+            id="unclosed object",
+        ),
+        pytest.param(
+            (200, RawResponse(r'{ "hello": ] }')),
+            r"failed to parse OpenID discovery document.* Expected JSON value",
+            id="bad array",
+        ),
+        pytest.param(
+            (200, {"issuer": 123}),
+            r'failed to parse OpenID discovery document: field "issuer" must be a string',
+            id="non-string issuer",
+        ),
+        pytest.param(
+            (200, {"issuer": ["something"]}),
+            r'failed to parse OpenID discovery document: field "issuer" must be a string',
+            id="issuer array",
+        ),
+        pytest.param(
+            (200, {"issuer": {}}),
+            r'failed to parse OpenID discovery document: field "issuer" must be a string',
+            id="issuer object",
+        ),
+        pytest.param(
+            (200, {"grant_types_supported": 123}),
+            r'failed to parse OpenID discovery document: field "grant_types_supported" must be an array of strings',
+            id="numeric grant types field",
+        ),
+        pytest.param(
+            (
+                200,
+                {
+                    "grant_types_supported": "urn:ietf:params:oauth:grant-type:device_code"
+                },
+            ),
+            r'failed to parse OpenID discovery document: field "grant_types_supported" must be an array of strings',
+            id="string grant types field",
+        ),
+        pytest.param(
+            (200, {"grant_types_supported": {}}),
+            r'failed to parse OpenID discovery document: field "grant_types_supported" must be an array of strings',
+            id="object grant types field",
+        ),
+        pytest.param(
+            (200, {"grant_types_supported": [123]}),
+            r'failed to parse OpenID discovery document: field "grant_types_supported" must be an array of strings',
+            id="non-string grant types",
+        ),
+        pytest.param(
+            (200, {"grant_types_supported": ["something", 123]}),
+            r'failed to parse OpenID discovery document: field "grant_types_supported" must be an array of strings',
+            id="non-string grant types later in the list",
+        ),
+        pytest.param(
+            (200, {"grant_types_supported": ["something", {}]}),
+            r'failed to parse OpenID discovery document: field "grant_types_supported" must be an array of strings',
+            id="object grant types later in the list",
+        ),
+        pytest.param(
+            (200, {"grant_types_supported": ["something", ["something"]]}),
+            r'failed to parse OpenID discovery document: field "grant_types_supported" must be an array of strings',
+            id="embedded array grant types later in the list",
+        ),
+        pytest.param(
+            (
+                200,
+                {
+                    "grant_types_supported": ["something"],
+                    "token_endpoint": "https://256.256.256.256/",
+                    "issuer": 123,
+                },
+            ),
+            r'failed to parse OpenID discovery document: field "issuer" must be a string',
+            id="non-string issuer after other valid fields",
+        ),
+        pytest.param(
+            (
+                200,
+                {
+                    "ignored": {"grant_types_supported": 123, "token_endpoint": 123},
+                    "issuer": 123,
+                },
+            ),
+            r'failed to parse OpenID discovery document: field "issuer" must be a string',
+            id="non-string issuer after other ignored fields",
+        ),
+        pytest.param(
+            (200, {"token_endpoint": "https://256.256.256.256/"}),
+            r'failed to parse OpenID discovery document: field "issuer" is missing',
+            id="missing issuer",
+        ),
+        pytest.param(
+            (200, {"issuer": "{issuer}"}),
+            r'failed to parse OpenID discovery document: field "token_endpoint" is missing',
+            id="missing token endpoint",
+        ),
+        pytest.param(
+            (
+                200,
+                {
+                    "issuer": "{issuer}",
+                    "token_endpoint": "https://256.256.256.256/token",
+                    "device_authorization_endpoint": "https://256.256.256.256/dev",
+                },
+            ),
+            r'cannot run OAuth device authorization: issuer "https://.*" does not support device code grants',
+            id="missing device code grants",
+        ),
+        pytest.param(
+            (
+                200,
+                {
+                    "issuer": "{issuer}",
+                    "token_endpoint": "https://256.256.256.256/token",
+                    "grant_types_supported": [
+                        "urn:ietf:params:oauth:grant-type:device_code"
+                    ],
+                },
+            ),
+            r'cannot run OAuth device authorization: issuer "https://.*" does not provide a device authorization endpoint',
+            id="missing device_authorization_endpoint",
+        ),
+        pytest.param(
+            (
+                200,
+                {
+                    "issuer": "{issuer}",
+                    "token_endpoint": "https://256.256.256.256/token",
+                    "grant_types_supported": [
+                        "urn:ietf:params:oauth:grant-type:device_code"
+                    ],
+                    "device_authorization_endpoint": "https://256.256.256.256/dev",
+                    "filler": "x" * 1024 * 1024,
+                },
+            ),
+            r"failed to fetch OpenID discovery document: response is too large",
+            id="gigantic discovery response",
+        ),
+        pytest.param(
+            (
+                200,
+                {
+                    "issuer": "{issuer}/path",
+                    "token_endpoint": "https://256.256.256.256/token",
+                    "grant_types_supported": [
+                        "urn:ietf:params:oauth:grant-type:device_code"
+                    ],
+                    "device_authorization_endpoint": "https://256.256.256.256/dev",
+                },
+            ),
+            r"failed to parse OpenID discovery document: the issuer identifier \(https://.*/path\) does not match oauth_issuer \(https://.*\)",
+            id="mismatched issuer identifier",
+        ),
+        pytest.param(
+            (
+                200,
+                RawResponse(
+                    """{
+                        "issuer": "https://256.256.256.256/path",
+                        "token_endpoint": "https://256.256.256.256/token",
+                        "grant_types_supported": [
+                            "urn:ietf:params:oauth:grant-type:device_code"
+                        ],
+                        "device_authorization_endpoint": "https://256.256.256.256/dev",
+                        "device_authorization_endpoint": "https://256.256.256.256/dev"
+                    }"""
+                ),
+            ),
+            r'failed to parse OpenID discovery document: field "device_authorization_endpoint" is duplicated',
+            id="duplicated field",
+        ),
+        #
+        # Exercise HTTP-level failures by breaking the protocol. Note that the
+        # error messages here are implementation-dependent.
+        #
+        pytest.param(
+            (1000, {}),
+            r"failed to fetch OpenID discovery document: Unsupported protocol \(.*\)",
+            id="invalid HTTP response code",
+        ),
+        pytest.param(
+            (200, {"Content-Length": -1}, {}),
+            r"failed to fetch OpenID discovery document: Weird server reply \(.*Content-Length.*\)",
+            id="bad HTTP Content-Length",
+        ),
+    ],
+)
+def test_oauth_discovery_provider_failure(
+    accept, openid_provider, bad_response, expected_error
+):
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id=secrets.token_hex(),
+    )
+
+    def failing_discovery_handler(headers, params):
+        try:
+            # Insert the correct issuer value if the test wants to.
+            resp = bad_response[1]
+            iss = resp["issuer"]
+            resp["issuer"] = iss.format(issuer=openid_provider.issuer)
+        except (AttributeError, KeyError, TypeError):
+            pass
+
+        return bad_response
+
+    openid_provider.register_endpoint(
+        None,
+        "GET",
+        "/.well-known/openid-configuration",
+        failing_discovery_handler,
+    )
+
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
[email protected](
+    "sasl_err,resp_type,resp_payload,expected_error",
+    [
+        pytest.param(
+            {"status": "invalid_request"},
+            pq3.types.ErrorResponse,
+            dict(
+                fields=[b"SFATAL", b"C28000", b"Mexpected error message", b""],
+            ),
+            "server rejected OAuth bearer token: invalid_request",
+            id="standard server error: invalid_request",
+        ),
+        pytest.param(
+            {"status": "invalid_token"},
+            pq3.types.ErrorResponse,
+            dict(
+                fields=[b"SFATAL", b"C28000", b"Mexpected error message", b""],
+            ),
+            "expected error message",
+            id="standard server error: invalid_token without discovery URI",
+        ),
+        pytest.param(
+            {"status": "invalid_token", "openid-configuration": ""},
+            pq3.types.AuthnRequest,
+            dict(type=pq3.authn.SASLContinue, body=b""),
+            "server sent additional OAuth data",
+            id="broken server: additional challenge after error",
+        ),
+        pytest.param(
+            {"status": "invalid_token", "openid-configuration": ""},
+            pq3.types.AuthnRequest,
+            dict(type=pq3.authn.SASLFinal),
+            "server sent additional OAuth data",
+            id="broken server: SASL success after error",
+        ),
+        pytest.param(
+            {"status": "invalid_token", "openid-configuration": ""},
+            pq3.types.AuthnRequest,
+            dict(type=pq3.authn.SASL, body=[b"OAUTHBEARER", b""]),
+            "duplicate SASL authentication request",
+            id="broken server: SASL reinitialization after error",
+        ),
+    ],
+)
+def test_oauth_server_error(
+    accept, auth_data_cb, sasl_err, resp_type, resp_payload, expected_error
+):
+    wkuri = f"https://256.256.256.256/.well-known/openid-configuration"
+    sock, client = accept(
+        oauth_issuer=wkuri,
+        oauth_client_id="some-id",
+    )
+
+    def bearer_hook(typ, pgconn, request):
+        """
+        Implementation of the PQAuthDataHook, which returns a token directly so
+        we don't need an openid_provider instance.
+        """
+        assert typ == PQAUTHDATA_OAUTH_BEARER_TOKEN
+        request.token = secrets.token_urlsafe().encode()
+        return 1
+
+    auth_data_cb.impl = bearer_hook
+
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            start_oauth_handshake(conn)
+
+            # Ignore the client data. Return an error "challenge".
+            if "openid-configuration" in sasl_err:
+                sasl_err["openid-configuration"] = wkuri
+
+            resp = json.dumps(sasl_err)
+            resp = resp.encode("utf-8")
+
+            pq3.send(
+                conn, pq3.types.AuthnRequest, type=pq3.authn.SASLContinue, body=resp
+            )
+
+            # Per RFC, the client is required to send a dummy ^A response.
+            pkt = pq3.recv1(conn)
+            assert pkt.type == pq3.types.PasswordMessage
+            assert pkt.payload == b"\x01"
+
+            # Now fail the SASL exchange (in either a valid way, or an
+            # invalid one, depending on the test).
+            pq3.send(conn, resp_type, **resp_payload)
+
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
+def test_oauth_interval_overflow(accept, openid_provider):
+    """
+    A really badly behaved server could send a huge interval and then
+    immediately tell us to slow_down; ensure we handle this without breaking.
+    """
+    # (should be equivalent to the INT_MAX in limits.h)
+    int_max = ctypes.c_uint(-1).value // 2
+
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id=secrets.token_hex(),
+    )
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        resp = {
+            "device_code": "my-device-code",
+            "user_code": "my-user-code",
+            "verification_uri": "https://example.com",
+            "expires_in": 5,
+            "interval": int_max,
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    def token_endpoint(headers, params):
+        return 400, {"error": "slow_down"}
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    expected_error = "slow_down interval overflow"
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
+def test_oauth_refuses_http(accept, openid_provider, monkeypatch):
+    """
+    HTTP must be refused without PGOAUTHDEBUG.
+    """
+    monkeypatch.delenv("PGOAUTHDEBUG")
+
+    def to_http(uri):
+        """Swaps out a URI's scheme for http."""
+        parts = urllib.parse.urlparse(uri)
+        parts = parts._replace(scheme="http")
+        return urllib.parse.urlunparse(parts)
+
+    sock, client = accept(
+        oauth_issuer=to_http(openid_provider.issuer),
+        oauth_client_id=secrets.token_hex(),
+    )
+
+    # No provider callbacks necessary; we should fail immediately.
+
+    with sock:
+        handle_discovery_connection(sock, to_http(openid_provider.discovery_uri))
+
+    expected_error = r'OAuth discovery URI ".*" must use HTTPS'
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
[email protected]("auth_type", [pq3.authn.OK, pq3.authn.SASLFinal])
+def test_discovery_incorrectly_permits_connection(accept, auth_type):
+    """
+    Incorrectly responds to a client's discovery request with AuthenticationOK
+    or AuthenticationSASLFinal. require_auth=oauth should catch the former, and
+    the mechanism itself should catch the latter.
+    """
+    issuer = "https://256.256.256.256"
+    sock, client = accept(
+        oauth_issuer=issuer,
+        oauth_client_id=secrets.token_hex(),
+        require_auth="oauth",
+    )
+
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            initial = start_oauth_handshake(conn)
+
+            auth = get_auth_value(initial)
+            assert auth == b""
+
+            # Incorrectly log the client in. It should immediately disconnect.
+            pq3.send(conn, pq3.types.AuthnRequest, type=auth_type)
+            assert not conn.read(1), "client sent unexpected data"
+
+    if auth_type == pq3.authn.OK:
+        expected_error = "server did not complete authentication"
+    else:
+        expected_error = "server sent unexpected additional OAuth data"
+
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
+def test_no_discovery_url_provided(accept):
+    """
+    Tests what happens when the client doesn't know who to contact and the
+    server doesn't tell it.
+    """
+    issuer = "https://256.256.256.256"
+    sock, client = accept(
+        oauth_issuer=issuer,
+        oauth_client_id=secrets.token_hex(),
+    )
+
+    with sock:
+        handle_discovery_connection(sock, discovery=None)
+
+    expected_error = "no discovery metadata was provided"
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
[email protected]("change_between_connections", [False, True])
+def test_discovery_url_changes(accept, openid_provider, change_between_connections):
+    """
+    Ensures that the client complains if the server agrees on the issuer, but
+    disagrees on the discovery URL to be used.
+    """
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        resp = {
+            "device_code": "DEV",
+            "user_code": "USER",
+            "interval": 0,
+            "verification_uri": "https://example.org",
+            "expires_in": 5,
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    def token_endpoint(headers, params):
+        resp = {
+            "access_token": secrets.token_urlsafe(),
+            "token_type": "bearer",
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    # Have the client connect.
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id="some-id",
+    )
+
+    other_wkuri = f"{openid_provider.issuer}/.well-known/oauth-authorization-server"
+
+    if not change_between_connections:
+        # Immediately respond with the wrong URL.
+        with sock:
+            handle_discovery_connection(sock, other_wkuri)
+
+    else:
+        # First connection; use the right URL to begin with.
+        with sock:
+            handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+        # Second connection. Reject the token and switch the URL.
+        sock, _ = accept()
+        with sock:
+            with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+                initial = start_oauth_handshake(conn)
+                get_auth_value(initial)
+
+                # Ignore the token; fail with a different discovery URL.
+                resp = {
+                    "status": "invalid_token",
+                    "openid-configuration": other_wkuri,
+                }
+                fail_oauth_handshake(conn, resp)
+
+    expected_error = rf"server's discovery document has moved to {other_wkuri} \(previous location was {openid_provider.discovery_uri}\)"
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
diff --git a/src/test/python/conftest.py b/src/test/python/conftest.py
new file mode 100644
index 00000000000..1a73865ee47
--- /dev/null
+++ b/src/test/python/conftest.py
@@ -0,0 +1,34 @@
+#
+# Copyright 2023 Timescale, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import os
+
+import pytest
+
+
+def pytest_addoption(parser):
+    """
+    Adds custom command line options to py.test. We add one to signal temporary
+    Postgres instance creation for the server tests.
+
+    Per pytest documentation, this must live in the top level test directory.
+    """
+    parser.addoption(
+        "--temp-instance",
+        metavar="DIR",
+        help="create a temporary Postgres instance in DIR",
+    )
+
+
[email protected](scope="session", autouse=True)
+def _check_PG_TEST_EXTRA(request):
+    """
+    Automatically skips the whole suite if PG_TEST_EXTRA doesn't contain
+    'python'. pytestmark doesn't seem to work in a top-level conftest.py, so
+    I've made this an autoused fixture instead.
+    """
+    extra_tests = os.getenv("PG_TEST_EXTRA", "").split()
+    if "python" not in extra_tests:
+        pytest.skip("Potentially unsafe test 'python' not enabled in PG_TEST_EXTRA")
diff --git a/src/test/python/meson.build b/src/test/python/meson.build
new file mode 100644
index 00000000000..e137df852ef
--- /dev/null
+++ b/src/test/python/meson.build
@@ -0,0 +1,47 @@
+# Copyright (c) 2023, PostgreSQL Global Development Group
+
+subdir('server')
+
+pytest_env = {
+  'with_libcurl': libcurl.found() ? 'yes' : 'no',
+
+  # Point to the default database; the tests will create their own databases as
+  # needed.
+  'PGDATABASE': 'postgres',
+
+  # Avoid the need for a Rust compiler on platforms without prebuilt wheels for
+  # pyca/cryptography.
+  'CRYPTOGRAPHY_DONT_BUILD_RUST': '1',
+}
+
+# Some modules (psycopg2) need OpenSSL at compile time; for platforms where we
+# might have multiple implementations installed (macOS+brew), try to use the
+# same one that libpq is using.
+if ssl.found()
+  pytest_incdir = ssl.get_variable(pkgconfig: 'includedir', default_value: '')
+  if pytest_incdir != ''
+    pytest_env += { 'CPPFLAGS': '-I@0@'.format(pytest_incdir) }
+  endif
+
+  pytest_libdir = ssl.get_variable(pkgconfig: 'libdir', default_value: '')
+  if pytest_libdir != ''
+    pytest_env += { 'LDFLAGS': '-L@0@'.format(pytest_libdir) }
+  endif
+endif
+
+tests += {
+  'name': 'python',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'pytest': {
+	'requirements': meson.current_source_dir() / 'requirements.txt',
+    'tests': [
+      './client',
+      './server',
+      './test_internals.py',
+      './test_pq3.py',
+    ],
+    'env': pytest_env,
+    'test_kwargs': {'priority': 50}, # python tests are slow, start early
+  },
+}
diff --git a/src/test/python/pq3.py b/src/test/python/pq3.py
new file mode 100644
index 00000000000..ef809e288af
--- /dev/null
+++ b/src/test/python/pq3.py
@@ -0,0 +1,740 @@
+#
+# Copyright 2021 VMware, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import contextlib
+import getpass
+import io
+import os
+import platform
+import ssl
+import sys
+import textwrap
+
+from construct import *
+
+import tls
+
+
+def protocol(major, minor):
+    """
+    Returns the protocol version, in integer format, corresponding to the given
+    major and minor version numbers.
+    """
+    return (major << 16) | minor
+
+
+# Startup
+
+StringList = GreedyRange(NullTerminated(GreedyBytes))
+
+
+class KeyValueAdapter(Adapter):
+    """
+    Turns a key-value store into a null-terminated list of null-terminated
+    strings, as presented on the wire in the startup packet.
+    """
+
+    def _encode(self, obj, context, path):
+        if isinstance(obj, list):
+            return obj
+
+        l = []
+
+        for k, v in obj.items():
+            if isinstance(k, str):
+                k = k.encode("utf-8")
+            l.append(k)
+
+            if isinstance(v, str):
+                v = v.encode("utf-8")
+            l.append(v)
+
+        l.append(b"")
+        return l
+
+    def _decode(self, obj, context, path):
+        # TODO: turn a list back into a dict
+        return obj
+
+
+KeyValues = KeyValueAdapter(StringList)
+
+_startup_payload = Switch(
+    this.proto,
+    {
+        protocol(3, 0): KeyValues,
+    },
+    default=GreedyBytes,
+)
+
+
+def _default_protocol(this):
+    try:
+        if isinstance(this.payload, (list, dict)):
+            return protocol(3, 0)
+    except AttributeError:
+        pass  # no payload passed during build
+
+    return 0
+
+
+def _startup_payload_len(this):
+    """
+    The payload field has a fixed size based on the length of the packet. But
+    if the caller hasn't supplied an explicit length at build time, we have to
+    build the payload to figure out how long it is, which requires us to know
+    the length first... This function exists solely to break the cycle.
+    """
+    assert this._building, "_startup_payload_len() cannot be called during parsing"
+
+    try:
+        payload = this.payload
+    except AttributeError:
+        return 0  # no payload
+
+    if isinstance(payload, bytes):
+        # already serialized; just use the given length
+        return len(payload)
+
+    try:
+        proto = this.proto
+    except AttributeError:
+        proto = _default_protocol(this)
+
+    data = _startup_payload.build(payload, proto=proto)
+    return len(data)
+
+
+Startup = Struct(
+    "len" / Default(Int32sb, lambda this: _startup_payload_len(this) + 8),
+    "proto" / Default(Hex(Int32sb), _default_protocol),
+    "payload" / FixedSized(this.len - 8, Default(_startup_payload, b"")),
+)
+
+# Pq3
+
+
+# Adapted from construct.core.EnumIntegerString
+class EnumNamedByte:
+    def __init__(self, val, name):
+        self._val = val
+        self._name = name
+
+    def __int__(self):
+        return ord(self._val)
+
+    def __str__(self):
+        return "(enum) %s %r" % (self._name, self._val)
+
+    def __repr__(self):
+        return "EnumNamedByte(%r)" % self._val
+
+    def __eq__(self, other):
+        if isinstance(other, EnumNamedByte):
+            other = other._val
+        if not isinstance(other, bytes):
+            return NotImplemented
+
+        return self._val == other
+
+    def __hash__(self):
+        return hash(self._val)
+
+
+# Adapted from construct.core.Enum
+class ByteEnum(Adapter):
+    def __init__(self, **mapping):
+        super(ByteEnum, self).__init__(Byte)
+        self.namemapping = {k: EnumNamedByte(v, k) for k, v in mapping.items()}
+        self.decmapping = {v: EnumNamedByte(v, k) for k, v in mapping.items()}
+
+    def __getattr__(self, name):
+        if name in self.namemapping:
+            return self.decmapping[self.namemapping[name]]
+        raise AttributeError
+
+    def _decode(self, obj, context, path):
+        b = bytes([obj])
+        try:
+            return self.decmapping[b]
+        except KeyError:
+            return EnumNamedByte(b, "(unknown)")
+
+    def _encode(self, obj, context, path):
+        if isinstance(obj, int):
+            return obj
+        elif isinstance(obj, bytes):
+            return ord(obj)
+        return int(obj)
+
+
+types = ByteEnum(
+    ErrorResponse=b"E",
+    ReadyForQuery=b"Z",
+    Query=b"Q",
+    EmptyQueryResponse=b"I",
+    AuthnRequest=b"R",
+    PasswordMessage=b"p",
+    BackendKeyData=b"K",
+    CommandComplete=b"C",
+    ParameterStatus=b"S",
+    DataRow=b"D",
+    Terminate=b"X",
+)
+
+
+authn = Enum(
+    Int32ub,
+    OK=0,
+    SASL=10,
+    SASLContinue=11,
+    SASLFinal=12,
+)
+
+
+_authn_body = Switch(
+    this.type,
+    {
+        authn.OK: Terminated,
+        authn.SASL: StringList,
+    },
+    default=GreedyBytes,
+)
+
+
+def _data_len(this):
+    assert this._building, "_data_len() cannot be called during parsing"
+
+    if not hasattr(this, "data") or this.data is None:
+        return -1
+
+    return len(this.data)
+
+
+# The protocol reuses the PasswordMessage for several authentication response
+# types, and there's no good way to figure out which is which without keeping
+# state for the entire stream. So this is a separate Construct that can be
+# explicitly parsed/built by code that knows it's needed.
+SASLInitialResponse = Struct(
+    "name" / NullTerminated(GreedyBytes),
+    "len" / Default(Int32sb, lambda this: _data_len(this)),
+    "data"
+    / IfThenElse(
+        # Allow tests to explicitly pass an incorrect length during testing, by
+        # not enforcing a FixedSized during build. (The len calculation above
+        # defaults to the correct size.)
+        this._building,
+        Optional(GreedyBytes),
+        If(this.len != -1, Default(FixedSized(this.len, GreedyBytes), b"")),
+    ),
+    Terminated,  # make sure the entire response is consumed
+)
+
+
+_column = FocusedSeq(
+    "data",
+    "len" / Default(Int32sb, lambda this: _data_len(this)),
+    "data" / If(this.len != -1, FixedSized(this.len, GreedyBytes)),
+)
+
+
+_payload_map = {
+    types.ErrorResponse: Struct("fields" / StringList),
+    types.ReadyForQuery: Struct("status" / Bytes(1)),
+    types.Query: Struct("query" / NullTerminated(GreedyBytes)),
+    types.EmptyQueryResponse: Terminated,
+    types.AuthnRequest: Struct("type" / authn, "body" / Default(_authn_body, b"")),
+    types.BackendKeyData: Struct("pid" / Int32ub, "key" / Hex(Int32ub)),
+    types.CommandComplete: Struct("tag" / NullTerminated(GreedyBytes)),
+    types.ParameterStatus: Struct(
+        "name" / NullTerminated(GreedyBytes), "value" / NullTerminated(GreedyBytes)
+    ),
+    types.DataRow: Struct("columns" / Default(PrefixedArray(Int16sb, _column), b"")),
+    types.Terminate: Terminated,
+}
+
+
+_payload = FocusedSeq(
+    "_payload",
+    "_payload"
+    / Switch(
+        this._.type,
+        _payload_map,
+        default=GreedyBytes,
+    ),
+    Terminated,  # make sure every payload consumes the entire packet
+)
+
+
+def _payload_len(this):
+    """
+    See _startup_payload_len() for an explanation.
+    """
+    assert this._building, "_payload_len() cannot be called during parsing"
+
+    try:
+        payload = this.payload
+    except AttributeError:
+        return 0  # no payload
+
+    if isinstance(payload, bytes):
+        # already serialized; just use the given length
+        return len(payload)
+
+    data = _payload.build(payload, type=this.type)
+    return len(data)
+
+
+Pq3 = Struct(
+    "type" / types,
+    "len" / Default(Int32ub, lambda this: _payload_len(this) + 4),
+    "payload"
+    / IfThenElse(
+        # Allow tests to explicitly pass an incorrect length during testing, by
+        # not enforcing a FixedSized during build. (The len calculation above
+        # defaults to the correct size.)
+        this._building,
+        Optional(_payload),
+        FixedSized(this.len - 4, Default(_payload, b"")),
+    ),
+)
+
+
+# Environment
+
+
+def pghost():
+    return os.environ.get("PGHOST", default="localhost")
+
+
+def pgport():
+    return int(os.environ.get("PGPORT", default=5432))
+
+
+def pguser():
+    try:
+        return os.environ["PGUSER"]
+    except KeyError:
+        if platform.system() == "Windows":
+            # libpq defaults to GetUserName() on Windows.
+            return os.getlogin()
+        return getpass.getuser()
+
+
+def pgdatabase():
+    return os.environ.get("PGDATABASE", default="postgres")
+
+
+# Connections
+
+
+def _hexdump_translation_map():
+    """
+    For hexdumps. Translates any unprintable or non-ASCII bytes into '.'.
+    """
+    input = bytearray()
+
+    for i in range(128):
+        c = chr(i)
+
+        if not c.isprintable():
+            input += bytes([i])
+
+    input += bytes(range(128, 256))
+
+    return bytes.maketrans(input, b"." * len(input))
+
+
+class _DebugStream(object):
+    """
+    Wraps a file-like object and adds hexdumps of the read and write data. Call
+    end_packet() on a _DebugStream to write the accumulated hexdumps to the
+    output stream, along with the packet that was sent.
+    """
+
+    _translation_map = _hexdump_translation_map()
+
+    def __init__(self, stream, out=sys.stdout):
+        """
+        Creates a new _DebugStream wrapping the given stream (which must have
+        been created by wrap()). All attributes not provided by the _DebugStream
+        are delegated to the wrapped stream. out is the text stream to which
+        hexdumps are written.
+        """
+        self.raw = stream
+        self._out = out
+        self._rbuf = io.BytesIO()
+        self._wbuf = io.BytesIO()
+
+    def __getattr__(self, name):
+        return getattr(self.raw, name)
+
+    def __setattr__(self, name, value):
+        if name in ("raw", "_out", "_rbuf", "_wbuf"):
+            return object.__setattr__(self, name, value)
+
+        setattr(self.raw, name, value)
+
+    def read(self, *args, **kwargs):
+        buf = self.raw.read(*args, **kwargs)
+
+        self._rbuf.write(buf)
+        return buf
+
+    def write(self, b):
+        self._wbuf.write(b)
+        return self.raw.write(b)
+
+    def recv(self, *args):
+        buf = self.raw.recv(*args)
+
+        self._rbuf.write(buf)
+        return buf
+
+    def _flush(self, buf, prefix):
+        width = 16
+        hexwidth = width * 3 - 1
+
+        count = 0
+        buf.seek(0)
+
+        while True:
+            line = buf.read(16)
+
+            if not line:
+                if count:
+                    self._out.write("\n")  # separate the output block with a newline
+                return
+
+            self._out.write("%s %04X:\t" % (prefix, count))
+            self._out.write("%*s\t" % (-hexwidth, line.hex(" ")))
+            self._out.write(line.translate(self._translation_map).decode("ascii"))
+            self._out.write("\n")
+
+            count += 16
+
+    def print_debug(self, obj, *, prefix=""):
+        contents = ""
+        if obj is not None:
+            contents = str(obj)
+
+        for line in contents.splitlines():
+            self._out.write("%s%s\n" % (prefix, line))
+
+        self._out.write("\n")
+
+    def flush_debug(self, *, prefix=""):
+        self._flush(self._rbuf, prefix + "<")
+        self._rbuf = io.BytesIO()
+
+        self._flush(self._wbuf, prefix + ">")
+        self._wbuf = io.BytesIO()
+
+    def end_packet(self, pkt, *, read=False, prefix="", indent="  "):
+        """
+        Marks the end of a logical "packet" of data. A string representation of
+        pkt will be printed, and the debug buffers will be flushed with an
+        indent. All lines can be optionally prefixed.
+
+        If read is True, the packet representation is written after the debug
+        buffers; otherwise the default of False (meaning write) causes the
+        packet representation to be dumped first. This is meant to capture the
+        logical flow of layer translation.
+        """
+        write = not read
+
+        if write:
+            self.print_debug(pkt, prefix=prefix + "> ")
+
+        self.flush_debug(prefix=prefix + indent)
+
+        if read:
+            self.print_debug(pkt, prefix=prefix + "< ")
+
+
[email protected]
+def wrap(socket, *, debug_stream=None):
+    """
+    Transforms a raw socket into a connection that can be used for Construct
+    building and parsing. The return value is a context manager and can be used
+    in a with statement.
+    """
+    # It is critical that buffering be disabled here, so that we can still
+    # manipulate the raw socket without desyncing the stream.
+    with socket.makefile("rwb", buffering=0) as sfile:
+        # Expose the original socket's recv() on the SocketIO object we return.
+        def recv(self, *args):
+            return socket.recv(*args)
+
+        sfile.recv = recv.__get__(sfile)
+
+        conn = sfile
+        if debug_stream:
+            conn = _DebugStream(conn, debug_stream)
+
+        try:
+            yield conn
+        finally:
+            if debug_stream:
+                conn.flush_debug(prefix="? ")
+
+
+def _send(stream, cls, obj):
+    debugging = hasattr(stream, "flush_debug")
+    out = io.BytesIO()
+
+    # Ideally we would build directly to the passed stream, but because we need
+    # to reparse the generated output for the debugging case, build to an
+    # intermediate BytesIO and send it instead.
+    cls.build_stream(obj, out)
+    buf = out.getvalue()
+
+    stream.write(buf)
+    if debugging:
+        pkt = cls.parse(buf)
+        stream.end_packet(pkt)
+
+    stream.flush()
+
+
+def send(stream, packet_type, payload_data=None, **payloadkw):
+    """
+    Sends a packet on the given pq3 connection. type is the pq3.types member
+    that should be assigned to the packet. If payload_data is given, it will be
+    used as the packet payload; otherwise the key/value pairs in payloadkw will
+    be the payload contents.
+    """
+    data = payloadkw
+
+    if payload_data is not None:
+        if payloadkw:
+            raise ValueError(
+                "payload_data and payload keywords may not be used simultaneously"
+            )
+
+        data = payload_data
+
+    _send(stream, Pq3, dict(type=packet_type, payload=data))
+
+
+def send_startup(stream, proto=None, **kwargs):
+    """
+    Sends a startup packet on the given pq3 connection. In most cases you should
+    use the handshake functions instead, which will do this for you.
+
+    By default, a protocol version 3 packet will be sent. This can be overridden
+    with the proto parameter.
+    """
+    pkt = {}
+
+    if proto is not None:
+        pkt["proto"] = proto
+    if kwargs:
+        pkt["payload"] = kwargs
+
+    _send(stream, Startup, pkt)
+
+
+def recv1(stream, *, cls=Pq3):
+    """
+    Receives a single pq3 packet from the given stream and returns it.
+    """
+    resp = cls.parse_stream(stream)
+
+    debugging = hasattr(stream, "flush_debug")
+    if debugging:
+        stream.end_packet(resp, read=True)
+
+    return resp
+
+
+def handshake(stream, **kwargs):
+    """
+    Performs a libpq v3 startup handshake. kwargs should contain the key/value
+    parameters to send to the server in the startup packet.
+    """
+    # Send our startup parameters.
+    send_startup(stream, **kwargs)
+
+    # Receive and dump packets until the server indicates it's ready for our
+    # first query.
+    while True:
+        resp = recv1(stream)
+        if resp is None:
+            raise RuntimeError("server closed connection during handshake")
+
+        if resp.type == types.ReadyForQuery:
+            return
+        elif resp.type == types.ErrorResponse:
+            raise RuntimeError(
+                f"received error response from peer: {resp.payload.fields!r}"
+            )
+
+
+# TLS
+
+
+class _TLSStream(object):
+    """
+    A file-like object that performs TLS encryption/decryption on a wrapped
+    stream. Differs from ssl.SSLSocket in that we have full visibility and
+    control over the TLS layer.
+    """
+
+    def __init__(self, stream, context):
+        self._stream = stream
+        self._debugging = hasattr(stream, "flush_debug")
+
+        self._in = ssl.MemoryBIO()
+        self._out = ssl.MemoryBIO()
+        self._ssl = context.wrap_bio(self._in, self._out)
+
+    def handshake(self):
+        try:
+            self._pump(lambda: self._ssl.do_handshake())
+        finally:
+            self._flush_debug(prefix="? ")
+
+    def read(self, *args):
+        return self._pump(lambda: self._ssl.read(*args))
+
+    def write(self, *args):
+        return self._pump(lambda: self._ssl.write(*args))
+
+    def _decode(self, buf):
+        """
+        Attempts to decode a buffer of TLS data into a packet representation
+        that can be printed.
+
+        TODO: handle buffers (and record fragments) that don't align with packet
+        boundaries.
+        """
+        end = len(buf)
+        bio = io.BytesIO(buf)
+
+        ret = io.StringIO()
+
+        while bio.tell() < end:
+            record = tls.Plaintext.parse_stream(bio)
+
+            if ret.tell() > 0:
+                ret.write("\n")
+            ret.write("[Record] ")
+            ret.write(str(record))
+            ret.write("\n")
+
+            if record.type == tls.ContentType.handshake:
+                record_cls = tls.Handshake
+            else:
+                continue
+
+            innerlen = len(record.fragment)
+            inner = io.BytesIO(record.fragment)
+
+            while inner.tell() < innerlen:
+                msg = record_cls.parse_stream(inner)
+
+                indented = "[Message] " + str(msg)
+                indented = textwrap.indent(indented, "    ")
+
+                ret.write("\n")
+                ret.write(indented)
+                ret.write("\n")
+
+        return ret.getvalue()
+
+    def flush(self):
+        if not self._out.pending:
+            self._stream.flush()
+            return
+
+        buf = self._out.read()
+        self._stream.write(buf)
+
+        if self._debugging:
+            pkt = self._decode(buf)
+            self._stream.end_packet(pkt, prefix="  ")
+
+        self._stream.flush()
+
+    def _pump(self, operation):
+        while True:
+            try:
+                return operation()
+            except (ssl.SSLWantReadError, ssl.SSLWantWriteError) as e:
+                want = e
+            self._read_write(want)
+
+    def _recv(self, maxsize):
+        buf = self._stream.recv(4096)
+        if not buf:
+            self._in.write_eof()
+            return
+
+        self._in.write(buf)
+
+        if not self._debugging:
+            return
+
+        pkt = self._decode(buf)
+        self._stream.end_packet(pkt, read=True, prefix="  ")
+
+    def _read_write(self, want):
+        # XXX This needs work. So many corner cases yet to handle. For one,
+        # doing blocking writes in flush may lead to distributed deadlock if the
+        # peer is already blocking on its writes.
+
+        if isinstance(want, ssl.SSLWantWriteError):
+            assert self._out.pending, "SSL backend wants write without data"
+
+        self.flush()
+
+        if isinstance(want, ssl.SSLWantReadError):
+            self._recv(4096)
+
+    def _flush_debug(self, prefix):
+        if not self._debugging:
+            return
+
+        self._stream.flush_debug(prefix=prefix)
+
+
[email protected]
+def tls_handshake(stream, context):
+    """
+    Performs a TLS handshake over the given stream (which must have been created
+    via a call to wrap()), and returns a new stream which transparently tunnels
+    data over the TLS connection.
+
+    If the passed stream has debugging enabled, the returned stream will also
+    have debugging, using the same output IO.
+    """
+    debugging = hasattr(stream, "flush_debug")
+
+    # Send our startup parameters.
+    send_startup(stream, proto=protocol(1234, 5679))
+
+    # Look at the SSL response.
+    resp = stream.read(1)
+    if debugging:
+        stream.flush_debug(prefix="  ")
+
+    if resp == b"N":
+        raise RuntimeError("server does not support SSLRequest")
+    if resp != b"S":
+        raise RuntimeError(f"unexpected response of type {resp!r} during TLS startup")
+
+    tls = _TLSStream(stream, context)
+    tls.handshake()
+
+    if debugging:
+        tls = _DebugStream(tls, stream._out)
+
+    try:
+        yield tls
+        # TODO: teardown/unwrap the connection?
+    finally:
+        if debugging:
+            tls.flush_debug(prefix="? ")
diff --git a/src/test/python/pytest.ini b/src/test/python/pytest.ini
new file mode 100644
index 00000000000..ab7a6e7fb96
--- /dev/null
+++ b/src/test/python/pytest.ini
@@ -0,0 +1,4 @@
+[pytest]
+
+markers =
+    slow: mark test as slow
diff --git a/src/test/python/requirements.txt b/src/test/python/requirements.txt
new file mode 100644
index 00000000000..0dfcffb83e0
--- /dev/null
+++ b/src/test/python/requirements.txt
@@ -0,0 +1,11 @@
+black
+# cryptography 35.x and later add many platform/toolchain restrictions, beware
+cryptography~=3.4.8
+# TODO: figure out why 2.10.70 broke things
+# (probably https://github.com/construct/construct/pull/1015)
+construct==2.10.69
+isort~=5.6
+# TODO: update to psycopg[c] 3.1
+psycopg2~=2.9.7
+pytest~=7.3
+pytest-asyncio~=0.21.0
diff --git a/src/test/python/server/__init__.py b/src/test/python/server/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/src/test/python/server/conftest.py b/src/test/python/server/conftest.py
new file mode 100644
index 00000000000..42af80c73ee
--- /dev/null
+++ b/src/test/python/server/conftest.py
@@ -0,0 +1,141 @@
+#
+# Portions Copyright 2021 VMware, Inc.
+# Portions Copyright 2023 Timescale, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import collections
+import contextlib
+import os
+import shutil
+import socket
+import subprocess
+import sys
+
+import pytest
+
+import pq3
+
+BLOCKING_TIMEOUT = 2  # the number of seconds to wait for blocking calls
+
+
+def cleanup_prior_instance(datadir):
+    """
+    Clean up an existing data directory, but make sure it actually looks like a
+    data directory first. (Empty folders will remain untouched, since initdb can
+    populate them.)
+    """
+    required_entries = set(["base", "PG_VERSION", "postgresql.conf"])
+    empty = True
+
+    try:
+        with os.scandir(datadir) as entries:
+            for e in entries:
+                empty = False
+                required_entries.discard(e.name)
+
+    except FileNotFoundError:
+        return  # nothing to clean up
+
+    if empty:
+        return  # initdb can handle an empty datadir
+
+    if required_entries:
+        pytest.fail(
+            f"--temp-instance directory \"{datadir}\" is not empty and doesn't look like a data directory (missing {', '.join(required_entries)})"
+        )
+
+    # Okay, seems safe enough now.
+    shutil.rmtree(datadir)
+
+
[email protected](scope="session")
+def postgres_instance(pytestconfig, unused_tcp_port_factory):
+    """
+    If --temp-instance has been passed to pytest, this fixture runs a temporary
+    Postgres instance on an available port. Otherwise, the fixture will attempt
+    to contact a running Postgres server on (PGHOST, PGPORT); dependent tests
+    will be skipped if the connection fails.
+
+    Yields a (host, port) tuple for connecting to the server.
+    """
+    PGInstance = collections.namedtuple("PGInstance", ["addr", "temporary"])
+
+    datadir = pytestconfig.getoption("temp_instance")
+    if datadir:
+        # We were told to create a temporary instance. Use pg_ctl to set it up
+        # on an unused port.
+        cleanup_prior_instance(datadir)
+        subprocess.run(["pg_ctl", "-D", datadir, "init"], check=True)
+
+        # The CI looks for *.log files to upload, so the file name here isn't
+        # completely arbitrary.
+        log = os.path.join(datadir, "postmaster.log")
+        port = unused_tcp_port_factory()
+
+        subprocess.run(
+            [
+                "pg_ctl",
+                "-D",
+                datadir,
+                "-l",
+                log,
+                "-o",
+                " ".join(
+                    [
+                        f"-c port={port}",
+                        "-c listen_addresses=localhost",
+                        "-c log_connections=on",
+                        "-c session_preload_libraries=oauthtest",
+                        "-c oauth_validator_libraries=oauthtest",
+                    ]
+                ),
+                "start",
+            ],
+            check=True,
+        )
+
+        yield ("localhost", port)
+
+        subprocess.run(["pg_ctl", "-D", datadir, "stop"], check=True)
+
+    else:
+        # Try to contact an already running server; skip the suite if we can't
+        # find one.
+        addr = (pq3.pghost(), pq3.pgport())
+
+        try:
+            with socket.create_connection(addr, timeout=BLOCKING_TIMEOUT):
+                pass
+        except ConnectionError as e:
+            pytest.skip(f"unable to connect to Postgres server at {addr}: {e}")
+
+        yield addr
+
+
[email protected]
+def connect(postgres_instance):
+    """
+    A factory fixture that, when called, returns a socket connected to a
+    Postgres server, wrapped in a pq3 connection. Dependent tests will be
+    skipped if no server is available.
+    """
+    addr = postgres_instance
+
+    # Set up an ExitStack to handle safe cleanup of all of the moving pieces.
+    with contextlib.ExitStack() as stack:
+
+        def conn_factory():
+            sock = socket.create_connection(addr, timeout=BLOCKING_TIMEOUT)
+
+            # Have ExitStack close our socket.
+            stack.enter_context(sock)
+
+            # Wrap the connection in a pq3 layer and have ExitStack clean it up
+            # too.
+            wrap_ctx = pq3.wrap(sock, debug_stream=sys.stdout)
+            conn = stack.enter_context(wrap_ctx)
+
+            return conn
+
+        yield conn_factory
diff --git a/src/test/python/server/meson.build b/src/test/python/server/meson.build
new file mode 100644
index 00000000000..85534b9cc99
--- /dev/null
+++ b/src/test/python/server/meson.build
@@ -0,0 +1,18 @@
+# Copyright (c) 2024, PostgreSQL Global Development Group
+
+oauthtest_sources = files(
+  'oauthtest.c',
+)
+
+if host_system == 'windows'
+  oauthtest_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'oauthtest',
+    '--FILEDESC', 'passthrough module to validate OAuth tests',
+  ])
+endif
+
+oauthtest = shared_module('oauthtest',
+  oauthtest_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += oauthtest
diff --git a/src/test/python/server/oauthtest.c b/src/test/python/server/oauthtest.c
new file mode 100644
index 00000000000..415748b9a66
--- /dev/null
+++ b/src/test/python/server/oauthtest.c
@@ -0,0 +1,118 @@
+/*-------------------------------------------------------------------------
+ *
+ * oauthtest.c
+ *	  Test module for serverside OAuth token validation callbacks
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/test/python/server/oauthtest.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "fmgr.h"
+#include "libpq/oauth.h"
+#include "utils/guc.h"
+#include "utils/memutils.h"
+
+PG_MODULE_MAGIC;
+
+static void test_startup(ValidatorModuleState *state);
+static void test_shutdown(ValidatorModuleState *state);
+static ValidatorModuleResult *test_validate(ValidatorModuleState *state,
+											const char *token,
+											const char *role);
+
+static const OAuthValidatorCallbacks callbacks = {
+	.startup_cb = test_startup,
+	.shutdown_cb = test_shutdown,
+	.validate_cb = test_validate,
+};
+
+static char *expected_bearer = "";
+static bool set_authn_id = false;
+static char *authn_id = "";
+static bool reflect_role = false;
+
+void
+_PG_init(void)
+{
+	DefineCustomStringVariable("oauthtest.expected_bearer",
+							   "Expected Bearer token for future connections",
+							   NULL,
+							   &expected_bearer,
+							   "",
+							   PGC_SIGHUP,
+							   0,
+							   NULL, NULL, NULL);
+
+	DefineCustomBoolVariable("oauthtest.set_authn_id",
+							 "Whether to set an authenticated identity",
+							 NULL,
+							 &set_authn_id,
+							 false,
+							 PGC_SIGHUP,
+							 0,
+							 NULL, NULL, NULL);
+	DefineCustomStringVariable("oauthtest.authn_id",
+							   "Authenticated identity to use for future connections",
+							   NULL,
+							   &authn_id,
+							   "",
+							   PGC_SIGHUP,
+							   0,
+							   NULL, NULL, NULL);
+
+	DefineCustomBoolVariable("oauthtest.reflect_role",
+							 "Ignore the bearer token; use the requested role as the authn_id",
+							 NULL,
+							 &reflect_role,
+							 false,
+							 PGC_SIGHUP,
+							 0,
+							 NULL, NULL, NULL);
+
+	MarkGUCPrefixReserved("oauthtest");
+}
+
+const OAuthValidatorCallbacks *
+_PG_oauth_validator_module_init(void)
+{
+	return &callbacks;
+}
+
+static void
+test_startup(ValidatorModuleState *state)
+{
+}
+
+static void
+test_shutdown(ValidatorModuleState *state)
+{
+}
+
+static ValidatorModuleResult *
+test_validate(ValidatorModuleState *state, const char *token, const char *role)
+{
+	ValidatorModuleResult *res;
+
+	res = palloc0(sizeof(ValidatorModuleResult));	/* TODO: palloc context? */
+
+	if (reflect_role)
+	{
+		res->authorized = true;
+		res->authn_id = pstrdup(role);	/* TODO: constify? */
+	}
+	else
+	{
+		if (*expected_bearer && strcmp(token, expected_bearer) == 0)
+			res->authorized = true;
+		if (set_authn_id)
+			res->authn_id = authn_id;
+	}
+
+	return res;
+}
diff --git a/src/test/python/server/test_oauth.py b/src/test/python/server/test_oauth.py
new file mode 100644
index 00000000000..2839343ffa1
--- /dev/null
+++ b/src/test/python/server/test_oauth.py
@@ -0,0 +1,1080 @@
+#
+# Copyright 2021 VMware, Inc.
+# Portions Copyright 2023 Timescale, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import base64
+import contextlib
+import json
+import os
+import pathlib
+import platform
+import secrets
+import shlex
+import shutil
+import socket
+import struct
+from multiprocessing import shared_memory
+
+import psycopg2
+import pytest
+from construct import Container
+from psycopg2 import sql
+
+import pq3
+
+from .conftest import BLOCKING_TIMEOUT
+
+MAX_SASL_MESSAGE_LENGTH = 65535
+
+INVALID_AUTHORIZATION_ERRCODE = b"28000"
+PROTOCOL_VIOLATION_ERRCODE = b"08P01"
+FEATURE_NOT_SUPPORTED_ERRCODE = b"0A000"
+
+SHARED_MEM_NAME = "oauth-pytest"
+MAX_UINT16 = 2**16 - 1
+
+
[email protected]
+def prepend_file(path, lines, *, suffix=".bak"):
+    """
+    A context manager that prepends a file on disk with the desired lines of
+    text. When the context manager is exited, the file will be restored to its
+    original contents.
+    """
+    # First make a backup of the original file.
+    bak = path + suffix
+    shutil.copy2(path, bak)
+
+    try:
+        # Write the new lines, followed by the original file content.
+        with open(path, "w") as new, open(bak, "r") as orig:
+            new.writelines(lines)
+            shutil.copyfileobj(orig, new)
+
+        # Return control to the calling code.
+        yield
+
+    finally:
+        # Put the backup back into place.
+        os.replace(bak, path)
+
+
[email protected](scope="module")
+def oauth_ctx(postgres_instance):
+    """
+    Creates a database and user that use the oauth auth method. The context
+    object contains the dbname and user attributes as strings to be used during
+    connection, as well as the issuer and scope that have been set in the HBA
+    configuration.
+
+    This fixture assumes that the standard PG* environment variables point to a
+    server running on a local machine, and that the PGUSER has rights to create
+    databases and roles.
+    """
+    id = secrets.token_hex(4)
+
+    class Context:
+        dbname = "oauth_test_" + id
+
+        user = "oauth_user_" + id
+        punct_user = "oauth_\"'? ;&!_user_" + id  # username w/ punctuation
+        map_user = "oauth_map_user_" + id
+        authz_user = "oauth_authz_user_" + id
+
+        issuer = "https://example.com/" + id
+        scope = "openid " + id
+
+    ctx = Context()
+    hba_lines = [
+        f'host {ctx.dbname} {ctx.map_user}   samehost oauth issuer="{ctx.issuer}" scope="{ctx.scope}" map=oauth\n',
+        f'host {ctx.dbname} {ctx.authz_user} samehost oauth issuer="{ctx.issuer}" scope="{ctx.scope}" delegate_ident_mapping=1\n',
+        f'host {ctx.dbname} all              samehost oauth issuer="{ctx.issuer}" scope="{ctx.scope}"\n',
+    ]
+    ident_lines = [r"oauth /^(.*)@example\.com$ \1"]
+
+    if platform.system() == "Windows":
+        # XXX why is 'samehost' not behaving as expected on Windows?
+        for l in list(hba_lines):
+            hba_lines.append(l.replace("samehost", "::1/128"))
+
+    host, port = postgres_instance
+    conn = psycopg2.connect(host=host, port=port)
+    conn.autocommit = True
+
+    with contextlib.closing(conn):
+        c = conn.cursor()
+
+        # Create our roles and database.
+        user = sql.Identifier(ctx.user)
+        punct_user = sql.Identifier(ctx.punct_user)
+        map_user = sql.Identifier(ctx.map_user)
+        authz_user = sql.Identifier(ctx.authz_user)
+        dbname = sql.Identifier(ctx.dbname)
+
+        c.execute(sql.SQL("CREATE ROLE {} LOGIN;").format(user))
+        c.execute(sql.SQL("CREATE ROLE {} LOGIN;").format(punct_user))
+        c.execute(sql.SQL("CREATE ROLE {} LOGIN;").format(map_user))
+        c.execute(sql.SQL("CREATE ROLE {} LOGIN;").format(authz_user))
+        c.execute(sql.SQL("CREATE DATABASE {};").format(dbname))
+
+        # Replace pg_hba and pg_ident.
+        c.execute("SHOW hba_file;")
+        hba = c.fetchone()[0]
+
+        c.execute("SHOW ident_file;")
+        ident = c.fetchone()[0]
+
+        with prepend_file(hba, hba_lines), prepend_file(ident, ident_lines):
+            c.execute("SELECT pg_reload_conf();")
+
+            # Use the new database and user.
+            yield ctx
+
+        # Put things back the way they were.
+        c.execute("SELECT pg_reload_conf();")
+
+        c.execute(sql.SQL("DROP DATABASE {};").format(dbname))
+        c.execute(sql.SQL("DROP ROLE {};").format(authz_user))
+        c.execute(sql.SQL("DROP ROLE {};").format(map_user))
+        c.execute(sql.SQL("DROP ROLE {};").format(punct_user))
+        c.execute(sql.SQL("DROP ROLE {};").format(user))
+
+
[email protected]()
+def conn(oauth_ctx, connect):
+    """
+    A convenience wrapper for connect(). The main purpose of this fixture is to
+    make sure oauth_ctx runs its setup code before the connection is made.
+    """
+    return connect()
+
+
+def bearer_token(*, size=16):
+    """
+    Generates a Bearer token using secrets.token_urlsafe(). The generated token
+    size in bytes may be specified; if unset, a small 16-byte token will be
+    generated.
+    """
+
+    if size % 4:
+        raise ValueError(f"requested token size {size} is not a multiple of 4")
+
+    token = secrets.token_urlsafe(size // 4 * 3)
+    assert len(token) == size
+
+    return token
+
+
+def begin_oauth_handshake(conn, oauth_ctx, *, user=None):
+    if user is None:
+        user = oauth_ctx.authz_user
+
+    pq3.send_startup(conn, user=user, database=oauth_ctx.dbname)
+
+    resp = pq3.recv1(conn)
+    assert resp.type == pq3.types.AuthnRequest
+
+    # The server should advertise exactly one mechanism.
+    assert resp.payload.type == pq3.authn.SASL
+    assert resp.payload.body == [b"OAUTHBEARER", b""]
+
+
+def send_initial_response(conn, *, auth=None, bearer=None):
+    """
+    Sends the OAUTHBEARER initial response on the connection, using the given
+    bearer token. Alternatively to a bearer token, the initial response's auth
+    field may be explicitly specified to test corner cases.
+    """
+    if bearer is not None and auth is not None:
+        raise ValueError("exactly one of the auth and bearer kwargs must be set")
+
+    if bearer is not None:
+        auth = b"Bearer " + bearer
+
+    if auth is None:
+        raise ValueError("exactly one of the auth and bearer kwargs must be set")
+
+    initial = pq3.SASLInitialResponse.build(
+        dict(
+            name=b"OAUTHBEARER",
+            data=b"n,,\x01auth=" + auth + b"\x01\x01",
+        )
+    )
+    pq3.send(conn, pq3.types.PasswordMessage, initial)
+
+
+def expect_handshake_success(conn):
+    """
+    Validates that the server responds with an AuthnOK message, and then drains
+    the connection until a ReadyForQuery message is received.
+    """
+    resp = pq3.recv1(conn)
+
+    assert resp.type == pq3.types.AuthnRequest
+    assert resp.payload.type == pq3.authn.OK
+    assert not resp.payload.body
+
+    receive_until(conn, pq3.types.ReadyForQuery)
+
+
+def expect_handshake_failure(conn, oauth_ctx):
+    """
+    Performs the OAUTHBEARER SASL failure "handshake" and validates the server's
+    side of the conversation, including the final ErrorResponse.
+    """
+
+    # We expect a discovery "challenge" back from the server before the authn
+    # failure message.
+    resp = pq3.recv1(conn)
+    assert resp.type == pq3.types.AuthnRequest
+
+    req = resp.payload
+    assert req.type == pq3.authn.SASLContinue
+
+    body = json.loads(req.body)
+    assert body["status"] == "invalid_token"
+    assert body["scope"] == oauth_ctx.scope
+
+    expected_config = oauth_ctx.issuer + "/.well-known/openid-configuration"
+    assert body["openid-configuration"] == expected_config
+
+    # Send the dummy response to complete the failed handshake.
+    pq3.send(conn, pq3.types.PasswordMessage, b"\x01")
+    resp = pq3.recv1(conn)
+
+    err = ExpectedError(INVALID_AUTHORIZATION_ERRCODE, "bearer authentication failed")
+    err.match(resp)
+
+
+def receive_until(conn, type):
+    """
+    receive_until pulls packets off the pq3 connection until a packet with the
+    desired type is found, or an error response is received.
+    """
+    while True:
+        pkt = pq3.recv1(conn)
+
+        if pkt.type == type:
+            return pkt
+        elif pkt.type == pq3.types.ErrorResponse:
+            raise RuntimeError(
+                f"received error response from peer: {pkt.payload.fields!r}"
+            )
+
+
[email protected]()
+def setup_validator(postgres_instance):
+    """
+    A per-test fixture that sets up the test validator with expected behavior.
+    The setting will be reverted during teardown.
+    """
+    host, port = postgres_instance
+    conn = psycopg2.connect(host=host, port=port)
+    conn.autocommit = True
+
+    with contextlib.closing(conn):
+        c = conn.cursor()
+        prev = dict()
+
+        def setter(**gucs):
+            for guc, val in gucs.items():
+                # Save the previous value.
+                c.execute(sql.SQL("SHOW oauthtest.{};").format(sql.Identifier(guc)))
+                prev[guc] = c.fetchone()[0]
+
+                c.execute(
+                    sql.SQL("ALTER SYSTEM SET oauthtest.{} TO %s;").format(
+                        sql.Identifier(guc)
+                    ),
+                    (val,),
+                )
+                c.execute("SELECT pg_reload_conf();")
+
+        yield setter
+
+        # Restore the previous values.
+        for guc, val in prev.items():
+            c.execute(
+                sql.SQL("ALTER SYSTEM SET oauthtest.{} TO %s;").format(
+                    sql.Identifier(guc)
+                ),
+                (val,),
+            )
+            c.execute("SELECT pg_reload_conf();")
+
+
[email protected]("token_len", [16, 1024, 4096])
[email protected](
+    "auth_prefix",
+    [
+        b"Bearer ",
+        b"bearer ",
+        b"Bearer    ",
+    ],
+)
+def test_oauth(setup_validator, connect, oauth_ctx, auth_prefix, token_len):
+    # Generate our bearer token with the desired length.
+    token = bearer_token(size=token_len)
+    setup_validator(expected_bearer=token)
+
+    conn = connect()
+    begin_oauth_handshake(conn, oauth_ctx)
+
+    auth = auth_prefix + token.encode("ascii")
+    send_initial_response(conn, auth=auth)
+    expect_handshake_success(conn)
+
+    # Make sure that the server has not set an authenticated ID.
+    pq3.send(conn, pq3.types.Query, query=b"SELECT system_user;")
+    resp = receive_until(conn, pq3.types.DataRow)
+
+    row = resp.payload
+    assert row.columns == [None]
+
+
[email protected](
+    "token_value",
+    [
+        "abcdzA==",
+        "123456M=",
+        "x-._~+/x",
+    ],
+)
+def test_oauth_bearer_corner_cases(setup_validator, connect, oauth_ctx, token_value):
+    setup_validator(expected_bearer=token_value)
+
+    conn = connect()
+    begin_oauth_handshake(conn, oauth_ctx)
+
+    send_initial_response(conn, bearer=token_value.encode("ascii"))
+
+    expect_handshake_success(conn)
+
+
[email protected](
+    "user,authn_id,should_succeed",
+    [
+        pytest.param(
+            lambda ctx: ctx.user,
+            lambda ctx: ctx.user,
+            True,
+            id="validator authn: succeeds when authn_id == username",
+        ),
+        pytest.param(
+            lambda ctx: ctx.user,
+            lambda ctx: None,
+            False,
+            id="validator authn: fails when authn_id is not set",
+        ),
+        pytest.param(
+            lambda ctx: ctx.user,
+            lambda ctx: "",
+            False,
+            id="validator authn: fails when authn_id is empty",
+        ),
+        pytest.param(
+            lambda ctx: ctx.user,
+            lambda ctx: ctx.authz_user,
+            False,
+            id="validator authn: fails when authn_id != username",
+        ),
+        pytest.param(
+            lambda ctx: ctx.map_user,
+            lambda ctx: ctx.map_user + "@example.com",
+            True,
+            id="validator with map: succeeds when authn_id matches map",
+        ),
+        pytest.param(
+            lambda ctx: ctx.map_user,
+            lambda ctx: None,
+            False,
+            id="validator with map: fails when authn_id is not set",
+        ),
+        pytest.param(
+            lambda ctx: ctx.map_user,
+            lambda ctx: ctx.map_user + "@example.net",
+            False,
+            id="validator with map: fails when authn_id doesn't match map",
+        ),
+        pytest.param(
+            lambda ctx: ctx.authz_user,
+            lambda ctx: None,
+            True,
+            id="validator authz: succeeds with no authn_id",
+        ),
+        pytest.param(
+            lambda ctx: ctx.authz_user,
+            lambda ctx: "",
+            True,
+            id="validator authz: succeeds with empty authn_id",
+        ),
+        pytest.param(
+            lambda ctx: ctx.authz_user,
+            lambda ctx: "postgres",
+            True,
+            id="validator authz: succeeds with basic username",
+        ),
+        pytest.param(
+            lambda ctx: ctx.authz_user,
+            lambda ctx: "[email protected]",
+            True,
+            id="validator authz: succeeds with email address",
+        ),
+    ],
+)
+def test_oauth_authn_id(
+    setup_validator, connect, oauth_ctx, user, authn_id, should_succeed
+):
+    token = bearer_token()
+    authn_id = authn_id(oauth_ctx)
+
+    # Set up the validator appropriately.
+    gucs = dict(expected_bearer=token)
+    if authn_id is not None:
+        gucs["set_authn_id"] = True
+        gucs["authn_id"] = authn_id
+    setup_validator(**gucs)
+
+    conn = connect()
+    username = user(oauth_ctx)
+    begin_oauth_handshake(conn, oauth_ctx, user=username)
+    send_initial_response(conn, bearer=token.encode("ascii"))
+
+    if not should_succeed:
+        expect_handshake_failure(conn, oauth_ctx)
+        return
+
+    expect_handshake_success(conn)
+
+    # Check the reported authn_id.
+    pq3.send(conn, pq3.types.Query, query=b"SELECT system_user;")
+    resp = receive_until(conn, pq3.types.DataRow)
+
+    expected = authn_id
+    if expected is not None:
+        expected = b"oauth:" + expected.encode("ascii")
+
+    row = resp.payload
+    assert row.columns == [expected]
+
+
+class ExpectedError(object):
+    def __init__(self, code, msg=None, detail=None):
+        self.code = code
+        self.msg = msg
+        self.detail = detail
+
+        # Protect against the footgun of an accidental empty string, which will
+        # "match" anything. If you don't want to match message or detail, just
+        # don't pass them.
+        if self.msg == "":
+            raise ValueError("msg must be non-empty or None")
+        if self.detail == "":
+            raise ValueError("detail must be non-empty or None")
+
+    def _getfield(self, resp, type):
+        """
+        Searches an ErrorResponse for a single field of the given type (e.g.
+        "M", "C", "D") and returns its value. Asserts if it doesn't find exactly
+        one field.
+        """
+        prefix = type.encode("ascii")
+        fields = [f for f in resp.payload.fields if f.startswith(prefix)]
+
+        assert len(fields) == 1
+        return fields[0][1:]  # strip off the type byte
+
+    def match(self, resp):
+        """
+        Checks that the given response matches the expected code, message, and
+        detail (if given). The error code must match exactly. The expected
+        message and detail must be contained within the actual strings.
+        """
+        assert resp.type == pq3.types.ErrorResponse
+
+        code = self._getfield(resp, "C")
+        assert code == self.code
+
+        if self.msg:
+            msg = self._getfield(resp, "M")
+            expected = self.msg.encode("utf-8")
+            assert expected in msg
+
+        if self.detail:
+            detail = self._getfield(resp, "D")
+            expected = self.detail.encode("utf-8")
+            assert expected in detail
+
+
+def test_oauth_rejected_bearer(conn, oauth_ctx):
+    begin_oauth_handshake(conn, oauth_ctx)
+
+    # Send a bearer token that doesn't match what the validator expects. It
+    # should fail the connection.
+    send_initial_response(conn, bearer=b"xxxxxx")
+
+    expect_handshake_failure(conn, oauth_ctx)
+
+
[email protected](
+    "bad_bearer",
+    [
+        b"Bearer    ",
+        b"Bearer a===b",
+        b"Bearer hello!",
+        b"Bearer trailingspace ",
+        b"Bearer trailingtab\t",
+        b"Bearer [email protected]",
+        b"Beare abcd",
+        b" Bearer leadingspace",
+        b'OAuth realm="Example"',
+        b"",
+    ],
+)
+def test_oauth_invalid_bearer(setup_validator, connect, oauth_ctx, bad_bearer):
+    # Tell the validator to accept any token. This ensures that the invalid
+    # bearer tokens are rejected before the validation step.
+    setup_validator(reflect_role=True)
+
+    conn = connect()
+    begin_oauth_handshake(conn, oauth_ctx)
+    send_initial_response(conn, auth=bad_bearer)
+
+    expect_handshake_failure(conn, oauth_ctx)
+
+
[email protected]
[email protected](
+    "resp_type,resp,err",
+    [
+        pytest.param(
+            None,
+            None,
+            None,
+            marks=pytest.mark.slow,
+            id="no response (expect timeout)",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            b"hello",
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "did not send a kvsep response",
+            ),
+            id="bad dummy response",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            b"\x01\x01",
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "did not send a kvsep response",
+            ),
+            id="multiple kvseps",
+        ),
+        pytest.param(
+            pq3.types.Query,
+            dict(query=b""),
+            ExpectedError(PROTOCOL_VIOLATION_ERRCODE, "expected SASL response"),
+            id="bad response message type",
+        ),
+    ],
+)
+def test_oauth_bad_response_to_error_challenge(conn, oauth_ctx, resp_type, resp, err):
+    begin_oauth_handshake(conn, oauth_ctx)
+
+    # Send an empty auth initial response, which will force an authn failure.
+    send_initial_response(conn, auth=b"")
+
+    # We expect a discovery "challenge" back from the server before the authn
+    # failure message.
+    pkt = pq3.recv1(conn)
+    assert pkt.type == pq3.types.AuthnRequest
+
+    req = pkt.payload
+    assert req.type == pq3.authn.SASLContinue
+
+    body = json.loads(req.body)
+    assert body["status"] == "invalid_token"
+
+    if resp_type is None:
+        # Do not send the dummy response. We should time out and not get a
+        # response from the server.
+        with pytest.raises(socket.timeout):
+            conn.read(1)
+
+        # Done with the test.
+        return
+
+    # Send the bad response.
+    pq3.send(conn, resp_type, resp)
+
+    # Make sure the server fails the connection correctly.
+    pkt = pq3.recv1(conn)
+    err.match(pkt)
+
+
[email protected](
+    "type,payload,err",
+    [
+        pytest.param(
+            pq3.types.ErrorResponse,
+            dict(fields=[b""]),
+            ExpectedError(PROTOCOL_VIOLATION_ERRCODE, "expected SASL response"),
+            id="error response in initial message",
+        ),
+        pytest.param(
+            None,
+            # Sending an actual 65k packet results in ECONNRESET on Windows, and
+            # it floods the tests' connection log uselessly, so just fake the
+            # length and send a smaller number of bytes.
+            dict(
+                type=pq3.types.PasswordMessage,
+                len=MAX_SASL_MESSAGE_LENGTH + 1,
+                payload=b"x" * 512,
+            ),
+            ExpectedError(
+                INVALID_AUTHORIZATION_ERRCODE, "bearer authentication failed"
+            ),
+            id="overlong initial response data",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"SCRAM-SHA-256")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE, "invalid SASL authentication mechanism"
+            ),
+            id="bad SASL mechanism selection",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", len=2, data=b"x")),
+            ExpectedError(PROTOCOL_VIOLATION_ERRCODE, "insufficient data"),
+            id="SASL data underflow",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", len=0, data=b"x")),
+            ExpectedError(PROTOCOL_VIOLATION_ERRCODE, "invalid message format"),
+            id="SASL data overflow",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "message is empty",
+            ),
+            id="empty",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(name=b"OAUTHBEARER", data=b"n,,\x01auth=\x01\x01\0")
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "length does not match input length",
+            ),
+            id="contains null byte",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"\x01")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "Unexpected channel-binding flag",  # XXX this is a bit strange
+            ),
+            id="initial error response",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(name=b"OAUTHBEARER", data=b"p=tls-server-end-point,,\x01")
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "server does not support channel binding",
+            ),
+            id="uses channel binding",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"x,,\x01")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "Unexpected channel-binding flag",
+            ),
+            id="invalid channel binding specifier",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"y")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "Comma expected",
+            ),
+            id="bad GS2 header: missing channel binding terminator",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"y,a")),
+            ExpectedError(
+                FEATURE_NOT_SUPPORTED_ERRCODE,
+                "client uses authorization identity",
+            ),
+            id="bad GS2 header: authzid in use",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"y,b,")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "Unexpected attribute",
+            ),
+            id="bad GS2 header: extra attribute",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"y,")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                'Unexpected attribute "0x00"',  # XXX this is a bit strange
+            ),
+            id="bad GS2 header: missing authzid terminator",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"y,,")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "Key-value separator expected",
+            ),
+            id="missing initial kvsep",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"y,,")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "Key-value separator expected",
+            ),
+            id="missing initial kvsep",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(name=b"OAUTHBEARER", data=b"y,,\x01\x01")
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "does not contain an auth value",
+            ),
+            id="missing auth value: empty key-value list",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(name=b"OAUTHBEARER", data=b"y,,\x01host=example.com\x01\x01")
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "does not contain an auth value",
+            ),
+            id="missing auth value: other keys present",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(name=b"OAUTHBEARER", data=b"y,,\x01host=example.com")
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "unterminated key/value pair",
+            ),
+            id="missing value terminator",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"y,,\x01")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "did not contain a final terminator",
+            ),
+            id="missing list terminator: empty list",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(name=b"OAUTHBEARER", data=b"y,,\x01auth=Bearer 0\x01")
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "did not contain a final terminator",
+            ),
+            id="missing list terminator: with auth value",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(name=b"OAUTHBEARER", data=b"y,,\x01auth=Bearer 0\x01\x01blah")
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "additional data after the final terminator",
+            ),
+            id="additional key after terminator",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(name=b"OAUTHBEARER", data=b"y,,\x01key\x01\x01")
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "key without a value",
+            ),
+            id="key without value",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(
+                    name=b"OAUTHBEARER",
+                    data=b"y,,\x01auth=Bearer 0\x01auth=Bearer 1\x01\x01",
+                )
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "contains multiple auth values",
+            ),
+            id="multiple auth values",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(
+                    name=b"OAUTHBEARER",
+                    data=b"y,,\x01=\x01\x01",
+                )
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "empty key name",
+            ),
+            id="empty key",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(
+                    name=b"OAUTHBEARER",
+                    data=b"y,,\x01my key= \x01\x01",
+                )
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "invalid key name",
+            ),
+            id="whitespace in key name",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(
+                    name=b"OAUTHBEARER",
+                    data=b"y,,\x01key=a\x05b\x01\x01",
+                )
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "invalid value",
+            ),
+            id="junk in value",
+        ),
+    ],
+)
+def test_oauth_bad_initial_response(conn, oauth_ctx, type, payload, err):
+    begin_oauth_handshake(conn, oauth_ctx)
+
+    # The server expects a SASL response; give it something else instead.
+    if type is not None:
+        # Build a new packet of the desired type.
+        if not isinstance(payload, dict):
+            payload = dict(payload_data=payload)
+        pq3.send(conn, type, **payload)
+    else:
+        # The test has a custom packet to send. (The only reason to do this is
+        # if the packet is corrupt or otherwise unbuildable/unparsable, so we
+        # don't use the standard pq3.send().)
+        conn.write(pq3.Pq3.build(payload))
+        conn.end_packet(Container(payload))
+
+    resp = pq3.recv1(conn)
+    err.match(resp)
+
+
+def test_oauth_empty_initial_response(setup_validator, connect, oauth_ctx):
+    token = bearer_token()
+    setup_validator(expected_bearer=token)
+
+    conn = connect()
+    begin_oauth_handshake(conn, oauth_ctx)
+
+    # Send an initial response without data.
+    initial = pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER"))
+    pq3.send(conn, pq3.types.PasswordMessage, initial)
+
+    # The server should respond with an empty challenge so we can send the data
+    # it wants.
+    pkt = pq3.recv1(conn)
+
+    assert pkt.type == pq3.types.AuthnRequest
+    assert pkt.payload.type == pq3.authn.SASLContinue
+    assert not pkt.payload.body
+
+    # Now send the initial data.
+    data = b"n,,\x01auth=Bearer " + token.encode("ascii") + b"\x01\x01"
+    pq3.send(conn, pq3.types.PasswordMessage, data)
+
+    # Server should now complete the handshake.
+    expect_handshake_success(conn)
+
+
+# TODO: see if there's a way to test this easily after the API switch
+def xtest_oauth_no_validator(setup_validator, oauth_ctx, connect):
+    # Clear out our validator command, then establish a new connection.
+    set_validator("")
+    conn = connect()
+
+    begin_oauth_handshake(conn, oauth_ctx)
+    send_initial_response(conn, bearer=bearer_token())
+
+    # The server should fail the connection.
+    expect_handshake_failure(conn, oauth_ctx)
+
+
[email protected](
+    "user",
+    [
+        pytest.param(
+            lambda ctx: ctx.user,
+            id="basic username",
+        ),
+        pytest.param(
+            lambda ctx: ctx.punct_user,
+            id="'unsafe' characters are passed through correctly",
+        ),
+    ],
+)
+def test_oauth_validator_role(setup_validator, oauth_ctx, connect, user):
+    username = user(oauth_ctx)
+
+    # Tell the validator to reflect the PGUSER as the authenticated identity.
+    setup_validator(reflect_role=True)
+    conn = connect()
+
+    # Log in. Note that reflection ignores the bearer token.
+    begin_oauth_handshake(conn, oauth_ctx, user=username)
+    send_initial_response(conn, bearer=b"dontcare")
+    expect_handshake_success(conn)
+
+    # Check the user identity.
+    pq3.send(conn, pq3.types.Query, query=b"SELECT system_user;")
+    resp = receive_until(conn, pq3.types.DataRow)
+
+    row = resp.payload
+    expected = b"oauth:" + username.encode("utf-8")
+    assert row.columns == [expected]
+
+
[email protected]
+def odd_oauth_ctx(postgres_instance, oauth_ctx):
+    """
+    Adds an HBA entry with messed up issuer/scope settings, to pin the server
+    behavior.
+
+    TODO: these should really be rejected in the HBA rather than passed through
+    by the server.
+    """
+    id = secrets.token_hex(4)
+
+    class Context:
+        user = oauth_ctx.user
+        dbname = oauth_ctx.dbname
+
+        # Both of these embedded double-quotes are invalid; they're prohibited
+        # in both URLs and OAuth scope identifiers.
+        issuer = oauth_ctx.issuer + '/"/'
+        scope = oauth_ctx.scope + ' quo"ted'
+
+    ctx = Context()
+    hba_issuer = ctx.issuer.replace('"', '""')
+    hba_scope = ctx.scope.replace('"', '""')
+    hba_lines = [
+        f'host {ctx.dbname} {ctx.user} samehost oauth issuer="{hba_issuer}" scope="{hba_scope}"\n',
+    ]
+
+    if platform.system() == "Windows":
+        # XXX why is 'samehost' not behaving as expected on Windows?
+        for l in list(hba_lines):
+            hba_lines.append(l.replace("samehost", "::1/128"))
+
+    host, port = postgres_instance
+    conn = psycopg2.connect(host=host, port=port)
+    conn.autocommit = True
+
+    with contextlib.closing(conn):
+        c = conn.cursor()
+
+        # Replace pg_hba. Note that it's already been replaced once by
+        # oauth_ctx, so use a different backup prefix in prepend_file().
+        c.execute("SHOW hba_file;")
+        hba = c.fetchone()[0]
+
+        with prepend_file(hba, hba_lines, suffix=".bak2"):
+            c.execute("SELECT pg_reload_conf();")
+
+            yield ctx
+
+        # Put things back the way they were.
+        c.execute("SELECT pg_reload_conf();")
+
+
+def test_odd_server_response(odd_oauth_ctx, connect):
+    """
+    Verifies that the server is correctly escaping the JSON in its failure
+    response.
+    """
+    conn = connect()
+    begin_oauth_handshake(conn, odd_oauth_ctx, user=odd_oauth_ctx.user)
+
+    # Send an empty auth initial response, which will force an authn failure.
+    send_initial_response(conn, auth=b"")
+
+    expect_handshake_failure(conn, odd_oauth_ctx)
diff --git a/src/test/python/server/test_server.py b/src/test/python/server/test_server.py
new file mode 100644
index 00000000000..02126dba792
--- /dev/null
+++ b/src/test/python/server/test_server.py
@@ -0,0 +1,21 @@
+#
+# Copyright 2021 VMware, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import pq3
+
+
+def test_handshake(connect):
+    """Basic sanity check."""
+    conn = connect()
+
+    pq3.handshake(conn, user=pq3.pguser(), database=pq3.pgdatabase())
+
+    pq3.send(conn, pq3.types.Query, query=b"")
+
+    resp = pq3.recv1(conn)
+    assert resp.type == pq3.types.EmptyQueryResponse
+
+    resp = pq3.recv1(conn)
+    assert resp.type == pq3.types.ReadyForQuery
diff --git a/src/test/python/test_internals.py b/src/test/python/test_internals.py
new file mode 100644
index 00000000000..dee4855fc0b
--- /dev/null
+++ b/src/test/python/test_internals.py
@@ -0,0 +1,138 @@
+#
+# Copyright 2021 VMware, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import io
+
+from pq3 import _DebugStream
+
+
+def test_DebugStream_read():
+    under = io.BytesIO(b"abcdefghijklmnopqrstuvwxyz")
+    out = io.StringIO()
+
+    stream = _DebugStream(under, out)
+
+    res = stream.read(5)
+    assert res == b"abcde"
+
+    res = stream.read(16)
+    assert res == b"fghijklmnopqrstu"
+
+    stream.flush_debug()
+
+    res = stream.read()
+    assert res == b"vwxyz"
+
+    stream.flush_debug()
+
+    expected = (
+        "< 0000:\t61 62 63 64 65 66 67 68 69 6a 6b 6c 6d 6e 6f 70\tabcdefghijklmnop\n"
+        "< 0010:\t71 72 73 74 75                                 \tqrstu\n"
+        "\n"
+        "< 0000:\t76 77 78 79 7a                                 \tvwxyz\n"
+        "\n"
+    )
+    assert out.getvalue() == expected
+
+
+def test_DebugStream_write():
+    under = io.BytesIO()
+    out = io.StringIO()
+
+    stream = _DebugStream(under, out)
+
+    stream.write(b"\x00\x01\x02")
+    stream.flush()
+
+    assert under.getvalue() == b"\x00\x01\x02"
+
+    stream.write(b"\xc0\xc1\xc2")
+    stream.flush()
+
+    assert under.getvalue() == b"\x00\x01\x02\xc0\xc1\xc2"
+
+    stream.flush_debug()
+
+    expected = "> 0000:\t00 01 02 c0 c1 c2                              \t......\n\n"
+    assert out.getvalue() == expected
+
+
+def test_DebugStream_read_write():
+    under = io.BytesIO(b"abcdefghijklmnopqrstuvwxyz")
+    out = io.StringIO()
+    stream = _DebugStream(under, out)
+
+    res = stream.read(5)
+    assert res == b"abcde"
+
+    stream.write(b"xxxxx")
+    stream.flush()
+
+    assert under.getvalue() == b"abcdexxxxxklmnopqrstuvwxyz"
+
+    res = stream.read(5)
+    assert res == b"klmno"
+
+    stream.write(b"xxxxx")
+    stream.flush()
+
+    assert under.getvalue() == b"abcdexxxxxklmnoxxxxxuvwxyz"
+
+    stream.flush_debug()
+
+    expected = (
+        "< 0000:\t61 62 63 64 65 6b 6c 6d 6e 6f                  \tabcdeklmno\n"
+        "\n"
+        "> 0000:\t78 78 78 78 78 78 78 78 78 78                  \txxxxxxxxxx\n"
+        "\n"
+    )
+    assert out.getvalue() == expected
+
+
+def test_DebugStream_end_packet():
+    under = io.BytesIO(b"abcdefghijklmnopqrstuvwxyz")
+    out = io.StringIO()
+    stream = _DebugStream(under, out)
+
+    stream.read(5)
+    stream.end_packet("read description", read=True, indent=" ")
+
+    stream.write(b"xxxxx")
+    stream.flush()
+    stream.end_packet("write description", indent=" ")
+
+    stream.read(5)
+    stream.write(b"xxxxx")
+    stream.flush()
+    stream.end_packet("read/write combo for read", read=True, indent=" ")
+
+    stream.read(5)
+    stream.write(b"xxxxx")
+    stream.flush()
+    stream.end_packet("read/write combo for write", indent=" ")
+
+    expected = (
+        " < 0000:\t61 62 63 64 65                                 \tabcde\n"
+        "\n"
+        "< read description\n"
+        "\n"
+        "> write description\n"
+        "\n"
+        " > 0000:\t78 78 78 78 78                                 \txxxxx\n"
+        "\n"
+        " < 0000:\t6b 6c 6d 6e 6f                                 \tklmno\n"
+        "\n"
+        " > 0000:\t78 78 78 78 78                                 \txxxxx\n"
+        "\n"
+        "< read/write combo for read\n"
+        "\n"
+        "> read/write combo for write\n"
+        "\n"
+        " < 0000:\t75 76 77 78 79                                 \tuvwxy\n"
+        "\n"
+        " > 0000:\t78 78 78 78 78                                 \txxxxx\n"
+        "\n"
+    )
+    assert out.getvalue() == expected
diff --git a/src/test/python/test_pq3.py b/src/test/python/test_pq3.py
new file mode 100644
index 00000000000..7c6817de31c
--- /dev/null
+++ b/src/test/python/test_pq3.py
@@ -0,0 +1,574 @@
+#
+# Copyright 2021 VMware, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import contextlib
+import getpass
+import io
+import os
+import platform
+import struct
+import sys
+
+import pytest
+from construct import Container, PaddingError, StreamError, TerminatedError
+
+import pq3
+
+
[email protected](
+    "raw,expected,extra",
+    [
+        pytest.param(
+            b"\x00\x00\x00\x10\x00\x04\x00\x00abcdefgh",
+            Container(len=16, proto=0x40000, payload=b"abcdefgh"),
+            b"",
+            id="8-byte payload",
+        ),
+        pytest.param(
+            b"\x00\x00\x00\x08\x00\x04\x00\x00",
+            Container(len=8, proto=0x40000, payload=b""),
+            b"",
+            id="no payload",
+        ),
+        pytest.param(
+            b"\x00\x00\x00\x09\x00\x04\x00\x00abcde",
+            Container(len=9, proto=0x40000, payload=b"a"),
+            b"bcde",
+            id="1-byte payload and extra padding",
+        ),
+        pytest.param(
+            b"\x00\x00\x00\x0B\x00\x03\x00\x00hi\x00",
+            Container(len=11, proto=pq3.protocol(3, 0), payload=[b"hi"]),
+            b"",
+            id="implied parameter list when using proto version 3.0",
+        ),
+    ],
+)
+def test_Startup_parse(raw, expected, extra):
+    with io.BytesIO(raw) as stream:
+        actual = pq3.Startup.parse_stream(stream)
+
+        assert actual == expected
+        assert stream.read() == extra
+
+
[email protected](
+    "packet,expected_bytes",
+    [
+        pytest.param(
+            dict(),
+            b"\x00\x00\x00\x08\x00\x00\x00\x00",
+            id="nothing set",
+        ),
+        pytest.param(
+            dict(len=10, proto=0x12345678),
+            b"\x00\x00\x00\x0A\x12\x34\x56\x78\x00\x00",
+            id="len and proto set explicitly",
+        ),
+        pytest.param(
+            dict(proto=0x12345678),
+            b"\x00\x00\x00\x08\x12\x34\x56\x78",
+            id="implied len with no payload",
+        ),
+        pytest.param(
+            dict(proto=0x12345678, payload=b"abcd"),
+            b"\x00\x00\x00\x0C\x12\x34\x56\x78abcd",
+            id="implied len with payload",
+        ),
+        pytest.param(
+            dict(payload=[b""]),
+            b"\x00\x00\x00\x09\x00\x03\x00\x00\x00",
+            id="implied proto version 3 when sending parameters",
+        ),
+        pytest.param(
+            dict(payload=[b"hi", b""]),
+            b"\x00\x00\x00\x0C\x00\x03\x00\x00hi\x00\x00",
+            id="implied proto version 3 and len when sending more than one parameter",
+        ),
+        pytest.param(
+            dict(payload=dict(user="jsmith", database="postgres")),
+            b"\x00\x00\x00\x27\x00\x03\x00\x00user\x00jsmith\x00database\x00postgres\x00\x00",
+            id="auto-serialization of dict parameters",
+        ),
+    ],
+)
+def test_Startup_build(packet, expected_bytes):
+    actual = pq3.Startup.build(packet)
+    assert actual == expected_bytes
+
+
[email protected](
+    "raw,expected,extra",
+    [
+        pytest.param(
+            b"*\x00\x00\x00\x08abcd",
+            dict(type=b"*", len=8, payload=b"abcd"),
+            b"",
+            id="4-byte payload",
+        ),
+        pytest.param(
+            b"*\x00\x00\x00\x04",
+            dict(type=b"*", len=4, payload=b""),
+            b"",
+            id="no payload",
+        ),
+        pytest.param(
+            b"*\x00\x00\x00\x05xabcd",
+            dict(type=b"*", len=5, payload=b"x"),
+            b"abcd",
+            id="1-byte payload with extra padding",
+        ),
+        pytest.param(
+            b"R\x00\x00\x00\x08\x00\x00\x00\x00",
+            dict(
+                type=pq3.types.AuthnRequest,
+                len=8,
+                payload=dict(type=pq3.authn.OK, body=None),
+            ),
+            b"",
+            id="AuthenticationOk",
+        ),
+        pytest.param(
+            b"R\x00\x00\x00\x12\x00\x00\x00\x0AEXTERNAL\x00\x00",
+            dict(
+                type=pq3.types.AuthnRequest,
+                len=18,
+                payload=dict(type=pq3.authn.SASL, body=[b"EXTERNAL", b""]),
+            ),
+            b"",
+            id="AuthenticationSASL",
+        ),
+        pytest.param(
+            b"R\x00\x00\x00\x0D\x00\x00\x00\x0B12345",
+            dict(
+                type=pq3.types.AuthnRequest,
+                len=13,
+                payload=dict(type=pq3.authn.SASLContinue, body=b"12345"),
+            ),
+            b"",
+            id="AuthenticationSASLContinue",
+        ),
+        pytest.param(
+            b"R\x00\x00\x00\x0D\x00\x00\x00\x0C12345",
+            dict(
+                type=pq3.types.AuthnRequest,
+                len=13,
+                payload=dict(type=pq3.authn.SASLFinal, body=b"12345"),
+            ),
+            b"",
+            id="AuthenticationSASLFinal",
+        ),
+        pytest.param(
+            b"p\x00\x00\x00\x0Bhunter2",
+            dict(
+                type=pq3.types.PasswordMessage,
+                len=11,
+                payload=b"hunter2",
+            ),
+            b"",
+            id="PasswordMessage",
+        ),
+        pytest.param(
+            b"K\x00\x00\x00\x0C\x00\x00\x00\x00\x12\x34\x56\x78",
+            dict(
+                type=pq3.types.BackendKeyData,
+                len=12,
+                payload=dict(pid=0, key=0x12345678),
+            ),
+            b"",
+            id="BackendKeyData",
+        ),
+        pytest.param(
+            b"C\x00\x00\x00\x08SET\x00",
+            dict(
+                type=pq3.types.CommandComplete,
+                len=8,
+                payload=dict(tag=b"SET"),
+            ),
+            b"",
+            id="CommandComplete",
+        ),
+        pytest.param(
+            b"E\x00\x00\x00\x11Mbad!\x00Mdog!\x00\x00",
+            dict(type=b"E", len=17, payload=dict(fields=[b"Mbad!", b"Mdog!", b""])),
+            b"",
+            id="ErrorResponse",
+        ),
+        pytest.param(
+            b"S\x00\x00\x00\x08a\x00b\x00",
+            dict(
+                type=pq3.types.ParameterStatus,
+                len=8,
+                payload=dict(name=b"a", value=b"b"),
+            ),
+            b"",
+            id="ParameterStatus",
+        ),
+        pytest.param(
+            b"Z\x00\x00\x00\x05x",
+            dict(type=b"Z", len=5, payload=dict(status=b"x")),
+            b"",
+            id="ReadyForQuery",
+        ),
+        pytest.param(
+            b"Q\x00\x00\x00\x06!\x00",
+            dict(type=pq3.types.Query, len=6, payload=dict(query=b"!")),
+            b"",
+            id="Query",
+        ),
+        pytest.param(
+            b"D\x00\x00\x00\x0B\x00\x01\x00\x00\x00\x01!",
+            dict(type=pq3.types.DataRow, len=11, payload=dict(columns=[b"!"])),
+            b"",
+            id="DataRow",
+        ),
+        pytest.param(
+            b"D\x00\x00\x00\x06\x00\x00extra",
+            dict(type=pq3.types.DataRow, len=6, payload=dict(columns=[])),
+            b"extra",
+            id="DataRow with extra data",
+        ),
+        pytest.param(
+            b"I\x00\x00\x00\x04",
+            dict(type=pq3.types.EmptyQueryResponse, len=4, payload=None),
+            b"",
+            id="EmptyQueryResponse",
+        ),
+        pytest.param(
+            b"I\x00\x00\x00\x04\xFF",
+            dict(type=b"I", len=4, payload=None),
+            b"\xFF",
+            id="EmptyQueryResponse with extra bytes",
+        ),
+        pytest.param(
+            b"X\x00\x00\x00\x04",
+            dict(type=pq3.types.Terminate, len=4, payload=None),
+            b"",
+            id="Terminate",
+        ),
+    ],
+)
+def test_Pq3_parse(raw, expected, extra):
+    with io.BytesIO(raw) as stream:
+        actual = pq3.Pq3.parse_stream(stream)
+
+        assert actual == expected
+        assert stream.read() == extra
+
+
[email protected](
+    "fields,expected",
+    [
+        pytest.param(
+            dict(type=b"*", len=5),
+            b"*\x00\x00\x00\x05",
+            id="type and len set explicitly",
+        ),
+        pytest.param(
+            dict(type=b"*"),
+            b"*\x00\x00\x00\x04",
+            id="implied len with no payload",
+        ),
+        pytest.param(
+            dict(type=b"*", payload=b"1234"),
+            b"*\x00\x00\x00\x081234",
+            id="implied len with payload",
+        ),
+        pytest.param(
+            dict(type=b"*", len=12, payload=b"1234"),
+            b"*\x00\x00\x00\x0C1234",
+            id="overridden len (payload underflow)",
+        ),
+        pytest.param(
+            dict(type=b"*", len=5, payload=b"1234"),
+            b"*\x00\x00\x00\x051234",
+            id="overridden len (payload overflow)",
+        ),
+        pytest.param(
+            dict(type=pq3.types.AuthnRequest, payload=dict(type=pq3.authn.OK)),
+            b"R\x00\x00\x00\x08\x00\x00\x00\x00",
+            id="implied len/type for AuthenticationOK",
+        ),
+        pytest.param(
+            dict(
+                type=pq3.types.AuthnRequest,
+                payload=dict(
+                    type=pq3.authn.SASL,
+                    body=[b"SCRAM-SHA-256-PLUS", b"SCRAM-SHA-256", b""],
+                ),
+            ),
+            b"R\x00\x00\x00\x2A\x00\x00\x00\x0ASCRAM-SHA-256-PLUS\x00SCRAM-SHA-256\x00\x00",
+            id="implied len/type for AuthenticationSASL",
+        ),
+        pytest.param(
+            dict(
+                type=pq3.types.AuthnRequest,
+                payload=dict(type=pq3.authn.SASLContinue, body=b"12345"),
+            ),
+            b"R\x00\x00\x00\x0D\x00\x00\x00\x0B12345",
+            id="implied len/type for AuthenticationSASLContinue",
+        ),
+        pytest.param(
+            dict(
+                type=pq3.types.AuthnRequest,
+                payload=dict(type=pq3.authn.SASLFinal, body=b"12345"),
+            ),
+            b"R\x00\x00\x00\x0D\x00\x00\x00\x0C12345",
+            id="implied len/type for AuthenticationSASLFinal",
+        ),
+        pytest.param(
+            dict(
+                type=pq3.types.PasswordMessage,
+                payload=b"hunter2",
+            ),
+            b"p\x00\x00\x00\x0Bhunter2",
+            id="implied len/type for PasswordMessage",
+        ),
+        pytest.param(
+            dict(type=pq3.types.BackendKeyData, payload=dict(pid=1, key=7)),
+            b"K\x00\x00\x00\x0C\x00\x00\x00\x01\x00\x00\x00\x07",
+            id="implied len/type for BackendKeyData",
+        ),
+        pytest.param(
+            dict(type=pq3.types.CommandComplete, payload=dict(tag=b"SET")),
+            b"C\x00\x00\x00\x08SET\x00",
+            id="implied len/type for CommandComplete",
+        ),
+        pytest.param(
+            dict(type=pq3.types.ErrorResponse, payload=dict(fields=[b"error", b""])),
+            b"E\x00\x00\x00\x0Berror\x00\x00",
+            id="implied len/type for ErrorResponse",
+        ),
+        pytest.param(
+            dict(type=pq3.types.ParameterStatus, payload=dict(name=b"a", value=b"b")),
+            b"S\x00\x00\x00\x08a\x00b\x00",
+            id="implied len/type for ParameterStatus",
+        ),
+        pytest.param(
+            dict(type=pq3.types.ReadyForQuery, payload=dict(status=b"I")),
+            b"Z\x00\x00\x00\x05I",
+            id="implied len/type for ReadyForQuery",
+        ),
+        pytest.param(
+            dict(type=pq3.types.Query, payload=dict(query=b"SELECT 1;")),
+            b"Q\x00\x00\x00\x0eSELECT 1;\x00",
+            id="implied len/type for Query",
+        ),
+        pytest.param(
+            dict(type=pq3.types.DataRow, payload=dict(columns=[b"abcd"])),
+            b"D\x00\x00\x00\x0E\x00\x01\x00\x00\x00\x04abcd",
+            id="implied len/type for DataRow",
+        ),
+        pytest.param(
+            dict(type=pq3.types.EmptyQueryResponse),
+            b"I\x00\x00\x00\x04",
+            id="implied len for EmptyQueryResponse",
+        ),
+        pytest.param(
+            dict(type=pq3.types.Terminate),
+            b"X\x00\x00\x00\x04",
+            id="implied len for Terminate",
+        ),
+    ],
+)
+def test_Pq3_build(fields, expected):
+    actual = pq3.Pq3.build(fields)
+    assert actual == expected
+
+
[email protected](
+    "raw,expected,extra",
+    [
+        pytest.param(
+            b"\x00\x00",
+            dict(columns=[]),
+            b"",
+            id="no columns",
+        ),
+        pytest.param(
+            b"\x00\x01\x00\x00\x00\x04abcd",
+            dict(columns=[b"abcd"]),
+            b"",
+            id="one column",
+        ),
+        pytest.param(
+            b"\x00\x02\x00\x00\x00\x04abcd\x00\x00\x00\x01x",
+            dict(columns=[b"abcd", b"x"]),
+            b"",
+            id="multiple columns",
+        ),
+        pytest.param(
+            b"\x00\x02\x00\x00\x00\x00\x00\x00\x00\x01x",
+            dict(columns=[b"", b"x"]),
+            b"",
+            id="empty column value",
+        ),
+        pytest.param(
+            b"\x00\x02\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF",
+            dict(columns=[None, None]),
+            b"",
+            id="null columns",
+        ),
+    ],
+)
+def test_DataRow_parse(raw, expected, extra):
+    pkt = b"D" + struct.pack("!i", len(raw) + 4) + raw
+    with io.BytesIO(pkt) as stream:
+        actual = pq3.Pq3.parse_stream(stream)
+
+        assert actual.type == pq3.types.DataRow
+        assert actual.payload == expected
+        assert stream.read() == extra
+
+
[email protected](
+    "fields,expected",
+    [
+        pytest.param(
+            dict(),
+            b"\x00\x00",
+            id="no columns",
+        ),
+        pytest.param(
+            dict(columns=[None, None]),
+            b"\x00\x02\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF",
+            id="null columns",
+        ),
+    ],
+)
+def test_DataRow_build(fields, expected):
+    actual = pq3.Pq3.build(dict(type=pq3.types.DataRow, payload=fields))
+
+    expected = b"D" + struct.pack("!i", len(expected) + 4) + expected
+    assert actual == expected
+
+
[email protected](
+    "raw,expected,exception",
+    [
+        pytest.param(
+            b"EXTERNAL\x00\xFF\xFF\xFF\xFF",
+            dict(name=b"EXTERNAL", len=-1, data=None),
+            None,
+            id="no initial response",
+        ),
+        pytest.param(
+            b"EXTERNAL\x00\x00\x00\x00\x02me",
+            dict(name=b"EXTERNAL", len=2, data=b"me"),
+            None,
+            id="initial response",
+        ),
+        pytest.param(
+            b"EXTERNAL\x00\x00\x00\x00\x02meextra",
+            None,
+            TerminatedError,
+            id="extra data",
+        ),
+        pytest.param(
+            b"EXTERNAL\x00\x00\x00\x00\xFFme",
+            None,
+            StreamError,
+            id="underflow",
+        ),
+    ],
+)
+def test_SASLInitialResponse_parse(raw, expected, exception):
+    ctx = contextlib.nullcontext()
+    if exception:
+        ctx = pytest.raises(exception)
+
+    with ctx:
+        actual = pq3.SASLInitialResponse.parse(raw)
+        assert actual == expected
+
+
[email protected](
+    "fields,expected",
+    [
+        pytest.param(
+            dict(name=b"EXTERNAL"),
+            b"EXTERNAL\x00\xFF\xFF\xFF\xFF",
+            id="no initial response",
+        ),
+        pytest.param(
+            dict(name=b"EXTERNAL", data=None),
+            b"EXTERNAL\x00\xFF\xFF\xFF\xFF",
+            id="no initial response (explicit None)",
+        ),
+        pytest.param(
+            dict(name=b"EXTERNAL", data=b""),
+            b"EXTERNAL\x00\x00\x00\x00\x00",
+            id="empty response",
+        ),
+        pytest.param(
+            dict(name=b"EXTERNAL", data=b"[email protected]"),
+            b"EXTERNAL\x00\x00\x00\x00\[email protected]",
+            id="initial response",
+        ),
+        pytest.param(
+            dict(name=b"EXTERNAL", len=2, data=b"[email protected]"),
+            b"EXTERNAL\x00\x00\x00\x00\[email protected]",
+            id="data overflow",
+        ),
+        pytest.param(
+            dict(name=b"EXTERNAL", len=14, data=b"me"),
+            b"EXTERNAL\x00\x00\x00\x00\x0Eme",
+            id="data underflow",
+        ),
+    ],
+)
+def test_SASLInitialResponse_build(fields, expected):
+    actual = pq3.SASLInitialResponse.build(fields)
+    assert actual == expected
+
+
[email protected](
+    "version,expected_bytes",
+    [
+        pytest.param((3, 0), b"\x00\x03\x00\x00", id="version 3"),
+        pytest.param((1234, 5679), b"\x04\xd2\x16\x2f", id="SSLRequest"),
+    ],
+)
+def test_protocol(version, expected_bytes):
+    # Make sure the integer returned by protocol is correctly serialized on the
+    # wire.
+    assert struct.pack("!i", pq3.protocol(*version)) == expected_bytes
+
+
[email protected](
+    "envvar,func,expected",
+    [
+        ("PGHOST", pq3.pghost, "localhost"),
+        ("PGPORT", pq3.pgport, 5432),
+        (
+            "PGUSER",
+            pq3.pguser,
+            os.getlogin() if platform.system() == "Windows" else getpass.getuser(),
+        ),
+        ("PGDATABASE", pq3.pgdatabase, "postgres"),
+    ],
+)
+def test_env_defaults(monkeypatch, envvar, func, expected):
+    monkeypatch.delenv(envvar, raising=False)
+
+    actual = func()
+    assert actual == expected
+
+
[email protected](
+    "envvars,func,expected",
+    [
+        (dict(PGHOST="otherhost"), pq3.pghost, "otherhost"),
+        (dict(PGPORT="6789"), pq3.pgport, 6789),
+        (dict(PGUSER="postgres"), pq3.pguser, "postgres"),
+        (dict(PGDATABASE="template1"), pq3.pgdatabase, "template1"),
+    ],
+)
+def test_env(monkeypatch, envvars, func, expected):
+    for k, v in envvars.items():
+        monkeypatch.setenv(k, v)
+
+    actual = func()
+    assert actual == expected
diff --git a/src/test/python/tls.py b/src/test/python/tls.py
new file mode 100644
index 00000000000..075c02c1ca6
--- /dev/null
+++ b/src/test/python/tls.py
@@ -0,0 +1,195 @@
+#
+# Copyright 2021 VMware, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+from construct import *
+
+#
+# TLS 1.3
+#
+# Most of the types below are transcribed from RFC 8446:
+#
+#     https://tools.ietf.org/html/rfc8446
+#
+
+
+def _Vector(size_field, element):
+    return Prefixed(size_field, GreedyRange(element))
+
+
+# Alerts
+
+AlertLevel = Enum(
+    Byte,
+    warning=1,
+    fatal=2,
+)
+
+AlertDescription = Enum(
+    Byte,
+    close_notify=0,
+    unexpected_message=10,
+    bad_record_mac=20,
+    decryption_failed_RESERVED=21,
+    record_overflow=22,
+    decompression_failure=30,
+    handshake_failure=40,
+    no_certificate_RESERVED=41,
+    bad_certificate=42,
+    unsupported_certificate=43,
+    certificate_revoked=44,
+    certificate_expired=45,
+    certificate_unknown=46,
+    illegal_parameter=47,
+    unknown_ca=48,
+    access_denied=49,
+    decode_error=50,
+    decrypt_error=51,
+    export_restriction_RESERVED=60,
+    protocol_version=70,
+    insufficient_security=71,
+    internal_error=80,
+    user_canceled=90,
+    no_renegotiation=100,
+    unsupported_extension=110,
+)
+
+Alert = Struct(
+    "level" / AlertLevel,
+    "description" / AlertDescription,
+)
+
+
+# Extensions
+
+ExtensionType = Enum(
+    Int16ub,
+    server_name=0,
+    max_fragment_length=1,
+    status_request=5,
+    supported_groups=10,
+    signature_algorithms=13,
+    use_srtp=14,
+    heartbeat=15,
+    application_layer_protocol_negotiation=16,
+    signed_certificate_timestamp=18,
+    client_certificate_type=19,
+    server_certificate_type=20,
+    padding=21,
+    pre_shared_key=41,
+    early_data=42,
+    supported_versions=43,
+    cookie=44,
+    psk_key_exchange_modes=45,
+    certificate_authorities=47,
+    oid_filters=48,
+    post_handshake_auth=49,
+    signature_algorithms_cert=50,
+    key_share=51,
+)
+
+Extension = Struct(
+    "extension_type" / ExtensionType,
+    "extension_data" / Prefixed(Int16ub, GreedyBytes),
+)
+
+
+# ClientHello
+
+
+class _CipherSuiteAdapter(Adapter):
+    class _hextuple(tuple):
+        def __repr__(self):
+            return f"(0x{self[0]:02X}, 0x{self[1]:02X})"
+
+    def _encode(self, obj, context, path):
+        return bytes(obj)
+
+    def _decode(self, obj, context, path):
+        assert len(obj) == 2
+        return self._hextuple(obj)
+
+
+ProtocolVersion = Hex(Int16ub)
+
+Random = Hex(Bytes(32))
+
+CipherSuite = _CipherSuiteAdapter(Byte[2])
+
+ClientHello = Struct(
+    "legacy_version" / ProtocolVersion,
+    "random" / Random,
+    "legacy_session_id" / Prefixed(Byte, Hex(GreedyBytes)),
+    "cipher_suites" / _Vector(Int16ub, CipherSuite),
+    "legacy_compression_methods" / Prefixed(Byte, GreedyBytes),
+    "extensions" / _Vector(Int16ub, Extension),
+)
+
+# ServerHello
+
+ServerHello = Struct(
+    "legacy_version" / ProtocolVersion,
+    "random" / Random,
+    "legacy_session_id_echo" / Prefixed(Byte, Hex(GreedyBytes)),
+    "cipher_suite" / CipherSuite,
+    "legacy_compression_method" / Hex(Byte),
+    "extensions" / _Vector(Int16ub, Extension),
+)
+
+# Handshake
+
+HandshakeType = Enum(
+    Byte,
+    client_hello=1,
+    server_hello=2,
+    new_session_ticket=4,
+    end_of_early_data=5,
+    encrypted_extensions=8,
+    certificate=11,
+    certificate_request=13,
+    certificate_verify=15,
+    finished=20,
+    key_update=24,
+    message_hash=254,
+)
+
+Handshake = Struct(
+    "msg_type" / HandshakeType,
+    "length" / Int24ub,
+    "payload"
+    / Switch(
+        this.msg_type,
+        {
+            HandshakeType.client_hello: ClientHello,
+            HandshakeType.server_hello: ServerHello,
+            # HandshakeType.end_of_early_data: EndOfEarlyData,
+            # HandshakeType.encrypted_extensions: EncryptedExtensions,
+            # HandshakeType.certificate_request: CertificateRequest,
+            # HandshakeType.certificate: Certificate,
+            # HandshakeType.certificate_verify: CertificateVerify,
+            # HandshakeType.finished: Finished,
+            # HandshakeType.new_session_ticket: NewSessionTicket,
+            # HandshakeType.key_update: KeyUpdate,
+        },
+        default=FixedSized(this.length, GreedyBytes),
+    ),
+)
+
+# Records
+
+ContentType = Enum(
+    Byte,
+    invalid=0,
+    change_cipher_spec=20,
+    alert=21,
+    handshake=22,
+    application_data=23,
+)
+
+Plaintext = Struct(
+    "type" / ContentType,
+    "legacy_record_version" / ProtocolVersion,
+    "length" / Int16ub,
+    "fragment" / FixedSized(this.length, GreedyBytes),
+)
diff --git a/src/tools/make_venv b/src/tools/make_venv
new file mode 100755
index 00000000000..804307ee120
--- /dev/null
+++ b/src/tools/make_venv
@@ -0,0 +1,56 @@
+#!/usr/bin/env python3
+
+import argparse
+import subprocess
+import os
+import platform
+import sys
+
+parser = argparse.ArgumentParser()
+
+parser.add_argument('--requirements', help='path to pip requirements file', type=str)
+parser.add_argument('--privatedir', help='private directory for target', type=str)
+parser.add_argument('venv_path', help='desired venv location')
+
+args = parser.parse_args()
+
+# Decide whether or not to capture stdout into a log file. We only do this if
+# we've been given our own private directory.
+#
+# FIXME Unfortunately this interferes with debugging on Cirrus, because the
+# private directory isn't uploaded in the sanity check's artifacts. When we
+# don't capture the log file, it gets spammed to stdout during build... Is there
+# a way to push this into the meson-log somehow? For now, the capture
+# implementation is commented out.
+logfile = None
+
+if args.privatedir:
+    if not os.path.isdir(args.privatedir):
+        os.mkdir(args.privatedir)
+
+    # FIXME see above comment
+    # logpath = os.path.join(args.privatedir, 'stdout.txt')
+    # logfile = open(logpath, 'w')
+
+def run(*args):
+    kwargs = dict(check=True)
+    if logfile:
+        kwargs.update(stdout=logfile)
+
+    subprocess.run(args, **kwargs)
+
+# Create the virtualenv first.
+run(sys.executable, '-m', 'venv', args.venv_path)
+
+# Update pip next. This helps avoid old pip bugs; the version inside system
+# Pythons tends to be pretty out of date.
+bindir = 'Scripts' if platform.system() == 'Windows' else 'bin'
+python = os.path.join(args.venv_path, bindir, 'python3')
+run(python, '-m', 'pip', 'install', '-U', 'pip')
+
+# Finally, install the test's requirements. We need pytest and pytest-tap, no
+# matter what the test needs.
+pip = os.path.join(args.venv_path, bindir, 'pip')
+run(pip, 'install', 'pytest', 'pytest-tap')
+if args.requirements:
+    run(pip, 'install', '-r', args.requirements)
diff --git a/src/tools/testwrap b/src/tools/testwrap
index 8ae8fb79ba7..ffdf760d79a 100755
--- a/src/tools/testwrap
+++ b/src/tools/testwrap
@@ -14,6 +14,7 @@ parser.add_argument('--testgroup', help='test group', type=str)
 parser.add_argument('--testname', help='test name', type=str)
 parser.add_argument('--skip', help='skip test (with reason)', type=str)
 parser.add_argument('--pg-test-extra', help='extra tests', type=str)
+parser.add_argument('--skip-without-extra', help='skip if PG_TEST_EXTRA is missing this arg', type=str)
 parser.add_argument('test_command', nargs='*')
 
 args = parser.parse_args()
@@ -29,6 +30,12 @@ if args.skip is not None:
     print('1..0 # Skipped: ' + args.skip)
     sys.exit(0)
 
+if args.skip_without_extra is not None:
+    extras = os.environ.get("PG_TEST_EXTRA", args.pg_test_extra)
+    if extras is None or args.skip_without_extra not in extras.split():
+        print(f'1..0 # Skipped: PG_TEST_EXTRA does not contain "{args.skip_without_extra}"')
+        sys.exit(0)
+
 if os.path.exists(testdir) and os.path.isdir(testdir):
     shutil.rmtree(testdir)
 os.makedirs(testdir)
-- 
2.34.1



^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-01-27 22:49  Daniel Gustafsson <[email protected]>
  parent: Jacob Champion <[email protected]>
  0 siblings, 1 reply; 101+ messages in thread

From: Daniel Gustafsson @ 2025-01-27 22:49 UTC (permalink / raw)
  To: Jacob Champion <[email protected]>; +Cc: Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

> On 21 Jan 2025, at 17:46, Jacob Champion <[email protected]> wrote:

> Done that way in v43.

I've spent some time staring at, and testing, 0001, 0002 and 0003 with the
intent of getting them in to pave the way for the end goal of getting 0004 in.
In general I would say they are ready, I only have a small nitpick on 0002:

+	conn->allowed_sasl_mechs[0] = &pg_scram_mech;
I'm not a huge fan of this hardcoding in fill_allowed_sasl_mechs().  It's true
that we only have one as of this patch, but we might as well plan a little for
the future maintainability. I took a quick stab in the attached.

On top of that I just re-arranged a comment to, IMHO, better match the style in
the rest of the file.

Unless there are objections I aim at committing these patches reasonably soon
to lower the barrier for getting OAuth support committed.

--
Daniel Gustafsson


commit 73f6e943711f9c158a0f1b32fcc78f210d767083
Author: Daniel Gustafsson <[email protected]>
Date:   Mon Jan 27 15:13:58 2025 +0100

    nitpickerying

diff --git a/src/interfaces/libpq/fe-connect.c b/src/interfaces/libpq/fe-connect.c
index 85ebf9f6d87..e390e428284 100644
--- a/src/interfaces/libpq/fe-connect.c
+++ b/src/interfaces/libpq/fe-connect.c
@@ -396,6 +396,25 @@ static const PQEnvironmentOption EnvironmentOptions[] =
 	}
 };
 
+typedef struct SupportedSASLMech
+{
+	const char *name;
+	const pg_fe_sasl_mech *mech;
+} SupportedSASLMech;
+
+#define SASL_MECHANISM_COUNT 1
+
+static SupportedSASLMech supported_sasl_mech[] =
+{
+	{
+		"SCRAM", &pg_scram_mech
+	},
+	{
+		NULL, NULL
+	}
+};
+
+
 /* The connection URI must start with either of the following designators: */
 static const char uri_designator[] = "postgresql://";
 static const char short_uri_designator[] = "postgres://";
@@ -512,8 +531,8 @@ pqDropConnection(PGconn *conn, bool flushInput)
 		conn->cleanup_async_auth = NULL;
 	}
 	conn->async_auth = NULL;
-	conn->altsock = PGINVALID_SOCKET;	/* cleanup_async_auth() should have
-										 * done this, but make sure. */
+	/* cleanup_async_auth() should have done this, but make sure */
+	conn->altsock = PGINVALID_SOCKET;
 #ifdef ENABLE_GSS
 	{
 		OM_uint32	min_s;
@@ -1144,14 +1163,14 @@ fill_allowed_sasl_mechs(PGconn *conn)
 	 *
 	 * To add a new mechanism to require_auth,
 	 * - update the length of conn->allowed_sasl_mechs,
-	 * - add the new pg_fe_sasl_mech pointer to this function, and
 	 * - handle the new mechanism name in the require_auth portion of
 	 *   pqConnectOptions2(), below.
 	 */
-	StaticAssertDecl(lengthof(conn->allowed_sasl_mechs) == 1,
-					 "fill_allowed_sasl_mechs() must be updated when resizing conn->allowed_sasl_mechs[]");
+	StaticAssertDecl(lengthof(conn->allowed_sasl_mechs) == SASL_MECHANISM_COUNT,
+					 "conn->allowed_sasl_mechs[] is not sufficiently large for holding all supported SASL mechanisms");
 
-	conn->allowed_sasl_mechs[0] = &pg_scram_mech;
+	for (int i = 0; i < SASL_MECHANISM_COUNT; i++)
+		conn->allowed_sasl_mechs[i] = supported_sasl_mech[i].mech;
 }
 
 /*
@@ -1506,8 +1525,7 @@ pqConnectOptions2(PGconn *conn)
 			 * Next group: SASL mechanisms. All of these use the same request
 			 * codes, so the list of allowed mechanisms is tracked separately.
 			 *
-			 * fill_allowed_sasl_mechs() must be updated when adding a new
-			 * mechanism here!
+			 * supported_sasl_mech must contain all mechanism handled here.
 			 */
 			else if (strcmp(method, "scram-sha-256") == 0)
 			{
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index a2644a2e653..89d738efdc1 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -2801,6 +2801,7 @@ Subscription
 SubscriptionInfo
 SubscriptionRelState
 SummarizerReadLocalXLogPrivate
+SupportedSASLMech
 SupportRequestCost
 SupportRequestIndexCondition
 SupportRequestOptimizeWindowClause


Attachments:

  [text/plain] v43review.diff.txt (2.9K, ../../[email protected]/2-v43review.diff.txt)
  download | inline diff:
commit 73f6e943711f9c158a0f1b32fcc78f210d767083
Author: Daniel Gustafsson <[email protected]>
Date:   Mon Jan 27 15:13:58 2025 +0100

    nitpickerying

diff --git a/src/interfaces/libpq/fe-connect.c b/src/interfaces/libpq/fe-connect.c
index 85ebf9f6d87..e390e428284 100644
--- a/src/interfaces/libpq/fe-connect.c
+++ b/src/interfaces/libpq/fe-connect.c
@@ -396,6 +396,25 @@ static const PQEnvironmentOption EnvironmentOptions[] =
 	}
 };
 
+typedef struct SupportedSASLMech
+{
+	const char *name;
+	const pg_fe_sasl_mech *mech;
+} SupportedSASLMech;
+
+#define SASL_MECHANISM_COUNT 1
+
+static SupportedSASLMech supported_sasl_mech[] =
+{
+	{
+		"SCRAM", &pg_scram_mech
+	},
+	{
+		NULL, NULL
+	}
+};
+
+
 /* The connection URI must start with either of the following designators: */
 static const char uri_designator[] = "postgresql://";
 static const char short_uri_designator[] = "postgres://";
@@ -512,8 +531,8 @@ pqDropConnection(PGconn *conn, bool flushInput)
 		conn->cleanup_async_auth = NULL;
 	}
 	conn->async_auth = NULL;
-	conn->altsock = PGINVALID_SOCKET;	/* cleanup_async_auth() should have
-										 * done this, but make sure. */
+	/* cleanup_async_auth() should have done this, but make sure */
+	conn->altsock = PGINVALID_SOCKET;
 #ifdef ENABLE_GSS
 	{
 		OM_uint32	min_s;
@@ -1144,14 +1163,14 @@ fill_allowed_sasl_mechs(PGconn *conn)
 	 *
 	 * To add a new mechanism to require_auth,
 	 * - update the length of conn->allowed_sasl_mechs,
-	 * - add the new pg_fe_sasl_mech pointer to this function, and
 	 * - handle the new mechanism name in the require_auth portion of
 	 *   pqConnectOptions2(), below.
 	 */
-	StaticAssertDecl(lengthof(conn->allowed_sasl_mechs) == 1,
-					 "fill_allowed_sasl_mechs() must be updated when resizing conn->allowed_sasl_mechs[]");
+	StaticAssertDecl(lengthof(conn->allowed_sasl_mechs) == SASL_MECHANISM_COUNT,
+					 "conn->allowed_sasl_mechs[] is not sufficiently large for holding all supported SASL mechanisms");
 
-	conn->allowed_sasl_mechs[0] = &pg_scram_mech;
+	for (int i = 0; i < SASL_MECHANISM_COUNT; i++)
+		conn->allowed_sasl_mechs[i] = supported_sasl_mech[i].mech;
 }
 
 /*
@@ -1506,8 +1525,7 @@ pqConnectOptions2(PGconn *conn)
 			 * Next group: SASL mechanisms. All of these use the same request
 			 * codes, so the list of allowed mechanisms is tracked separately.
 			 *
-			 * fill_allowed_sasl_mechs() must be updated when adding a new
-			 * mechanism here!
+			 * supported_sasl_mech must contain all mechanism handled here.
 			 */
 			else if (strcmp(method, "scram-sha-256") == 0)
 			{
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index a2644a2e653..89d738efdc1 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -2801,6 +2801,7 @@ Subscription
 SubscriptionInfo
 SubscriptionRelState
 SummarizerReadLocalXLogPrivate
+SupportedSASLMech
 SupportRequestCost
 SupportRequestIndexCondition
 SupportRequestOptimizeWindowClause


^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-01-28 00:59  Jacob Champion <[email protected]>
  parent: Daniel Gustafsson <[email protected]>
  0 siblings, 1 reply; 101+ messages in thread

From: Jacob Champion @ 2025-01-28 00:59 UTC (permalink / raw)
  To: Daniel Gustafsson <[email protected]>; +Cc: Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

On Mon, Jan 27, 2025 at 2:50 PM Daniel Gustafsson <[email protected]> wrote:
> +       conn->allowed_sasl_mechs[0] = &pg_scram_mech;
> I'm not a huge fan of this hardcoding in fill_allowed_sasl_mechs().  It's true
> that we only have one as of this patch, but we might as well plan a little for
> the future maintainability. I took a quick stab in the attached.

Okay. I've folded that in and simplified it some, to remove the unused
names and just store the mechanism pointers without a wrapper struct;
see what you think.

> Unless there are objections I aim at committing these patches reasonably soon
> to lower the barrier for getting OAuth support committed.

Thanks!

--

v44 tackles threadsafety for older versions of Curl. If we can't prove
that the installed libcurl is threadsafe at configure time, we'll wrap
our one-time initialization in the pg_g_threadlock. Otherwise, we
won't bother with locking, but we will bail out loudly if our
threadsafety code has not been compiled in and libcurl has been
downgraded to a version/build that can't do that itself. Documentation
has been added for clients, to detail when they need to worry about
PQregisterThreadLock(), in the same way they already do with Kerberos.

While I was playing with that, I noticed that the Autoconf side of
things was not correctly picking up pkg-config variables, and my local
environment had masked the bug. I've added code to handle
CFLAGS/LDFLAGS in the same way that e.g. libxml is handled.

libpq no longer requires the authorization server to advertise support
for the device_code grant type. Entra ID doesn't appear to add that to
any of the openid-configurations it publishes, which was the primary
impetus for the change. Note that if a provider claims to support a
device_authorization_endpoint but then rejects a device_code grant,
we're not going to know what spec they're implementing anyway, so this
check likely doesn't give us any particular advantage. I've removed it
with an explanatory comment.

A description of the OAUTHBEARER handshake has been added to our
protocol docs, and I've added a comment to the new GUC in the sample
file. I've also added slightly nicer error messages in the case that
either OAuth endpoint isn't secured by HTTPS.

Thanks,
--Jacob

1:  5d474397364 = 1:  258b8dbb770 Move PG_MAX_AUTH_TOKEN_LENGTH to libpq/auth.h
2:  20452d21e0b ! 2:  ec960cf363d require_auth: prepare for multiple SASL mechanisms
    @@ src/interfaces/libpq/fe-auth.c: pg_SASL_init(PGconn *conn, int payloadlen)
      	{
     
      ## src/interfaces/libpq/fe-connect.c ##
    +@@ src/interfaces/libpq/fe-connect.c: static const PQEnvironmentOption EnvironmentOptions[] =
    + 	}
    + };
    + 
    ++static const pg_fe_sasl_mech *supported_sasl_mechs[] =
    ++{
    ++	&pg_scram_mech,
    ++};
    ++#define SASL_MECHANISM_COUNT lengthof(supported_sasl_mechs)
    ++
    + /* The connection URI must start with either of the following designators: */
    + static const char uri_designator[] = "postgresql://";
    + static const char short_uri_designator[] = "postgres://";
     @@ src/interfaces/libpq/fe-connect.c: libpq_prng_init(PGconn *conn)
      	pg_prng_seed(&conn->prng_state, rseed);
      }
    @@ src/interfaces/libpq/fe-connect.c: libpq_prng_init(PGconn *conn)
     +	 * rely on the compile-time assertion here to keep us honest.
     +	 *
     +	 * To add a new mechanism to require_auth,
    ++	 * - add it to supported_sasl_mechs,
     +	 * - update the length of conn->allowed_sasl_mechs,
    -+	 * - add the new pg_fe_sasl_mech pointer to this function, and
     +	 * - handle the new mechanism name in the require_auth portion of
     +	 *   pqConnectOptions2(), below.
     +	 */
    -+	StaticAssertDecl(lengthof(conn->allowed_sasl_mechs) == 1,
    -+					 "fill_allowed_sasl_mechs() must be updated when resizing conn->allowed_sasl_mechs[]");
    ++	StaticAssertDecl(lengthof(conn->allowed_sasl_mechs) == SASL_MECHANISM_COUNT,
    ++					 "conn->allowed_sasl_mechs[] is not sufficiently large for holding all supported SASL mechanisms");
     +
    -+	conn->allowed_sasl_mechs[0] = &pg_scram_mech;
    ++	for (int i = 0; i < SASL_MECHANISM_COUNT; i++)
    ++		conn->allowed_sasl_mechs[i] = supported_sasl_mechs[i];
     +}
     +
     +/*
    @@ src/interfaces/libpq/fe-connect.c: pqConnectOptions2(PGconn *conn)
     +			 * Next group: SASL mechanisms. All of these use the same request
     +			 * codes, so the list of allowed mechanisms is tracked separately.
     +			 *
    -+			 * fill_allowed_sasl_mechs() must be updated when adding a new
    -+			 * mechanism here!
    ++			 * supported_sasl_mechs must contain all mechanisms handled here.
     +			 */
      			else if (strcmp(method, "scram-sha-256") == 0)
      			{
    @@ src/interfaces/libpq/fe-connect.c: pqConnectOptions2(PGconn *conn)
     +					i = index_of_allowed_sasl_mech(conn, mech);
     +					if (i < 0)
     +						goto duplicate;
    - 
    --				conn->allowed_auth_methods &= ~bits;
    ++
     +					conn->allowed_sasl_mechs[i] = NULL;
     +				}
     +				else
    @@ src/interfaces/libpq/fe-connect.c: pqConnectOptions2(PGconn *conn)
     +					i = index_of_allowed_sasl_mech(conn, mech);
     +					if (i >= 0)
     +						goto duplicate;
    -+
    + 
    +-				conn->allowed_auth_methods &= ~bits;
     +					i = index_of_allowed_sasl_mech(conn, NULL);
     +					if (i < 0)
     +					{
3:  f0afefb80d6 ! 3:  9725788086c libpq: handle asynchronous actions during SASL
    @@ src/interfaces/libpq/fe-connect.c: pqDropConnection(PGconn *conn, bool flushInpu
     +		conn->cleanup_async_auth = NULL;
     +	}
     +	conn->async_auth = NULL;
    -+	conn->altsock = PGINVALID_SOCKET;	/* cleanup_async_auth() should have
    -+										 * done this, but make sure. */
    ++	/* cleanup_async_auth() should have done this, but make sure */
    ++	conn->altsock = PGINVALID_SOCKET;
      #ifdef ENABLE_GSS
      	{
      		OM_uint32	min_s;
4:  711ca3f1efc ! 4:  a260d9436f0 Add OAUTHBEARER SASL mechanism
    @@ .cirrus.tasks.yml: task:
        ###
        # Test that code can be built with gcc/clang without warnings
     
    + ## config/programs.m4 ##
    +@@ config/programs.m4: AC_DEFUN([PGAC_CHECK_STRIP],
    +   AC_SUBST(STRIP_STATIC_LIB)
    +   AC_SUBST(STRIP_SHARED_LIB)
    + ])# PGAC_CHECK_STRIP
    ++
    ++
    ++
    ++# PGAC_CHECK_LIBCURL
    ++# ------------------
    ++# Check for required libraries and headers, and test to see whether the current
    ++# installation of libcurl is threadsafe.
    ++
    ++AC_DEFUN([PGAC_CHECK_LIBCURL],
    ++[
    ++  AC_CHECK_HEADER(curl/curl.h, [],
    ++				  [AC_MSG_ERROR([header file <curl/curl.h> is required for --with-libcurl])])
    ++  AC_CHECK_LIB(curl, curl_multi_init, [],
    ++			   [AC_MSG_ERROR([library 'curl' does not provide curl_multi_init])])
    ++
    ++  # Check to see whether the current platform supports threadsafe Curl
    ++  # initialization.
    ++  AC_CACHE_CHECK([for curl_global_init thread safety], [pgac_cv__libcurl_threadsafe_init],
    ++  [AC_RUN_IFELSE([AC_LANG_PROGRAM([
    ++#include <curl/curl.h>
    ++],[
    ++    curl_version_info_data *info;
    ++
    ++    if (curl_global_init(CURL_GLOBAL_ALL))
    ++        return -1;
    ++
    ++    info = curl_version_info(CURLVERSION_NOW);
    ++#ifdef CURL_VERSION_THREADSAFE
    ++    if (info->features & CURL_VERSION_THREADSAFE)
    ++        return 0;
    ++#endif
    ++
    ++    return 1;
    ++])],
    ++  [pgac_cv__libcurl_threadsafe_init=yes],
    ++  [pgac_cv__libcurl_threadsafe_init=no],
    ++  [pgac_cv__libcurl_threadsafe_init=unknown])])
    ++  if test x"$pgac_cv__libcurl_threadsafe_init" = xyes ; then
    ++    AC_DEFINE(HAVE_THREADSAFE_CURL_GLOBAL_INIT, 1,
    ++              [Define to 1 if curl_global_init() is guaranteed to be threadsafe.])
    ++  fi
    ++])# PGAC_CHECK_LIBCURL
    +
      ## configure ##
     @@ configure: XML2_LIBS
      XML2_CFLAGS
    @@ configure: fi
     +
     +fi
     +
    ++  # We only care about -I, -D, and -L switches;
    ++  # note that -lcurl will be added by PGAC_CHECK_LIBCURL below.
    ++  for pgac_option in $LIBCURL_CFLAGS; do
    ++    case $pgac_option in
    ++      -I*|-D*) CPPFLAGS="$CPPFLAGS $pgac_option";;
    ++    esac
    ++  done
    ++  for pgac_option in $LIBCURL_LIBS; do
    ++    case $pgac_option in
    ++      -L*) LDFLAGS="$LDFLAGS $pgac_option";;
    ++    esac
    ++  done
    ++
     +  # OAuth requires python for testing
     +  if test "$with_python" != yes; then
     +    { $as_echo "$as_me:${as_lineno-$LINENO}: WARNING: *** OAuth support tests require --with-python to run" >&5
    @@ configure: fi
     +# during gss_acquire_cred(). This is possibly related to Curl's Heimdal
     +# dependency on that platform?
     +if test "$with_libcurl" = yes ; then
    ++
    ++  ac_fn_c_check_header_mongrel "$LINENO" "curl/curl.h" "ac_cv_header_curl_curl_h" "$ac_includes_default"
    ++if test "x$ac_cv_header_curl_curl_h" = xyes; then :
    ++
    ++else
    ++  as_fn_error $? "header file <curl/curl.h> is required for --with-libcurl" "$LINENO" 5
    ++fi
    ++
    ++
     +  { $as_echo "$as_me:${as_lineno-$LINENO}: checking for curl_multi_init in -lcurl" >&5
     +$as_echo_n "checking for curl_multi_init in -lcurl... " >&6; }
     +if ${ac_cv_lib_curl_curl_multi_init+:} false; then :
    @@ configure: fi
     +  LIBS="-lcurl $LIBS"
     +
     +else
    -+  as_fn_error $? "library 'curl' is required for --with-libcurl" "$LINENO" 5
    ++  as_fn_error $? "library 'curl' does not provide curl_multi_init" "$LINENO" 5
     +fi
     +
    ++
    ++  # Check to see whether the current platform supports threadsafe Curl
    ++  # initialization.
    ++  { $as_echo "$as_me:${as_lineno-$LINENO}: checking for curl_global_init thread safety" >&5
    ++$as_echo_n "checking for curl_global_init thread safety... " >&6; }
    ++if ${pgac_cv__libcurl_threadsafe_init+:} false; then :
    ++  $as_echo_n "(cached) " >&6
    ++else
    ++  if test "$cross_compiling" = yes; then :
    ++  pgac_cv__libcurl_threadsafe_init=unknown
    ++else
    ++  cat confdefs.h - <<_ACEOF >conftest.$ac_ext
    ++/* end confdefs.h.  */
    ++
    ++#include <curl/curl.h>
    ++
    ++int
    ++main ()
    ++{
    ++
    ++    curl_version_info_data *info;
    ++
    ++    if (curl_global_init(CURL_GLOBAL_ALL))
    ++        return -1;
    ++
    ++    info = curl_version_info(CURLVERSION_NOW);
    ++#ifdef CURL_VERSION_THREADSAFE
    ++    if (info->features & CURL_VERSION_THREADSAFE)
    ++        return 0;
    ++#endif
    ++
    ++    return 1;
    ++
    ++  ;
    ++  return 0;
    ++}
    ++_ACEOF
    ++if ac_fn_c_try_run "$LINENO"; then :
    ++  pgac_cv__libcurl_threadsafe_init=yes
    ++else
    ++  pgac_cv__libcurl_threadsafe_init=no
    ++fi
    ++rm -f core *.core core.conftest.* gmon.out bb.out conftest$ac_exeext \
    ++  conftest.$ac_objext conftest.beam conftest.$ac_ext
    ++fi
    ++
    ++fi
    ++{ $as_echo "$as_me:${as_lineno-$LINENO}: result: $pgac_cv__libcurl_threadsafe_init" >&5
    ++$as_echo "$pgac_cv__libcurl_threadsafe_init" >&6; }
    ++  if test x"$pgac_cv__libcurl_threadsafe_init" = xyes ; then
    ++
    ++$as_echo "#define HAVE_THREADSAFE_CURL_GLOBAL_INIT 1" >>confdefs.h
    ++
    ++  fi
    ++
     +fi
     +
      if test "$with_gssapi" = yes ; then
        if test "$PORTNAME" != "win32"; then
          { $as_echo "$as_me:${as_lineno-$LINENO}: checking for library containing gss_store_cred_into" >&5
    -@@ configure: fi
    - 
    - done
    - 
    -+fi
    -+
    -+if test "$with_libcurl" = yes; then
    -+  ac_fn_c_check_header_mongrel "$LINENO" "curl/curl.h" "ac_cv_header_curl_curl_h" "$ac_includes_default"
    -+if test "x$ac_cv_header_curl_curl_h" = xyes; then :
    -+
    -+else
    -+  as_fn_error $? "header file <curl/curl.h> is required for --with-libcurl" "$LINENO" 5
    -+fi
    -+
    -+
    - fi
    - 
    - if test "$PORTNAME" = "win32" ; then
     
      ## configure.ac ##
     @@ configure.ac: fi
    @@ configure.ac: fi
     +  # to explicitly set TLS 1.3 ciphersuites).
     +  PKG_CHECK_MODULES(LIBCURL, [libcurl >= 7.61.0])
     +
    ++  # We only care about -I, -D, and -L switches;
    ++  # note that -lcurl will be added by PGAC_CHECK_LIBCURL below.
    ++  for pgac_option in $LIBCURL_CFLAGS; do
    ++    case $pgac_option in
    ++      -I*|-D*) CPPFLAGS="$CPPFLAGS $pgac_option";;
    ++    esac
    ++  done
    ++  for pgac_option in $LIBCURL_LIBS; do
    ++    case $pgac_option in
    ++      -L*) LDFLAGS="$LDFLAGS $pgac_option";;
    ++    esac
    ++  done
    ++
     +  # OAuth requires python for testing
     +  if test "$with_python" != yes; then
     +    AC_MSG_WARN([*** OAuth support tests require --with-python to run])
    @@ configure.ac: failure.  It is possible the compiler isn't looking in the proper
     +# during gss_acquire_cred(). This is possibly related to Curl's Heimdal
     +# dependency on that platform?
     +if test "$with_libcurl" = yes ; then
    -+  AC_CHECK_LIB(curl, curl_multi_init, [], [AC_MSG_ERROR([library 'curl' is required for --with-libcurl])])
    ++  PGAC_CHECK_LIBCURL
     +fi
     +
      if test "$with_gssapi" = yes ; then
        if test "$PORTNAME" != "win32"; then
          AC_SEARCH_LIBS(gss_store_cred_into, [gssapi_krb5 gss 'gssapi -lkrb5 -lcrypto'], [],
    -@@ configure.ac: elif test "$with_uuid" = ossp ; then
    -       [AC_MSG_ERROR([header file <ossp/uuid.h> or <uuid.h> is required for OSSP UUID])])])
    - fi
    - 
    -+if test "$with_libcurl" = yes; then
    -+  AC_CHECK_HEADER(curl/curl.h, [], [AC_MSG_ERROR([header file <curl/curl.h> is required for --with-libcurl])])
    -+fi
    -+
    - if test "$PORTNAME" = "win32" ; then
    -    AC_CHECK_HEADERS(crtdefs.h)
    - fi
     
      ## doc/src/sgml/client-auth.sgml ##
     @@ doc/src/sgml/client-auth.sgml: include_dir         <replaceable>directory</replaceable>
    @@ doc/src/sgml/libpq.sgml: void PQinitSSL(int do_ssl);
      
       <sect1 id="libpq-threading">
        <title>Behavior in Threaded Programs</title>
    +@@ doc/src/sgml/libpq.sgml: int PQisthreadsafe();
    +    <application>libpq</application> source code for a way to do cooperative
    +    locking between <application>libpq</application> and your application.
    +   </para>
    ++
    ++  <para>
    ++   Similarly, if you are using Curl inside your application,
    ++   <emphasis>and</emphasis> you do not already
    ++   <ulink url="https://curl.se/libcurl/c/curl_global_init.html">initialize
    ++   libcurl globally</ulink> before starting new threads, you will need to
    ++   cooperatively lock (again via <function>PQregisterThreadLock</function>)
    ++   around any code that may initialize libcurl. This restriction is lifted for
    ++   more recent versions of Curl that are built to support threadsafe
    ++   initialization; those builds can be identified by the advertisement of a
    ++   <literal>threadsafe</literal> feature in their version metadata.
    ++  </para>
    +  </sect1>
    + 
    + 
     
      ## doc/src/sgml/oauth-validators.sgml (new) ##
     @@
    @@ doc/src/sgml/postgres.sgml: break is not needed in a wider output rendering.
       </part>
      
     
    + ## doc/src/sgml/protocol.sgml ##
    +@@ doc/src/sgml/protocol.sgml: SELCT 1/0;<!-- this typo is intentional -->
    + 
    +   <para>
    +    <firstterm>SASL</firstterm> is a framework for authentication in connection-oriented
    +-   protocols. At the moment, <productname>PostgreSQL</productname> implements two SASL
    +-   authentication mechanisms, SCRAM-SHA-256 and SCRAM-SHA-256-PLUS. More
    +-   might be added in the future. The below steps illustrate how SASL
    +-   authentication is performed in general, while the next subsection gives
    +-   more details on SCRAM-SHA-256 and SCRAM-SHA-256-PLUS.
    ++   protocols. At the moment, <productname>PostgreSQL</productname> implements three
    ++   SASL authentication mechanisms: SCRAM-SHA-256, SCRAM-SHA-256-PLUS, and
    ++   OAUTHBEARER. More might be added in the future. The below steps illustrate how SASL
    ++   authentication is performed in general, while the next subsections give
    ++   more details on particular mechanisms.
    +   </para>
    + 
    +   <procedure>
    +@@ doc/src/sgml/protocol.sgml: SELCT 1/0;<!-- this typo is intentional -->
    +    <step id="sasl-auth-end">
    +     <para>
    +      Finally, when the authentication exchange is completed successfully, the
    +-     server sends an AuthenticationSASLFinal message, followed
    ++     server sends an optional AuthenticationSASLFinal message, followed
    +      immediately by an AuthenticationOk message. The AuthenticationSASLFinal
    +      contains additional server-to-client data, whose content is particular to the
    +      selected authentication mechanism. If the authentication mechanism doesn't
    +@@ doc/src/sgml/protocol.sgml: SELCT 1/0;<!-- this typo is intentional -->
    +    <title>SCRAM-SHA-256 Authentication</title>
    + 
    +    <para>
    +-    The implemented SASL mechanisms at the moment
    +-    are <literal>SCRAM-SHA-256</literal> and its variant with channel
    +-    binding <literal>SCRAM-SHA-256-PLUS</literal>. They are described in
    ++    <literal>SCRAM-SHA-256</literal>, and its variant with channel
    ++    binding <literal>SCRAM-SHA-256-PLUS</literal>, are password-based
    ++    authentication mechanisms. They are described in
    +     detail in <ulink url="https://datatracker.ietf.org/doc/html/rfc7677">RFC 7677</ulink>
    +     and <ulink url="https://datatracker.ietf.org/doc/html/rfc5802">RFC 5802</ulink>.
    +    </para>
    +@@ doc/src/sgml/protocol.sgml: SELCT 1/0;<!-- this typo is intentional -->
    +     </step>
    +    </procedure>
    +   </sect2>
    ++
    ++  <sect2 id="sasl-oauthbearer">
    ++   <title>OAUTHBEARER Authentication</title>
    ++
    ++   <para>
    ++    <literal>OAUTHBEARER</literal> is a token-based mechanism for federated
    ++    authentication. It is described in detail in
    ++    <ulink url="https://datatracker.ietf.org/doc/html/rfc7628">RFC 7628</ulink>.
    ++   </para>
    ++
    ++   <para>
    ++    A typical exchange differs depending on whether or not the client already
    ++    has a bearer token cached for the current user. If it does not, the exchange
    ++    will take place over two connections: the first "discovery" connection to
    ++    obtain OAuth metadata from the server, and the second connection to send
    ++    the token after the client has obtained it. (libpq does not currently
    ++    implement a caching method as part of its builtin flow, so it uses the
    ++    two-connection exchange.)
    ++   </para>
    ++
    ++   <para>
    ++    This mechanism is client-initiated, like SCRAM. The client initial response
    ++    consists of the standard "GS2" header used by SCRAM, followed by a list of
    ++    <literal>key=value</literal> pairs. The only key currently supported by
    ++    the server is <literal>auth</literal>, which contains the bearer token.
    ++    <literal>OAUTHBEARER</literal> additionally specifies three optional
    ++    components of the client initial response (the <literal>authzid</literal> of
    ++    the GS2 header, and the <structfield>host</structfield> and
    ++    <structfield>port</structfield> keys) which are currently ignored by the
    ++    server.
    ++   </para>
    ++
    ++   <para>
    ++    <literal>OAUTHBEARER</literal> does not support channel binding, and there
    ++    is no "OAUTHBEARER-PLUS" mechanism. This mechanism does not make use of
    ++    server data during a successful authentication, so the
    ++    AuthenticationSASLFinal message is not used in the exchange.
    ++   </para>
    ++
    ++   <procedure>
    ++    <title>Example</title>
    ++    <step>
    ++     <para>
    ++      During the first exchange, the server sends an AuthenticationSASL message
    ++      with the <literal>OAUTHBEARER</literal> mechanism advertised.
    ++     </para>
    ++    </step>
    ++
    ++    <step>
    ++     <para>
    ++      The client responds by sending a SASLInitialResponse message which
    ++      indicates the <literal>OAUTHBEARER</literal> mechanism. Assuming the
    ++      client does not already have a valid bearer token for the current user,
    ++      the <structfield>auth</structfield> field is empty, indicating a discovery
    ++      connection.
    ++     </para>
    ++    </step>
    ++
    ++    <step>
    ++     <para>
    ++      Server sends an AuthenticationSASLContinue message containing an error
    ++      <literal>status</literal> alongside a well-known URI and scopes that the
    ++      client should use to conduct an OAuth flow.
    ++     </para>
    ++    </step>
    ++
    ++    <step>
    ++     <para>
    ++      Client sends a SASLResponse message containing the empty set (a single
    ++      <literal>0x01</literal> byte) to finish its half of the discovery
    ++      exchange.
    ++     </para>
    ++    </step>
    ++
    ++    <step>
    ++     <para>
    ++      Server sends an ErrorMessage to fail the first exchange.
    ++     </para>
    ++     <para>
    ++      At this point, the client conducts one of many possible OAuth flows to
    ++      obtain a bearer token, using any metadata that it has been configured with
    ++      in addition to that provided by the server. (This description is left
    ++      deliberately vague; <literal>OAUTHBEARER</literal> does not specify or
    ++      mandate any particular method for obtaining a token.)
    ++     </para>
    ++     <para>
    ++      Once it has a token, the client reconnects to the server for the final
    ++      exchange:
    ++     </para>
    ++    </step>
    ++
    ++    <step>
    ++     <para>
    ++      The server once again sends an AuthenticationSASL message with the
    ++      <literal>OAUTHBEARER</literal> mechanism advertised.
    ++     </para>
    ++    </step>
    ++
    ++    <step>
    ++     <para>
    ++      The client responds by sending a SASLInitialResponse message, but this
    ++      time the <structfield>auth</structfield> field in the message contains the
    ++      bearer token that was obtained during the client flow.
    ++     </para>
    ++    </step>
    ++
    ++    <step>
    ++     <para>
    ++      The server validates the token according to the instructions of the
    ++      token provider. If the client is authorized to connect, it sends an
    ++      AuthenticationOk message to end the SASL exchange.
    ++     </para>
    ++    </step>
    ++   </procedure>
    ++  </sect2>
    +  </sect1>
    + 
    +  <sect1 id="protocol-replication">
    +
      ## doc/src/sgml/regress.sgml ##
     @@ doc/src/sgml/regress.sgml: make check-world PG_TEST_EXTRA='kerberos ldap ssl load_balance libpq_encryption'
            </para>
    @@ meson.build: endif
     +  libcurl = dependency('libcurl', version: '>= 7.61.0', required: libcurlopt)
     +  if libcurl.found()
     +    cdata.set('USE_LIBCURL', 1)
    ++
    ++    # Check to see whether the current platform supports threadsafe Curl
    ++    # initialization.
    ++    libcurl_threadsafe_init = false
    ++
    ++    if not meson.is_cross_build()
    ++      r = cc.run('''
    ++        #include <curl/curl.h>
    ++
    ++        int main(void)
    ++        {
    ++            curl_version_info_data *info;
    ++
    ++            if (curl_global_init(CURL_GLOBAL_ALL))
    ++                return -1;
    ++
    ++            info = curl_version_info(CURLVERSION_NOW);
    ++        #ifdef CURL_VERSION_THREADSAFE
    ++            if (info->features & CURL_VERSION_THREADSAFE)
    ++                return 0;
    ++        #endif
    ++
    ++            return 1;
    ++        }''',
    ++        name: 'test for curl_global_init thread safety',
    ++        dependencies: libcurl,
    ++      )
    ++
    ++      assert(r.compiled())
    ++      if r.returncode() == 0
    ++        libcurl_threadsafe_init = true
    ++        message('curl_global_init is threadsafe')
    ++      elif r.returncode() == 1
    ++        message('curl_global_init is not threadsafe')
    ++      else
    ++        message('curl_global_init failed; assuming not threadsafe')
    ++      endif
    ++    endif
    ++
    ++    if libcurl_threadsafe_init
    ++      cdata.set('HAVE_THREADSAFE_CURL_GLOBAL_INIT', 1)
    ++    endif
     +  endif
    ++
     +else
     +  libcurl = not_found_dep
     +endif
    @@ src/backend/utils/misc/postgresql.conf.sample
      #ssl_passphrase_command_supports_reload = off
      
     +# OAuth
    -+#oauth_validator_libraries = ''
    ++#oauth_validator_libraries = ''	# comma-separated list of trusted validator modules
     +
      
      #------------------------------------------------------------------------------
    @@ src/include/pg_config.h.in
      /* Define to 1 if you have the `ldap' library (-lldap). */
      #undef HAVE_LIBLDAP
      
    +@@
    + /* Define to 1 if you have the <termios.h> header file. */
    + #undef HAVE_TERMIOS_H
    + 
    ++/* Define to 1 if curl_global_init() is guaranteed to be threadsafe. */
    ++#undef HAVE_THREADSAFE_CURL_GLOBAL_INIT
    ++
    + /* Define to 1 if your compiler understands `typeof' or something similar. */
    + #undef HAVE_TYPEOF
    + 
     @@
      /* Define to 1 to build with LDAP support. (--with-ldap) */
      #undef USE_LDAP
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +	return true;
     +}
     +
    ++#define HTTPS_SCHEME "https://";
     +#define OAUTH_GRANT_TYPE_DEVICE_CODE "urn:ietf:params:oauth:grant-type:device_code"
     +
     +/*
     + * Ensure that the provider supports the Device Authorization flow (i.e. it
    -+ * accepts the device_code grant type and provides an authorization endpoint).
    ++ * provides an authorization endpoint, and both the token and authorization
    ++ * endpoint URLs seem reasonable).
     + */
     +static bool
     +check_for_device_flow(struct async_ctx *actx)
     +{
     +	const struct provider *provider = &actx->provider;
    -+	const struct curl_slist *grant;
    -+	bool		device_grant_found = false;
     +
     +	Assert(provider->issuer);	/* ensured by parse_provider() */
    -+
    -+	/*------
    -+	 * First, sanity checks for discovery contents that are OPTIONAL in the
    -+	 * spec but required for our flow:
    -+	 * - the issuer must support the device_code grant
    -+	 * - the issuer must have actually given us a
    -+	 *   device_authorization_endpoint
    -+	 */
    -+
    -+	grant = provider->grant_types_supported;
    -+	while (grant)
    -+	{
    -+		if (strcmp(grant->data, OAUTH_GRANT_TYPE_DEVICE_CODE) == 0)
    -+		{
    -+			device_grant_found = true;
    -+			break;
    -+		}
    -+
    -+		grant = grant->next;
    -+	}
    -+
    -+	if (!device_grant_found)
    -+	{
    -+		actx_error(actx, "issuer \"%s\" does not support device code grants",
    -+				   provider->issuer);
    -+		return false;
    -+	}
    ++	Assert(provider->token_endpoint);	/* ensured by parse_provider() */
     +
     +	if (!provider->device_authorization_endpoint)
     +	{
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +		return false;
     +	}
     +
    -+	/* TODO: check that the endpoint uses HTTPS */
    ++	/*
    ++	 * The original implementation checked that OAUTH_GRANT_TYPE_DEVICE_CODE
    ++	 * was present in the discovery document's grant_types_supported list. MS
    ++	 * Entra does not advertise this grant type, though, and since it doesn't
    ++	 * make sense to stand up a device_authorization_endpoint without also
    ++	 * accepting device codes at the token_endpoint, that's the only thing we
    ++	 * currently require.
    ++	 */
    ++
    ++	/*
    ++	 * Although libcurl will fail later if the URL contains an unsupported
    ++	 * scheme, that error message is going to be a bit opaque. This is a
    ++	 * decent time to bail out if we're not using HTTPS for the endpoints
    ++	 * we'll use for the flow.
    ++	 */
    ++	if (!actx->debugging)
    ++	{
    ++		if (pg_strncasecmp(provider->device_authorization_endpoint,
    ++						   HTTPS_SCHEME, strlen(HTTPS_SCHEME)) != 0)
    ++		{
    ++			actx_error(actx,
    ++					   "device authorization endpoint \"%s\" must use HTTPS",
    ++					   provider->device_authorization_endpoint);
    ++			return false;
    ++		}
    ++
    ++		if (pg_strncasecmp(provider->token_endpoint,
    ++						   HTTPS_SCHEME, strlen(HTTPS_SCHEME)) != 0)
    ++		{
    ++			actx_error(actx,
    ++					   "token endpoint \"%s\" must use HTTPS",
    ++					   provider->token_endpoint);
    ++			return false;
    ++		}
    ++	}
     +
     +	return true;
     +}
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +	return true;
     +}
     +
    ++/*
    ++ * Calls curl_global_init() in a thread-safe way.
    ++ *
    ++ * libcurl has stringent requirements for the thread context in which you call
    ++ * curl_global_init(), because it's going to try initializing a bunch of other
    ++ * libraries (OpenSSL, Winsock, etc). Recent versions of libcurl have improved
    ++ * the thread-safety situation, but there's a chicken-and-egg problem at
    ++ * runtime: you can't check the thread safety until you've initialized libcurl,
    ++ * which you can't do from within a thread unless you know it's thread-safe...
    ++ *
    ++ * Returns true if initialization was successful. Successful or not, this
    ++ * function will not try to reinitialize Curl on successive calls.
    ++ */
    ++static bool
    ++initialize_curl(PGconn *conn)
    ++{
    ++	/*
    ++	 * Don't let the compiler play tricks with this variable. In the
    ++	 * HAVE_THREADSAFE_CURL_GLOBAL_INIT case, we don't care if two threads
    ++	 * enter simultaneously, but we do care if this gets set transiently to
    ++	 * PG_BOOL_YES/NO in cases where that's not the final answer.
    ++	 */
    ++	static volatile PGTernaryBool init_successful = PG_BOOL_UNKNOWN;
    ++#if HAVE_THREADSAFE_CURL_GLOBAL_INIT
    ++	curl_version_info_data *info;
    ++#endif
    ++
    ++#if !HAVE_THREADSAFE_CURL_GLOBAL_INIT
    ++
    ++	/*
    ++	 * Lock around the whole function. If a libpq client performs its own work
    ++	 * with libcurl, it must either ensure that Curl is initialized safely
    ++	 * before calling us (in which case our call will be a no-op), or else it
    ++	 * must guard its own calls to curl_global_init() with a registered
    ++	 * threadlock handler. See PQregisterThreadLock().
    ++	 */
    ++	pglock_thread();
    ++#endif
    ++
    ++	/*
    ++	 * Skip initialization if we've already done it. (Curl tracks the number
    ++	 * of calls; there's no point in incrementing the counter every time we
    ++	 * connect.)
    ++	 */
    ++	if (init_successful == PG_BOOL_YES)
    ++		goto done;
    ++	else if (init_successful == PG_BOOL_NO)
    ++	{
    ++		libpq_append_conn_error(conn,
    ++								"curl_global_init previously failed during OAuth setup");
    ++		goto done;
    ++	}
    ++
    ++	/*
    ++	 * We know we've already initialized Winsock by this point (see
    ++	 * pqMakeEmptyPGconn()), so we should be able to safely skip that bit. But
    ++	 * we have to tell libcurl to initialize everything else, because other
    ++	 * pieces of our client executable may already be using libcurl for their
    ++	 * own purposes. If we initialize libcurl with only a subset of its
    ++	 * features, we could break those other clients nondeterministically, and
    ++	 * that would probably be a nightmare to debug.
    ++	 *
    ++	 * If some other part of the program has already called this, it's a
    ++	 * no-op.
    ++	 */
    ++	if (curl_global_init(CURL_GLOBAL_ALL & ~CURL_GLOBAL_WIN32) != CURLE_OK)
    ++	{
    ++		libpq_append_conn_error(conn,
    ++								"curl_global_init failed during OAuth setup");
    ++		init_successful = PG_BOOL_NO;
    ++		goto done;
    ++	}
    ++
    ++#if HAVE_THREADSAFE_CURL_GLOBAL_INIT
    ++
    ++	/*
    ++	 * If we determined at configure time that the Curl installation is
    ++	 * threadsafe, our job here is much easier. We simply initialize above
    ++	 * without any locking (concurrent or duplicated calls are fine in that
    ++	 * situation), then double-check to make sure the runtime setting agrees,
    ++	 * to try to catch silent downgrades.
    ++	 */
    ++	info = curl_version_info(CURLVERSION_NOW);
    ++	if (!(info->features & CURL_VERSION_THREADSAFE))
    ++	{
    ++		/*
    ++		 * 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 threadsafe\n"
    ++								"\tCurl initialization was reported threadsafe 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;
    ++	}
    ++#endif
    ++
    ++	init_successful = PG_BOOL_YES;
    ++
    ++done:
    ++#if !HAVE_THREADSAFE_CURL_GLOBAL_INIT
    ++	pgunlock_thread();
    ++#endif
    ++	return (init_successful == PG_BOOL_YES);
    ++}
     +
     +/*
     + * The core nonblocking libcurl implementation. This will be called several
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +	fe_oauth_state *state = conn->sasl_state;
     +	struct async_ctx *actx;
     +
    -+	/*
    -+	 * XXX This is not safe. libcurl has stringent requirements for the thread
    -+	 * context in which you call curl_global_init(), because it's going to try
    -+	 * initializing a bunch of other libraries (OpenSSL, Winsock...). And we
    -+	 * probably need to consider both the TLS backend libcurl is compiled
    -+	 * against and what the user has asked us to do via PQinit[Open]SSL.
    -+	 *
    -+	 * Recent versions of libcurl have improved the thread-safety situation,
    -+	 * but you apparently can't check at compile time whether the
    -+	 * implementation is thread-safe, and there's a chicken-and-egg problem
    -+	 * where you can't check the thread safety until you've initialized
    -+	 * libcurl, which you can't do before you've made sure it's thread-safe...
    -+	 *
    -+	 * We know we've already initialized Winsock by this point, so we should
    -+	 * be able to safely skip that bit. But we have to tell libcurl to
    -+	 * initialize everything else, because other pieces of our client
    -+	 * executable may already be using libcurl for their own purposes. If we
    -+	 * initialize libcurl first, with only a subset of its features, we could
    -+	 * break those other clients nondeterministically, and that would probably
    -+	 * be a nightmare to debug.
    -+	 */
    -+	curl_global_init(CURL_GLOBAL_ALL
    -+					 & ~CURL_GLOBAL_WIN32); /* we already initialized Winsock */
    ++	if (!initialize_curl(conn))
    ++		return PGRES_POLLING_FAILED;
     +
     +	if (!state->async_ctx)
     +	{
    @@ src/interfaces/libpq/fe-connect.c: static const internalPQconninfoOption PQconni
      	/* Terminating entry --- MUST BE LAST */
      	{NULL, NULL, NULL, NULL,
      	NULL, NULL, 0}
    +@@ src/interfaces/libpq/fe-connect.c: static const PQEnvironmentOption EnvironmentOptions[] =
    + static const pg_fe_sasl_mech *supported_sasl_mechs[] =
    + {
    + 	&pg_scram_mech,
    ++	&pg_oauth_mech,
    + };
    + #define SASL_MECHANISM_COUNT lengthof(supported_sasl_mechs)
    + 
     @@ src/interfaces/libpq/fe-connect.c: pqDropServerData(PGconn *conn)
      	conn->write_failed = false;
      	free(conn->write_err_msg);
    @@ src/interfaces/libpq/fe-connect.c: static inline void
      	 * linked list, conn->allowed_sasl_mechs is an array of static length. We
      	 * rely on the compile-time assertion here to keep us honest.
      	 *
    -@@ src/interfaces/libpq/fe-connect.c: fill_allowed_sasl_mechs(PGconn *conn)
    - 	 * - handle the new mechanism name in the require_auth portion of
    - 	 *   pqConnectOptions2(), below.
    - 	 */
    --	StaticAssertDecl(lengthof(conn->allowed_sasl_mechs) == 1,
    -+	StaticAssertDecl(lengthof(conn->allowed_sasl_mechs) == 2,
    - 					 "fill_allowed_sasl_mechs() must be updated when resizing conn->allowed_sasl_mechs[]");
    - 
    - 	conn->allowed_sasl_mechs[0] = &pg_scram_mech;
    -+	conn->allowed_sasl_mechs[1] = &pg_oauth_mech;
    - }
    - 
    - /*
     @@ src/interfaces/libpq/fe-connect.c: pqConnectOptions2(PGconn *conn)
      			{
      				mech = &pg_scram_mech;
5:  66ef3b4b687 = 5:  035a3832b40 XXX fix libcurl link error
6:  4df1bc59638 ! 6:  5e360725bf9 DO NOT MERGE: Add pytest suite for OAuth
    @@ src/test/python/client/test_oauth.py (new)
     +                {
     +                    "issuer": "{issuer}",
     +                    "token_endpoint": "https://256.256.256.256/token";,
    -+                    "device_authorization_endpoint": "https://256.256.256.256/dev";,
    -+                },
    -+            ),
    -+            r'cannot run OAuth device authorization: issuer "https://.*"; does not support device code grants',
    -+            id="missing device code grants",
    -+        ),
    -+        pytest.param(
    -+            (
    -+                200,
    -+                {
    -+                    "issuer": "{issuer}",
    -+                    "token_endpoint": "https://256.256.256.256/token";,
     +                    "grant_types_supported": [
     +                        "urn:ietf:params:oauth:grant-type:device_code"
     +                    ],


Attachments:

  [text/plain] since-v43.diff.txt (34.7K, ../../CAOYmi+kJqzo6XsR9TEhvVfeVNQ-TyFM5LATypm9yoQVYk=4Wrw@mail.gmail.com/2-since-v43.diff.txt)
  download | inline:
1:  5d474397364 = 1:  258b8dbb770 Move PG_MAX_AUTH_TOKEN_LENGTH to libpq/auth.h
2:  20452d21e0b ! 2:  ec960cf363d require_auth: prepare for multiple SASL mechanisms
    @@ src/interfaces/libpq/fe-auth.c: pg_SASL_init(PGconn *conn, int payloadlen)
      	{
     
      ## src/interfaces/libpq/fe-connect.c ##
    +@@ src/interfaces/libpq/fe-connect.c: static const PQEnvironmentOption EnvironmentOptions[] =
    + 	}
    + };
    + 
    ++static const pg_fe_sasl_mech *supported_sasl_mechs[] =
    ++{
    ++	&pg_scram_mech,
    ++};
    ++#define SASL_MECHANISM_COUNT lengthof(supported_sasl_mechs)
    ++
    + /* The connection URI must start with either of the following designators: */
    + static const char uri_designator[] = "postgresql://";
    + static const char short_uri_designator[] = "postgres://";
     @@ src/interfaces/libpq/fe-connect.c: libpq_prng_init(PGconn *conn)
      	pg_prng_seed(&conn->prng_state, rseed);
      }
    @@ src/interfaces/libpq/fe-connect.c: libpq_prng_init(PGconn *conn)
     +	 * rely on the compile-time assertion here to keep us honest.
     +	 *
     +	 * To add a new mechanism to require_auth,
    ++	 * - add it to supported_sasl_mechs,
     +	 * - update the length of conn->allowed_sasl_mechs,
    -+	 * - add the new pg_fe_sasl_mech pointer to this function, and
     +	 * - handle the new mechanism name in the require_auth portion of
     +	 *   pqConnectOptions2(), below.
     +	 */
    -+	StaticAssertDecl(lengthof(conn->allowed_sasl_mechs) == 1,
    -+					 "fill_allowed_sasl_mechs() must be updated when resizing conn->allowed_sasl_mechs[]");
    ++	StaticAssertDecl(lengthof(conn->allowed_sasl_mechs) == SASL_MECHANISM_COUNT,
    ++					 "conn->allowed_sasl_mechs[] is not sufficiently large for holding all supported SASL mechanisms");
     +
    -+	conn->allowed_sasl_mechs[0] = &pg_scram_mech;
    ++	for (int i = 0; i < SASL_MECHANISM_COUNT; i++)
    ++		conn->allowed_sasl_mechs[i] = supported_sasl_mechs[i];
     +}
     +
     +/*
    @@ src/interfaces/libpq/fe-connect.c: pqConnectOptions2(PGconn *conn)
     +			 * Next group: SASL mechanisms. All of these use the same request
     +			 * codes, so the list of allowed mechanisms is tracked separately.
     +			 *
    -+			 * fill_allowed_sasl_mechs() must be updated when adding a new
    -+			 * mechanism here!
    ++			 * supported_sasl_mechs must contain all mechanisms handled here.
     +			 */
      			else if (strcmp(method, "scram-sha-256") == 0)
      			{
    @@ src/interfaces/libpq/fe-connect.c: pqConnectOptions2(PGconn *conn)
     +					i = index_of_allowed_sasl_mech(conn, mech);
     +					if (i < 0)
     +						goto duplicate;
    - 
    --				conn->allowed_auth_methods &= ~bits;
    ++
     +					conn->allowed_sasl_mechs[i] = NULL;
     +				}
     +				else
    @@ src/interfaces/libpq/fe-connect.c: pqConnectOptions2(PGconn *conn)
     +					i = index_of_allowed_sasl_mech(conn, mech);
     +					if (i >= 0)
     +						goto duplicate;
    -+
    + 
    +-				conn->allowed_auth_methods &= ~bits;
     +					i = index_of_allowed_sasl_mech(conn, NULL);
     +					if (i < 0)
     +					{
3:  f0afefb80d6 ! 3:  9725788086c libpq: handle asynchronous actions during SASL
    @@ src/interfaces/libpq/fe-connect.c: pqDropConnection(PGconn *conn, bool flushInpu
     +		conn->cleanup_async_auth = NULL;
     +	}
     +	conn->async_auth = NULL;
    -+	conn->altsock = PGINVALID_SOCKET;	/* cleanup_async_auth() should have
    -+										 * done this, but make sure. */
    ++	/* cleanup_async_auth() should have done this, but make sure */
    ++	conn->altsock = PGINVALID_SOCKET;
      #ifdef ENABLE_GSS
      	{
      		OM_uint32	min_s;
4:  711ca3f1efc ! 4:  a260d9436f0 Add OAUTHBEARER SASL mechanism
    @@ .cirrus.tasks.yml: task:
        ###
        # Test that code can be built with gcc/clang without warnings
     
    + ## config/programs.m4 ##
    +@@ config/programs.m4: AC_DEFUN([PGAC_CHECK_STRIP],
    +   AC_SUBST(STRIP_STATIC_LIB)
    +   AC_SUBST(STRIP_SHARED_LIB)
    + ])# PGAC_CHECK_STRIP
    ++
    ++
    ++
    ++# PGAC_CHECK_LIBCURL
    ++# ------------------
    ++# Check for required libraries and headers, and test to see whether the current
    ++# installation of libcurl is threadsafe.
    ++
    ++AC_DEFUN([PGAC_CHECK_LIBCURL],
    ++[
    ++  AC_CHECK_HEADER(curl/curl.h, [],
    ++				  [AC_MSG_ERROR([header file <curl/curl.h> is required for --with-libcurl])])
    ++  AC_CHECK_LIB(curl, curl_multi_init, [],
    ++			   [AC_MSG_ERROR([library 'curl' does not provide curl_multi_init])])
    ++
    ++  # Check to see whether the current platform supports threadsafe Curl
    ++  # initialization.
    ++  AC_CACHE_CHECK([for curl_global_init thread safety], [pgac_cv__libcurl_threadsafe_init],
    ++  [AC_RUN_IFELSE([AC_LANG_PROGRAM([
    ++#include <curl/curl.h>
    ++],[
    ++    curl_version_info_data *info;
    ++
    ++    if (curl_global_init(CURL_GLOBAL_ALL))
    ++        return -1;
    ++
    ++    info = curl_version_info(CURLVERSION_NOW);
    ++#ifdef CURL_VERSION_THREADSAFE
    ++    if (info->features & CURL_VERSION_THREADSAFE)
    ++        return 0;
    ++#endif
    ++
    ++    return 1;
    ++])],
    ++  [pgac_cv__libcurl_threadsafe_init=yes],
    ++  [pgac_cv__libcurl_threadsafe_init=no],
    ++  [pgac_cv__libcurl_threadsafe_init=unknown])])
    ++  if test x"$pgac_cv__libcurl_threadsafe_init" = xyes ; then
    ++    AC_DEFINE(HAVE_THREADSAFE_CURL_GLOBAL_INIT, 1,
    ++              [Define to 1 if curl_global_init() is guaranteed to be threadsafe.])
    ++  fi
    ++])# PGAC_CHECK_LIBCURL
    +
      ## configure ##
     @@ configure: XML2_LIBS
      XML2_CFLAGS
    @@ configure: fi
     +
     +fi
     +
    ++  # We only care about -I, -D, and -L switches;
    ++  # note that -lcurl will be added by PGAC_CHECK_LIBCURL below.
    ++  for pgac_option in $LIBCURL_CFLAGS; do
    ++    case $pgac_option in
    ++      -I*|-D*) CPPFLAGS="$CPPFLAGS $pgac_option";;
    ++    esac
    ++  done
    ++  for pgac_option in $LIBCURL_LIBS; do
    ++    case $pgac_option in
    ++      -L*) LDFLAGS="$LDFLAGS $pgac_option";;
    ++    esac
    ++  done
    ++
     +  # OAuth requires python for testing
     +  if test "$with_python" != yes; then
     +    { $as_echo "$as_me:${as_lineno-$LINENO}: WARNING: *** OAuth support tests require --with-python to run" >&5
    @@ configure: fi
     +# during gss_acquire_cred(). This is possibly related to Curl's Heimdal
     +# dependency on that platform?
     +if test "$with_libcurl" = yes ; then
    ++
    ++  ac_fn_c_check_header_mongrel "$LINENO" "curl/curl.h" "ac_cv_header_curl_curl_h" "$ac_includes_default"
    ++if test "x$ac_cv_header_curl_curl_h" = xyes; then :
    ++
    ++else
    ++  as_fn_error $? "header file <curl/curl.h> is required for --with-libcurl" "$LINENO" 5
    ++fi
    ++
    ++
     +  { $as_echo "$as_me:${as_lineno-$LINENO}: checking for curl_multi_init in -lcurl" >&5
     +$as_echo_n "checking for curl_multi_init in -lcurl... " >&6; }
     +if ${ac_cv_lib_curl_curl_multi_init+:} false; then :
    @@ configure: fi
     +  LIBS="-lcurl $LIBS"
     +
     +else
    -+  as_fn_error $? "library 'curl' is required for --with-libcurl" "$LINENO" 5
    ++  as_fn_error $? "library 'curl' does not provide curl_multi_init" "$LINENO" 5
     +fi
     +
    ++
    ++  # Check to see whether the current platform supports threadsafe Curl
    ++  # initialization.
    ++  { $as_echo "$as_me:${as_lineno-$LINENO}: checking for curl_global_init thread safety" >&5
    ++$as_echo_n "checking for curl_global_init thread safety... " >&6; }
    ++if ${pgac_cv__libcurl_threadsafe_init+:} false; then :
    ++  $as_echo_n "(cached) " >&6
    ++else
    ++  if test "$cross_compiling" = yes; then :
    ++  pgac_cv__libcurl_threadsafe_init=unknown
    ++else
    ++  cat confdefs.h - <<_ACEOF >conftest.$ac_ext
    ++/* end confdefs.h.  */
    ++
    ++#include <curl/curl.h>
    ++
    ++int
    ++main ()
    ++{
    ++
    ++    curl_version_info_data *info;
    ++
    ++    if (curl_global_init(CURL_GLOBAL_ALL))
    ++        return -1;
    ++
    ++    info = curl_version_info(CURLVERSION_NOW);
    ++#ifdef CURL_VERSION_THREADSAFE
    ++    if (info->features & CURL_VERSION_THREADSAFE)
    ++        return 0;
    ++#endif
    ++
    ++    return 1;
    ++
    ++  ;
    ++  return 0;
    ++}
    ++_ACEOF
    ++if ac_fn_c_try_run "$LINENO"; then :
    ++  pgac_cv__libcurl_threadsafe_init=yes
    ++else
    ++  pgac_cv__libcurl_threadsafe_init=no
    ++fi
    ++rm -f core *.core core.conftest.* gmon.out bb.out conftest$ac_exeext \
    ++  conftest.$ac_objext conftest.beam conftest.$ac_ext
    ++fi
    ++
    ++fi
    ++{ $as_echo "$as_me:${as_lineno-$LINENO}: result: $pgac_cv__libcurl_threadsafe_init" >&5
    ++$as_echo "$pgac_cv__libcurl_threadsafe_init" >&6; }
    ++  if test x"$pgac_cv__libcurl_threadsafe_init" = xyes ; then
    ++
    ++$as_echo "#define HAVE_THREADSAFE_CURL_GLOBAL_INIT 1" >>confdefs.h
    ++
    ++  fi
    ++
     +fi
     +
      if test "$with_gssapi" = yes ; then
        if test "$PORTNAME" != "win32"; then
          { $as_echo "$as_me:${as_lineno-$LINENO}: checking for library containing gss_store_cred_into" >&5
    -@@ configure: fi
    - 
    - done
    - 
    -+fi
    -+
    -+if test "$with_libcurl" = yes; then
    -+  ac_fn_c_check_header_mongrel "$LINENO" "curl/curl.h" "ac_cv_header_curl_curl_h" "$ac_includes_default"
    -+if test "x$ac_cv_header_curl_curl_h" = xyes; then :
    -+
    -+else
    -+  as_fn_error $? "header file <curl/curl.h> is required for --with-libcurl" "$LINENO" 5
    -+fi
    -+
    -+
    - fi
    - 
    - if test "$PORTNAME" = "win32" ; then
     
      ## configure.ac ##
     @@ configure.ac: fi
    @@ configure.ac: fi
     +  # to explicitly set TLS 1.3 ciphersuites).
     +  PKG_CHECK_MODULES(LIBCURL, [libcurl >= 7.61.0])
     +
    ++  # We only care about -I, -D, and -L switches;
    ++  # note that -lcurl will be added by PGAC_CHECK_LIBCURL below.
    ++  for pgac_option in $LIBCURL_CFLAGS; do
    ++    case $pgac_option in
    ++      -I*|-D*) CPPFLAGS="$CPPFLAGS $pgac_option";;
    ++    esac
    ++  done
    ++  for pgac_option in $LIBCURL_LIBS; do
    ++    case $pgac_option in
    ++      -L*) LDFLAGS="$LDFLAGS $pgac_option";;
    ++    esac
    ++  done
    ++
     +  # OAuth requires python for testing
     +  if test "$with_python" != yes; then
     +    AC_MSG_WARN([*** OAuth support tests require --with-python to run])
    @@ configure.ac: failure.  It is possible the compiler isn't looking in the proper
     +# during gss_acquire_cred(). This is possibly related to Curl's Heimdal
     +# dependency on that platform?
     +if test "$with_libcurl" = yes ; then
    -+  AC_CHECK_LIB(curl, curl_multi_init, [], [AC_MSG_ERROR([library 'curl' is required for --with-libcurl])])
    ++  PGAC_CHECK_LIBCURL
     +fi
     +
      if test "$with_gssapi" = yes ; then
        if test "$PORTNAME" != "win32"; then
          AC_SEARCH_LIBS(gss_store_cred_into, [gssapi_krb5 gss 'gssapi -lkrb5 -lcrypto'], [],
    -@@ configure.ac: elif test "$with_uuid" = ossp ; then
    -       [AC_MSG_ERROR([header file <ossp/uuid.h> or <uuid.h> is required for OSSP UUID])])])
    - fi
    - 
    -+if test "$with_libcurl" = yes; then
    -+  AC_CHECK_HEADER(curl/curl.h, [], [AC_MSG_ERROR([header file <curl/curl.h> is required for --with-libcurl])])
    -+fi
    -+
    - if test "$PORTNAME" = "win32" ; then
    -    AC_CHECK_HEADERS(crtdefs.h)
    - fi
     
      ## doc/src/sgml/client-auth.sgml ##
     @@ doc/src/sgml/client-auth.sgml: include_dir         <replaceable>directory</replaceable>
    @@ doc/src/sgml/libpq.sgml: void PQinitSSL(int do_ssl);
      
       <sect1 id="libpq-threading">
        <title>Behavior in Threaded Programs</title>
    +@@ doc/src/sgml/libpq.sgml: int PQisthreadsafe();
    +    <application>libpq</application> source code for a way to do cooperative
    +    locking between <application>libpq</application> and your application.
    +   </para>
    ++
    ++  <para>
    ++   Similarly, if you are using Curl inside your application,
    ++   <emphasis>and</emphasis> you do not already
    ++   <ulink url="https://curl.se/libcurl/c/curl_global_init.html">initialize
    ++   libcurl globally</ulink> before starting new threads, you will need to
    ++   cooperatively lock (again via <function>PQregisterThreadLock</function>)
    ++   around any code that may initialize libcurl. This restriction is lifted for
    ++   more recent versions of Curl that are built to support threadsafe
    ++   initialization; those builds can be identified by the advertisement of a
    ++   <literal>threadsafe</literal> feature in their version metadata.
    ++  </para>
    +  </sect1>
    + 
    + 
     
      ## doc/src/sgml/oauth-validators.sgml (new) ##
     @@
    @@ doc/src/sgml/postgres.sgml: break is not needed in a wider output rendering.
       </part>
      
     
    + ## doc/src/sgml/protocol.sgml ##
    +@@ doc/src/sgml/protocol.sgml: SELCT 1/0;<!-- this typo is intentional -->
    + 
    +   <para>
    +    <firstterm>SASL</firstterm> is a framework for authentication in connection-oriented
    +-   protocols. At the moment, <productname>PostgreSQL</productname> implements two SASL
    +-   authentication mechanisms, SCRAM-SHA-256 and SCRAM-SHA-256-PLUS. More
    +-   might be added in the future. The below steps illustrate how SASL
    +-   authentication is performed in general, while the next subsection gives
    +-   more details on SCRAM-SHA-256 and SCRAM-SHA-256-PLUS.
    ++   protocols. At the moment, <productname>PostgreSQL</productname> implements three
    ++   SASL authentication mechanisms: SCRAM-SHA-256, SCRAM-SHA-256-PLUS, and
    ++   OAUTHBEARER. More might be added in the future. The below steps illustrate how SASL
    ++   authentication is performed in general, while the next subsections give
    ++   more details on particular mechanisms.
    +   </para>
    + 
    +   <procedure>
    +@@ doc/src/sgml/protocol.sgml: SELCT 1/0;<!-- this typo is intentional -->
    +    <step id="sasl-auth-end">
    +     <para>
    +      Finally, when the authentication exchange is completed successfully, the
    +-     server sends an AuthenticationSASLFinal message, followed
    ++     server sends an optional AuthenticationSASLFinal message, followed
    +      immediately by an AuthenticationOk message. The AuthenticationSASLFinal
    +      contains additional server-to-client data, whose content is particular to the
    +      selected authentication mechanism. If the authentication mechanism doesn't
    +@@ doc/src/sgml/protocol.sgml: SELCT 1/0;<!-- this typo is intentional -->
    +    <title>SCRAM-SHA-256 Authentication</title>
    + 
    +    <para>
    +-    The implemented SASL mechanisms at the moment
    +-    are <literal>SCRAM-SHA-256</literal> and its variant with channel
    +-    binding <literal>SCRAM-SHA-256-PLUS</literal>. They are described in
    ++    <literal>SCRAM-SHA-256</literal>, and its variant with channel
    ++    binding <literal>SCRAM-SHA-256-PLUS</literal>, are password-based
    ++    authentication mechanisms. They are described in
    +     detail in <ulink url="https://datatracker.ietf.org/doc/html/rfc7677">RFC 7677</ulink>
    +     and <ulink url="https://datatracker.ietf.org/doc/html/rfc5802">RFC 5802</ulink>.
    +    </para>
    +@@ doc/src/sgml/protocol.sgml: SELCT 1/0;<!-- this typo is intentional -->
    +     </step>
    +    </procedure>
    +   </sect2>
    ++
    ++  <sect2 id="sasl-oauthbearer">
    ++   <title>OAUTHBEARER Authentication</title>
    ++
    ++   <para>
    ++    <literal>OAUTHBEARER</literal> is a token-based mechanism for federated
    ++    authentication. It is described in detail in
    ++    <ulink url="https://datatracker.ietf.org/doc/html/rfc7628">RFC 7628</ulink>.
    ++   </para>
    ++
    ++   <para>
    ++    A typical exchange differs depending on whether or not the client already
    ++    has a bearer token cached for the current user. If it does not, the exchange
    ++    will take place over two connections: the first "discovery" connection to
    ++    obtain OAuth metadata from the server, and the second connection to send
    ++    the token after the client has obtained it. (libpq does not currently
    ++    implement a caching method as part of its builtin flow, so it uses the
    ++    two-connection exchange.)
    ++   </para>
    ++
    ++   <para>
    ++    This mechanism is client-initiated, like SCRAM. The client initial response
    ++    consists of the standard "GS2" header used by SCRAM, followed by a list of
    ++    <literal>key=value</literal> pairs. The only key currently supported by
    ++    the server is <literal>auth</literal>, which contains the bearer token.
    ++    <literal>OAUTHBEARER</literal> additionally specifies three optional
    ++    components of the client initial response (the <literal>authzid</literal> of
    ++    the GS2 header, and the <structfield>host</structfield> and
    ++    <structfield>port</structfield> keys) which are currently ignored by the
    ++    server.
    ++   </para>
    ++
    ++   <para>
    ++    <literal>OAUTHBEARER</literal> does not support channel binding, and there
    ++    is no "OAUTHBEARER-PLUS" mechanism. This mechanism does not make use of
    ++    server data during a successful authentication, so the
    ++    AuthenticationSASLFinal message is not used in the exchange.
    ++   </para>
    ++
    ++   <procedure>
    ++    <title>Example</title>
    ++    <step>
    ++     <para>
    ++      During the first exchange, the server sends an AuthenticationSASL message
    ++      with the <literal>OAUTHBEARER</literal> mechanism advertised.
    ++     </para>
    ++    </step>
    ++
    ++    <step>
    ++     <para>
    ++      The client responds by sending a SASLInitialResponse message which
    ++      indicates the <literal>OAUTHBEARER</literal> mechanism. Assuming the
    ++      client does not already have a valid bearer token for the current user,
    ++      the <structfield>auth</structfield> field is empty, indicating a discovery
    ++      connection.
    ++     </para>
    ++    </step>
    ++
    ++    <step>
    ++     <para>
    ++      Server sends an AuthenticationSASLContinue message containing an error
    ++      <literal>status</literal> alongside a well-known URI and scopes that the
    ++      client should use to conduct an OAuth flow.
    ++     </para>
    ++    </step>
    ++
    ++    <step>
    ++     <para>
    ++      Client sends a SASLResponse message containing the empty set (a single
    ++      <literal>0x01</literal> byte) to finish its half of the discovery
    ++      exchange.
    ++     </para>
    ++    </step>
    ++
    ++    <step>
    ++     <para>
    ++      Server sends an ErrorMessage to fail the first exchange.
    ++     </para>
    ++     <para>
    ++      At this point, the client conducts one of many possible OAuth flows to
    ++      obtain a bearer token, using any metadata that it has been configured with
    ++      in addition to that provided by the server. (This description is left
    ++      deliberately vague; <literal>OAUTHBEARER</literal> does not specify or
    ++      mandate any particular method for obtaining a token.)
    ++     </para>
    ++     <para>
    ++      Once it has a token, the client reconnects to the server for the final
    ++      exchange:
    ++     </para>
    ++    </step>
    ++
    ++    <step>
    ++     <para>
    ++      The server once again sends an AuthenticationSASL message with the
    ++      <literal>OAUTHBEARER</literal> mechanism advertised.
    ++     </para>
    ++    </step>
    ++
    ++    <step>
    ++     <para>
    ++      The client responds by sending a SASLInitialResponse message, but this
    ++      time the <structfield>auth</structfield> field in the message contains the
    ++      bearer token that was obtained during the client flow.
    ++     </para>
    ++    </step>
    ++
    ++    <step>
    ++     <para>
    ++      The server validates the token according to the instructions of the
    ++      token provider. If the client is authorized to connect, it sends an
    ++      AuthenticationOk message to end the SASL exchange.
    ++     </para>
    ++    </step>
    ++   </procedure>
    ++  </sect2>
    +  </sect1>
    + 
    +  <sect1 id="protocol-replication">
    +
      ## doc/src/sgml/regress.sgml ##
     @@ doc/src/sgml/regress.sgml: make check-world PG_TEST_EXTRA='kerberos ldap ssl load_balance libpq_encryption'
            </para>
    @@ meson.build: endif
     +  libcurl = dependency('libcurl', version: '>= 7.61.0', required: libcurlopt)
     +  if libcurl.found()
     +    cdata.set('USE_LIBCURL', 1)
    ++
    ++    # Check to see whether the current platform supports threadsafe Curl
    ++    # initialization.
    ++    libcurl_threadsafe_init = false
    ++
    ++    if not meson.is_cross_build()
    ++      r = cc.run('''
    ++        #include <curl/curl.h>
    ++
    ++        int main(void)
    ++        {
    ++            curl_version_info_data *info;
    ++
    ++            if (curl_global_init(CURL_GLOBAL_ALL))
    ++                return -1;
    ++
    ++            info = curl_version_info(CURLVERSION_NOW);
    ++        #ifdef CURL_VERSION_THREADSAFE
    ++            if (info->features & CURL_VERSION_THREADSAFE)
    ++                return 0;
    ++        #endif
    ++
    ++            return 1;
    ++        }''',
    ++        name: 'test for curl_global_init thread safety',
    ++        dependencies: libcurl,
    ++      )
    ++
    ++      assert(r.compiled())
    ++      if r.returncode() == 0
    ++        libcurl_threadsafe_init = true
    ++        message('curl_global_init is threadsafe')
    ++      elif r.returncode() == 1
    ++        message('curl_global_init is not threadsafe')
    ++      else
    ++        message('curl_global_init failed; assuming not threadsafe')
    ++      endif
    ++    endif
    ++
    ++    if libcurl_threadsafe_init
    ++      cdata.set('HAVE_THREADSAFE_CURL_GLOBAL_INIT', 1)
    ++    endif
     +  endif
    ++
     +else
     +  libcurl = not_found_dep
     +endif
    @@ src/backend/utils/misc/postgresql.conf.sample
      #ssl_passphrase_command_supports_reload = off
      
     +# OAuth
    -+#oauth_validator_libraries = ''
    ++#oauth_validator_libraries = ''	# comma-separated list of trusted validator modules
     +
      
      #------------------------------------------------------------------------------
    @@ src/include/pg_config.h.in
      /* Define to 1 if you have the `ldap' library (-lldap). */
      #undef HAVE_LIBLDAP
      
    +@@
    + /* Define to 1 if you have the <termios.h> header file. */
    + #undef HAVE_TERMIOS_H
    + 
    ++/* Define to 1 if curl_global_init() is guaranteed to be threadsafe. */
    ++#undef HAVE_THREADSAFE_CURL_GLOBAL_INIT
    ++
    + /* Define to 1 if your compiler understands `typeof' or something similar. */
    + #undef HAVE_TYPEOF
    + 
     @@
      /* Define to 1 to build with LDAP support. (--with-ldap) */
      #undef USE_LDAP
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +	return true;
     +}
     +
    ++#define HTTPS_SCHEME "https://"
     +#define OAUTH_GRANT_TYPE_DEVICE_CODE "urn:ietf:params:oauth:grant-type:device_code"
     +
     +/*
     + * Ensure that the provider supports the Device Authorization flow (i.e. it
    -+ * accepts the device_code grant type and provides an authorization endpoint).
    ++ * provides an authorization endpoint, and both the token and authorization
    ++ * endpoint URLs seem reasonable).
     + */
     +static bool
     +check_for_device_flow(struct async_ctx *actx)
     +{
     +	const struct provider *provider = &actx->provider;
    -+	const struct curl_slist *grant;
    -+	bool		device_grant_found = false;
     +
     +	Assert(provider->issuer);	/* ensured by parse_provider() */
    -+
    -+	/*------
    -+	 * First, sanity checks for discovery contents that are OPTIONAL in the
    -+	 * spec but required for our flow:
    -+	 * - the issuer must support the device_code grant
    -+	 * - the issuer must have actually given us a
    -+	 *   device_authorization_endpoint
    -+	 */
    -+
    -+	grant = provider->grant_types_supported;
    -+	while (grant)
    -+	{
    -+		if (strcmp(grant->data, OAUTH_GRANT_TYPE_DEVICE_CODE) == 0)
    -+		{
    -+			device_grant_found = true;
    -+			break;
    -+		}
    -+
    -+		grant = grant->next;
    -+	}
    -+
    -+	if (!device_grant_found)
    -+	{
    -+		actx_error(actx, "issuer \"%s\" does not support device code grants",
    -+				   provider->issuer);
    -+		return false;
    -+	}
    ++	Assert(provider->token_endpoint);	/* ensured by parse_provider() */
     +
     +	if (!provider->device_authorization_endpoint)
     +	{
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +		return false;
     +	}
     +
    -+	/* TODO: check that the endpoint uses HTTPS */
    ++	/*
    ++	 * The original implementation checked that OAUTH_GRANT_TYPE_DEVICE_CODE
    ++	 * was present in the discovery document's grant_types_supported list. MS
    ++	 * Entra does not advertise this grant type, though, and since it doesn't
    ++	 * make sense to stand up a device_authorization_endpoint without also
    ++	 * accepting device codes at the token_endpoint, that's the only thing we
    ++	 * currently require.
    ++	 */
    ++
    ++	/*
    ++	 * Although libcurl will fail later if the URL contains an unsupported
    ++	 * scheme, that error message is going to be a bit opaque. This is a
    ++	 * decent time to bail out if we're not using HTTPS for the endpoints
    ++	 * we'll use for the flow.
    ++	 */
    ++	if (!actx->debugging)
    ++	{
    ++		if (pg_strncasecmp(provider->device_authorization_endpoint,
    ++						   HTTPS_SCHEME, strlen(HTTPS_SCHEME)) != 0)
    ++		{
    ++			actx_error(actx,
    ++					   "device authorization endpoint \"%s\" must use HTTPS",
    ++					   provider->device_authorization_endpoint);
    ++			return false;
    ++		}
    ++
    ++		if (pg_strncasecmp(provider->token_endpoint,
    ++						   HTTPS_SCHEME, strlen(HTTPS_SCHEME)) != 0)
    ++		{
    ++			actx_error(actx,
    ++					   "token endpoint \"%s\" must use HTTPS",
    ++					   provider->token_endpoint);
    ++			return false;
    ++		}
    ++	}
     +
     +	return true;
     +}
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +	return true;
     +}
     +
    ++/*
    ++ * Calls curl_global_init() in a thread-safe way.
    ++ *
    ++ * libcurl has stringent requirements for the thread context in which you call
    ++ * curl_global_init(), because it's going to try initializing a bunch of other
    ++ * libraries (OpenSSL, Winsock, etc). Recent versions of libcurl have improved
    ++ * the thread-safety situation, but there's a chicken-and-egg problem at
    ++ * runtime: you can't check the thread safety until you've initialized libcurl,
    ++ * which you can't do from within a thread unless you know it's thread-safe...
    ++ *
    ++ * Returns true if initialization was successful. Successful or not, this
    ++ * function will not try to reinitialize Curl on successive calls.
    ++ */
    ++static bool
    ++initialize_curl(PGconn *conn)
    ++{
    ++	/*
    ++	 * Don't let the compiler play tricks with this variable. In the
    ++	 * HAVE_THREADSAFE_CURL_GLOBAL_INIT case, we don't care if two threads
    ++	 * enter simultaneously, but we do care if this gets set transiently to
    ++	 * PG_BOOL_YES/NO in cases where that's not the final answer.
    ++	 */
    ++	static volatile PGTernaryBool init_successful = PG_BOOL_UNKNOWN;
    ++#if HAVE_THREADSAFE_CURL_GLOBAL_INIT
    ++	curl_version_info_data *info;
    ++#endif
    ++
    ++#if !HAVE_THREADSAFE_CURL_GLOBAL_INIT
    ++
    ++	/*
    ++	 * Lock around the whole function. If a libpq client performs its own work
    ++	 * with libcurl, it must either ensure that Curl is initialized safely
    ++	 * before calling us (in which case our call will be a no-op), or else it
    ++	 * must guard its own calls to curl_global_init() with a registered
    ++	 * threadlock handler. See PQregisterThreadLock().
    ++	 */
    ++	pglock_thread();
    ++#endif
    ++
    ++	/*
    ++	 * Skip initialization if we've already done it. (Curl tracks the number
    ++	 * of calls; there's no point in incrementing the counter every time we
    ++	 * connect.)
    ++	 */
    ++	if (init_successful == PG_BOOL_YES)
    ++		goto done;
    ++	else if (init_successful == PG_BOOL_NO)
    ++	{
    ++		libpq_append_conn_error(conn,
    ++								"curl_global_init previously failed during OAuth setup");
    ++		goto done;
    ++	}
    ++
    ++	/*
    ++	 * We know we've already initialized Winsock by this point (see
    ++	 * pqMakeEmptyPGconn()), so we should be able to safely skip that bit. But
    ++	 * we have to tell libcurl to initialize everything else, because other
    ++	 * pieces of our client executable may already be using libcurl for their
    ++	 * own purposes. If we initialize libcurl with only a subset of its
    ++	 * features, we could break those other clients nondeterministically, and
    ++	 * that would probably be a nightmare to debug.
    ++	 *
    ++	 * If some other part of the program has already called this, it's a
    ++	 * no-op.
    ++	 */
    ++	if (curl_global_init(CURL_GLOBAL_ALL & ~CURL_GLOBAL_WIN32) != CURLE_OK)
    ++	{
    ++		libpq_append_conn_error(conn,
    ++								"curl_global_init failed during OAuth setup");
    ++		init_successful = PG_BOOL_NO;
    ++		goto done;
    ++	}
    ++
    ++#if HAVE_THREADSAFE_CURL_GLOBAL_INIT
    ++
    ++	/*
    ++	 * If we determined at configure time that the Curl installation is
    ++	 * threadsafe, our job here is much easier. We simply initialize above
    ++	 * without any locking (concurrent or duplicated calls are fine in that
    ++	 * situation), then double-check to make sure the runtime setting agrees,
    ++	 * to try to catch silent downgrades.
    ++	 */
    ++	info = curl_version_info(CURLVERSION_NOW);
    ++	if (!(info->features & CURL_VERSION_THREADSAFE))
    ++	{
    ++		/*
    ++		 * 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 threadsafe\n"
    ++								"\tCurl initialization was reported threadsafe 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;
    ++	}
    ++#endif
    ++
    ++	init_successful = PG_BOOL_YES;
    ++
    ++done:
    ++#if !HAVE_THREADSAFE_CURL_GLOBAL_INIT
    ++	pgunlock_thread();
    ++#endif
    ++	return (init_successful == PG_BOOL_YES);
    ++}
     +
     +/*
     + * The core nonblocking libcurl implementation. This will be called several
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +	fe_oauth_state *state = conn->sasl_state;
     +	struct async_ctx *actx;
     +
    -+	/*
    -+	 * XXX This is not safe. libcurl has stringent requirements for the thread
    -+	 * context in which you call curl_global_init(), because it's going to try
    -+	 * initializing a bunch of other libraries (OpenSSL, Winsock...). And we
    -+	 * probably need to consider both the TLS backend libcurl is compiled
    -+	 * against and what the user has asked us to do via PQinit[Open]SSL.
    -+	 *
    -+	 * Recent versions of libcurl have improved the thread-safety situation,
    -+	 * but you apparently can't check at compile time whether the
    -+	 * implementation is thread-safe, and there's a chicken-and-egg problem
    -+	 * where you can't check the thread safety until you've initialized
    -+	 * libcurl, which you can't do before you've made sure it's thread-safe...
    -+	 *
    -+	 * We know we've already initialized Winsock by this point, so we should
    -+	 * be able to safely skip that bit. But we have to tell libcurl to
    -+	 * initialize everything else, because other pieces of our client
    -+	 * executable may already be using libcurl for their own purposes. If we
    -+	 * initialize libcurl first, with only a subset of its features, we could
    -+	 * break those other clients nondeterministically, and that would probably
    -+	 * be a nightmare to debug.
    -+	 */
    -+	curl_global_init(CURL_GLOBAL_ALL
    -+					 & ~CURL_GLOBAL_WIN32); /* we already initialized Winsock */
    ++	if (!initialize_curl(conn))
    ++		return PGRES_POLLING_FAILED;
     +
     +	if (!state->async_ctx)
     +	{
    @@ src/interfaces/libpq/fe-connect.c: static const internalPQconninfoOption PQconni
      	/* Terminating entry --- MUST BE LAST */
      	{NULL, NULL, NULL, NULL,
      	NULL, NULL, 0}
    +@@ src/interfaces/libpq/fe-connect.c: static const PQEnvironmentOption EnvironmentOptions[] =
    + static const pg_fe_sasl_mech *supported_sasl_mechs[] =
    + {
    + 	&pg_scram_mech,
    ++	&pg_oauth_mech,
    + };
    + #define SASL_MECHANISM_COUNT lengthof(supported_sasl_mechs)
    + 
     @@ src/interfaces/libpq/fe-connect.c: pqDropServerData(PGconn *conn)
      	conn->write_failed = false;
      	free(conn->write_err_msg);
    @@ src/interfaces/libpq/fe-connect.c: static inline void
      	 * linked list, conn->allowed_sasl_mechs is an array of static length. We
      	 * rely on the compile-time assertion here to keep us honest.
      	 *
    -@@ src/interfaces/libpq/fe-connect.c: fill_allowed_sasl_mechs(PGconn *conn)
    - 	 * - handle the new mechanism name in the require_auth portion of
    - 	 *   pqConnectOptions2(), below.
    - 	 */
    --	StaticAssertDecl(lengthof(conn->allowed_sasl_mechs) == 1,
    -+	StaticAssertDecl(lengthof(conn->allowed_sasl_mechs) == 2,
    - 					 "fill_allowed_sasl_mechs() must be updated when resizing conn->allowed_sasl_mechs[]");
    - 
    - 	conn->allowed_sasl_mechs[0] = &pg_scram_mech;
    -+	conn->allowed_sasl_mechs[1] = &pg_oauth_mech;
    - }
    - 
    - /*
     @@ src/interfaces/libpq/fe-connect.c: pqConnectOptions2(PGconn *conn)
      			{
      				mech = &pg_scram_mech;
5:  66ef3b4b687 = 5:  035a3832b40 XXX fix libcurl link error
6:  4df1bc59638 ! 6:  5e360725bf9 DO NOT MERGE: Add pytest suite for OAuth
    @@ src/test/python/client/test_oauth.py (new)
     +                {
     +                    "issuer": "{issuer}",
     +                    "token_endpoint": "https://256.256.256.256/token",
    -+                    "device_authorization_endpoint": "https://256.256.256.256/dev",
    -+                },
    -+            ),
    -+            r'cannot run OAuth device authorization: issuer "https://.*" does not support device code grants',
    -+            id="missing device code grants",
    -+        ),
    -+        pytest.param(
    -+            (
    -+                200,
    -+                {
    -+                    "issuer": "{issuer}",
    -+                    "token_endpoint": "https://256.256.256.256/token",
     +                    "grant_types_supported": [
     +                        "urn:ietf:params:oauth:grant-type:device_code"
     +                    ],

  [application/octet-stream] v44-0001-Move-PG_MAX_AUTH_TOKEN_LENGTH-to-libpq-auth.h.patch (3.1K, ../../CAOYmi+kJqzo6XsR9TEhvVfeVNQ-TyFM5LATypm9yoQVYk=4Wrw@mail.gmail.com/3-v44-0001-Move-PG_MAX_AUTH_TOKEN_LENGTH-to-libpq-auth.h.patch)
  download | inline diff:
From 258b8dbb77021a3943aec88612bdc9796202a308 Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Wed, 8 Jan 2025 09:30:05 -0800
Subject: [PATCH v44 1/6] Move PG_MAX_AUTH_TOKEN_LENGTH to libpq/auth.h

OAUTHBEARER would like to use this as a limit on Bearer token messages
coming from the client, so promote it to the header file.
---
 src/backend/libpq/auth.c | 16 ----------------
 src/include/libpq/auth.h | 16 ++++++++++++++++
 2 files changed, 16 insertions(+), 16 deletions(-)

diff --git a/src/backend/libpq/auth.c b/src/backend/libpq/auth.c
index 46facc275ef..d6ef32cc823 100644
--- a/src/backend/libpq/auth.c
+++ b/src/backend/libpq/auth.c
@@ -201,22 +201,6 @@ static int	CheckRADIUSAuth(Port *port);
 static int	PerformRadiusTransaction(const char *server, const char *secret, const char *portstr, const char *identifier, const char *user_name, const char *passwd);
 
 
-/*
- * Maximum accepted size of GSS and SSPI authentication tokens.
- * We also use this as a limit on ordinary password packet lengths.
- *
- * Kerberos tickets are usually quite small, but the TGTs issued by Windows
- * domain controllers include an authorization field known as the Privilege
- * Attribute Certificate (PAC), which contains the user's Windows permissions
- * (group memberships etc.). The PAC is copied into all tickets obtained on
- * the basis of this TGT (even those issued by Unix realms which the Windows
- * realm trusts), and can be several kB in size. The maximum token size
- * accepted by Windows systems is determined by the MaxAuthToken Windows
- * registry setting. Microsoft recommends that it is not set higher than
- * 65535 bytes, so that seems like a reasonable limit for us as well.
- */
-#define PG_MAX_AUTH_TOKEN_LENGTH	65535
-
 /*----------------------------------------------------------------
  * Global authentication functions
  *----------------------------------------------------------------
diff --git a/src/include/libpq/auth.h b/src/include/libpq/auth.h
index 9157dbe6092..902c5f6de32 100644
--- a/src/include/libpq/auth.h
+++ b/src/include/libpq/auth.h
@@ -16,6 +16,22 @@
 
 #include "libpq/libpq-be.h"
 
+/*
+ * Maximum accepted size of GSS and SSPI authentication tokens.
+ * We also use this as a limit on ordinary password packet lengths.
+ *
+ * Kerberos tickets are usually quite small, but the TGTs issued by Windows
+ * domain controllers include an authorization field known as the Privilege
+ * Attribute Certificate (PAC), which contains the user's Windows permissions
+ * (group memberships etc.). The PAC is copied into all tickets obtained on
+ * the basis of this TGT (even those issued by Unix realms which the Windows
+ * realm trusts), and can be several kB in size. The maximum token size
+ * accepted by Windows systems is determined by the MaxAuthToken Windows
+ * registry setting. Microsoft recommends that it is not set higher than
+ * 65535 bytes, so that seems like a reasonable limit for us as well.
+ */
+#define PG_MAX_AUTH_TOKEN_LENGTH	65535
+
 extern PGDLLIMPORT char *pg_krb_server_keyfile;
 extern PGDLLIMPORT bool pg_krb_caseins_users;
 extern PGDLLIMPORT bool pg_gss_accept_delegation;
-- 
2.34.1



  [application/octet-stream] v44-0002-require_auth-prepare-for-multiple-SASL-mechanism.patch (10.9K, ../../CAOYmi+kJqzo6XsR9TEhvVfeVNQ-TyFM5LATypm9yoQVYk=4Wrw@mail.gmail.com/4-v44-0002-require_auth-prepare-for-multiple-SASL-mechanism.patch)
  download | inline diff:
From ec960cf363d908372b364e91b3c36c98bf52cdef Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Mon, 16 Dec 2024 13:57:14 -0800
Subject: [PATCH v44 2/6] require_auth: prepare for multiple SASL mechanisms

Prior to this patch, the require_auth implementation assumed that the
AuthenticationSASL protocol message was synonymous with SCRAM-SHA-256.
In preparation for the OAUTHBEARER SASL mechanism, split the
implementation into two tiers: the first checks the acceptable
AUTH_REQ_* codes, and the second checks acceptable mechanisms if
AUTH_REQ_SASL et al are permitted.

conn->allowed_sasl_mechs is the list of pointers to acceptable
mechanisms. (Since we'll support only a small number of mechanisms, this
is an array of static length to minimize bookkeeping.) pg_SASL_init()
will bail if the selected mechanism isn't contained in this array.

Since there's only one mechansism supported right now, one branch of the
second tier cannot be exercised yet (it's marked with Assert(false)).
This assertion will need to be removed when the next mechanism is added.
---
 src/interfaces/libpq/fe-auth.c            |  29 ++++
 src/interfaces/libpq/fe-connect.c         | 184 ++++++++++++++++++++--
 src/interfaces/libpq/libpq-int.h          |   2 +
 src/test/authentication/t/001_password.pl |  10 ++
 4 files changed, 208 insertions(+), 17 deletions(-)

diff --git a/src/interfaces/libpq/fe-auth.c b/src/interfaces/libpq/fe-auth.c
index 7e478489b71..70753d8ec29 100644
--- a/src/interfaces/libpq/fe-auth.c
+++ b/src/interfaces/libpq/fe-auth.c
@@ -543,6 +543,35 @@ pg_SASL_init(PGconn *conn, int payloadlen)
 		goto error;
 	}
 
+	/* Make sure require_auth is satisfied. */
+	if (conn->require_auth)
+	{
+		bool		allowed = false;
+
+		for (int i = 0; i < lengthof(conn->allowed_sasl_mechs); i++)
+		{
+			if (conn->sasl == conn->allowed_sasl_mechs[i])
+			{
+				allowed = true;
+				break;
+			}
+		}
+
+		if (!allowed)
+		{
+			/*
+			 * TODO: this is dead code until a second SASL mechanism is added;
+			 * the connection can't have proceeded past check_expected_areq()
+			 * if no SASL methods are allowed.
+			 */
+			Assert(false);
+
+			libpq_append_conn_error(conn, "authentication method requirement \"%s\" failed: server requested %s authentication",
+									conn->require_auth, selected_mechanism);
+			goto error;
+		}
+	}
+
 	if (conn->channel_binding[0] == 'r' &&	/* require */
 		strcmp(selected_mechanism, SCRAM_SHA_256_PLUS_NAME) != 0)
 	{
diff --git a/src/interfaces/libpq/fe-connect.c b/src/interfaces/libpq/fe-connect.c
index 7878e2e33af..e1cea790f9e 100644
--- a/src/interfaces/libpq/fe-connect.c
+++ b/src/interfaces/libpq/fe-connect.c
@@ -396,6 +396,12 @@ static const PQEnvironmentOption EnvironmentOptions[] =
 	}
 };
 
+static const pg_fe_sasl_mech *supported_sasl_mechs[] =
+{
+	&pg_scram_mech,
+};
+#define SASL_MECHANISM_COUNT lengthof(supported_sasl_mechs)
+
 /* The connection URI must start with either of the following designators: */
 static const char uri_designator[] = "postgresql://";
 static const char short_uri_designator[] = "postgres://";
@@ -1117,6 +1123,57 @@ libpq_prng_init(PGconn *conn)
 	pg_prng_seed(&conn->prng_state, rseed);
 }
 
+/*
+ * Fills the connection's allowed_sasl_mechs list with all supported SASL
+ * mechanisms.
+ */
+static inline void
+fill_allowed_sasl_mechs(PGconn *conn)
+{
+	/*---
+	 * We only support one mechanism at the moment, so rather than deal with a
+	 * linked list, conn->allowed_sasl_mechs is an array of static length. We
+	 * rely on the compile-time assertion here to keep us honest.
+	 *
+	 * To add a new mechanism to require_auth,
+	 * - add it to supported_sasl_mechs,
+	 * - update the length of conn->allowed_sasl_mechs,
+	 * - handle the new mechanism name in the require_auth portion of
+	 *   pqConnectOptions2(), below.
+	 */
+	StaticAssertDecl(lengthof(conn->allowed_sasl_mechs) == SASL_MECHANISM_COUNT,
+					 "conn->allowed_sasl_mechs[] is not sufficiently large for holding all supported SASL mechanisms");
+
+	for (int i = 0; i < SASL_MECHANISM_COUNT; i++)
+		conn->allowed_sasl_mechs[i] = supported_sasl_mechs[i];
+}
+
+/*
+ * Clears the connection's allowed_sasl_mechs list.
+ */
+static inline void
+clear_allowed_sasl_mechs(PGconn *conn)
+{
+	for (int i = 0; i < lengthof(conn->allowed_sasl_mechs); i++)
+		conn->allowed_sasl_mechs[i] = NULL;
+}
+
+/*
+ * Helper routine that searches the static allowed_sasl_mechs list for a
+ * specific mechanism.
+ */
+static inline int
+index_of_allowed_sasl_mech(PGconn *conn, const pg_fe_sasl_mech *mech)
+{
+	for (int i = 0; i < lengthof(conn->allowed_sasl_mechs); i++)
+	{
+		if (conn->allowed_sasl_mechs[i] == mech)
+			return i;
+	}
+
+	return -1;
+}
+
 /*
  *		pqConnectOptions2
  *
@@ -1358,17 +1415,19 @@ pqConnectOptions2(PGconn *conn)
 		bool		negated = false;
 
 		/*
-		 * By default, start from an empty set of allowed options and add to
-		 * it.
+		 * By default, start from an empty set of allowed methods and
+		 * mechanisms, and add to it.
 		 */
 		conn->auth_required = true;
 		conn->allowed_auth_methods = 0;
+		clear_allowed_sasl_mechs(conn);
 
 		for (first = true, more = true; more; first = false)
 		{
 			char	   *method,
 					   *part;
-			uint32		bits;
+			uint32		bits = 0;
+			const pg_fe_sasl_mech *mech = NULL;
 
 			part = parse_comma_separated_list(&s, &more);
 			if (part == NULL)
@@ -1384,11 +1443,12 @@ pqConnectOptions2(PGconn *conn)
 				if (first)
 				{
 					/*
-					 * Switch to a permissive set of allowed options, and
-					 * subtract from it.
+					 * Switch to a permissive set of allowed methods and
+					 * mechanisms, and subtract from it.
 					 */
 					conn->auth_required = false;
 					conn->allowed_auth_methods = -1;
+					fill_allowed_sasl_mechs(conn);
 				}
 				else if (!negated)
 				{
@@ -1413,6 +1473,10 @@ pqConnectOptions2(PGconn *conn)
 				return false;
 			}
 
+			/*
+			 * First group: methods that can be handled solely with the
+			 * authentication request codes.
+			 */
 			if (strcmp(method, "password") == 0)
 			{
 				bits = (1 << AUTH_REQ_PASSWORD);
@@ -1431,13 +1495,21 @@ pqConnectOptions2(PGconn *conn)
 				bits = (1 << AUTH_REQ_SSPI);
 				bits |= (1 << AUTH_REQ_GSS_CONT);
 			}
+
+			/*
+			 * Next group: SASL mechanisms. All of these use the same request
+			 * codes, so the list of allowed mechanisms is tracked separately.
+			 *
+			 * supported_sasl_mechs must contain all mechanisms handled here.
+			 */
 			else if (strcmp(method, "scram-sha-256") == 0)
 			{
-				/* This currently assumes that SCRAM is the only SASL method. */
-				bits = (1 << AUTH_REQ_SASL);
-				bits |= (1 << AUTH_REQ_SASL_CONT);
-				bits |= (1 << AUTH_REQ_SASL_FIN);
+				mech = &pg_scram_mech;
 			}
+
+			/*
+			 * Final group: meta-options.
+			 */
 			else if (strcmp(method, "none") == 0)
 			{
 				/*
@@ -1473,20 +1545,68 @@ pqConnectOptions2(PGconn *conn)
 				return false;
 			}
 
-			/* Update the bitmask. */
-			if (negated)
+			if (mech)
 			{
-				if ((conn->allowed_auth_methods & bits) == 0)
-					goto duplicate;
+				/*
+				 * Update the mechanism set only. The method bitmask will be
+				 * updated for SASL further down.
+				 */
+				Assert(!bits);
+
+				if (negated)
+				{
+					/* Remove the existing mechanism from the list. */
+					i = index_of_allowed_sasl_mech(conn, mech);
+					if (i < 0)
+						goto duplicate;
+
+					conn->allowed_sasl_mechs[i] = NULL;
+				}
+				else
+				{
+					/*
+					 * Find a space to put the new mechanism (after making
+					 * sure it's not already there).
+					 */
+					i = index_of_allowed_sasl_mech(conn, mech);
+					if (i >= 0)
+						goto duplicate;
 
-				conn->allowed_auth_methods &= ~bits;
+					i = index_of_allowed_sasl_mech(conn, NULL);
+					if (i < 0)
+					{
+						/* Should not happen; the pointer list is corrupted. */
+						Assert(false);
+
+						conn->status = CONNECTION_BAD;
+						libpq_append_conn_error(conn,
+												"internal error: no space in allowed_sasl_mechs");
+						free(part);
+						return false;
+					}
+
+					conn->allowed_sasl_mechs[i] = mech;
+				}
 			}
 			else
 			{
-				if ((conn->allowed_auth_methods & bits) == bits)
-					goto duplicate;
+				/* Update the method bitmask. */
+				Assert(bits);
+
+				if (negated)
+				{
+					if ((conn->allowed_auth_methods & bits) == 0)
+						goto duplicate;
+
+					conn->allowed_auth_methods &= ~bits;
+				}
+				else
+				{
+					if ((conn->allowed_auth_methods & bits) == bits)
+						goto duplicate;
 
-				conn->allowed_auth_methods |= bits;
+					conn->allowed_auth_methods |= bits;
+				}
 			}
 
 			free(part);
@@ -1505,6 +1625,36 @@ pqConnectOptions2(PGconn *conn)
 			free(part);
 			return false;
 		}
+
+		/*
+		 * Finally, allow SASL authentication requests if (and only if) we've
+		 * allowed any mechanisms.
+		 */
+		{
+			bool		allowed = false;
+			const uint32 sasl_bits =
+				(1 << AUTH_REQ_SASL)
+				| (1 << AUTH_REQ_SASL_CONT)
+				| (1 << AUTH_REQ_SASL_FIN);
+
+			for (i = 0; i < lengthof(conn->allowed_sasl_mechs); i++)
+			{
+				if (conn->allowed_sasl_mechs[i])
+				{
+					allowed = true;
+					break;
+				}
+			}
+
+			/*
+			 * For the standard case, add the SASL bits to the (default-empty)
+			 * set if needed. For the negated case, remove them.
+			 */
+			if (!negated && allowed)
+				conn->allowed_auth_methods |= sasl_bits;
+			else if (negated && !allowed)
+				conn->allowed_auth_methods &= ~sasl_bits;
+		}
 	}
 
 	/*
diff --git a/src/interfaces/libpq/libpq-int.h b/src/interfaces/libpq/libpq-int.h
index 4be5fd7ae4f..e0d5b5fe0be 100644
--- a/src/interfaces/libpq/libpq-int.h
+++ b/src/interfaces/libpq/libpq-int.h
@@ -505,6 +505,8 @@ struct pg_conn
 								 * the server? */
 	uint32		allowed_auth_methods;	/* bitmask of acceptable AuthRequest
 										 * codes */
+	const pg_fe_sasl_mech *allowed_sasl_mechs[1];	/* and acceptable SASL
+													 * mechanisms */
 	bool		client_finished_auth;	/* have we finished our half of the
 										 * authentication exchange? */
 	char		current_auth_response;	/* used by pqTraceOutputMessage to
diff --git a/src/test/authentication/t/001_password.pl b/src/test/authentication/t/001_password.pl
index 773238b76fd..1357f806b6f 100644
--- a/src/test/authentication/t/001_password.pl
+++ b/src/test/authentication/t/001_password.pl
@@ -277,6 +277,16 @@ $node->connect_fails(
 	"require_auth methods cannot be duplicated, !none case",
 	expected_stderr =>
 	  qr/require_auth method "!none" is specified more than once/);
+$node->connect_fails(
+	"user=scram_role require_auth=scram-sha-256,scram-sha-256",
+	"require_auth methods cannot be duplicated, scram-sha-256 case",
+	expected_stderr =>
+	  qr/require_auth method "scram-sha-256" is specified more than once/);
+$node->connect_fails(
+	"user=scram_role require_auth=!scram-sha-256,!scram-sha-256",
+	"require_auth methods cannot be duplicated, !scram-sha-256 case",
+	expected_stderr =>
+	  qr/require_auth method "!scram-sha-256" is specified more than once/);
 
 # Unknown value defined in require_auth.
 $node->connect_fails(
-- 
2.34.1



  [application/octet-stream] v44-0003-libpq-handle-asynchronous-actions-during-SASL.patch (18.1K, ../../CAOYmi+kJqzo6XsR9TEhvVfeVNQ-TyFM5LATypm9yoQVYk=4Wrw@mail.gmail.com/5-v44-0003-libpq-handle-asynchronous-actions-during-SASL.patch)
  download | inline diff:
From 9725788086c74dbec0a4feb895ff2f401dff83ea Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Wed, 8 Jan 2025 09:30:05 -0800
Subject: [PATCH v44 3/6] libpq: handle asynchronous actions during SASL

This adds the ability for a SASL mechanism to signal to PQconnectPoll()
that some arbitrary work must be done, external to the Postgres
connection, before authentication can continue. The intent is for the
upcoming OAUTHBEARER mechanism to make use of this functionality.

To ensure that threads are not blocked waiting for the SASL mechanism to
make long-running calls, the mechanism communicates with the top-level
client via the "altsock": a file or socket descriptor, opaque to this
layer of libpq, which is signaled when work is ready to be done again.
This socket temporarily takes the place of the standard connection
descriptor, so PQsocket() clients should continue to operate correctly
using their existing polling implementations.

A mechanism should set an authentication callback (conn->async_auth())
and a cleanup callback (conn->cleanup_async_auth()), return SASL_ASYNC
during the exchange, and assign conn->altsock during the first call to
async_auth(). When the cleanup callback is called, either because
authentication has succeeded or because the connection is being
dropped, the altsock must be released and disconnected from the PGconn.
---
 src/interfaces/libpq/fe-auth-sasl.h  |  11 ++-
 src/interfaces/libpq/fe-auth-scram.c |   6 +-
 src/interfaces/libpq/fe-auth.c       | 120 ++++++++++++++++++++-------
 src/interfaces/libpq/fe-auth.h       |   3 +-
 src/interfaces/libpq/fe-connect.c    |  93 ++++++++++++++++++++-
 src/interfaces/libpq/fe-misc.c       |  35 +++++---
 src/interfaces/libpq/libpq-fe.h      |   2 +
 src/interfaces/libpq/libpq-int.h     |   6 ++
 8 files changed, 227 insertions(+), 49 deletions(-)

diff --git a/src/interfaces/libpq/fe-auth-sasl.h b/src/interfaces/libpq/fe-auth-sasl.h
index f0c62139092..f06f547c07d 100644
--- a/src/interfaces/libpq/fe-auth-sasl.h
+++ b/src/interfaces/libpq/fe-auth-sasl.h
@@ -30,6 +30,7 @@ typedef enum
 	SASL_COMPLETE = 0,
 	SASL_FAILED,
 	SASL_CONTINUE,
+	SASL_ASYNC,
 } SASLStatus;
 
 /*
@@ -77,6 +78,8 @@ typedef struct pg_fe_sasl_mech
 	 *
 	 *	state:	   The opaque mechanism state returned by init()
 	 *
+	 *	final:	   true if the server has sent a final exchange outcome
+	 *
 	 *	input:	   The challenge data sent by the server, or NULL when
 	 *			   generating a client-first initial response (that is, when
 	 *			   the server expects the client to send a message to start
@@ -101,12 +104,18 @@ typedef struct pg_fe_sasl_mech
 	 *
 	 *	SASL_CONTINUE:	The output buffer is filled with a client response.
 	 *					Additional server challenge is expected
+	 *	SASL_ASYNC:		Some asynchronous processing external to the
+	 *					connection needs to be done before a response can be
+	 *					generated. The mechanism is responsible for setting up
+	 *					conn->async_auth/cleanup_async_auth appropriately
+	 *					before returning.
 	 *	SASL_COMPLETE:	The SASL exchange has completed successfully.
 	 *	SASL_FAILED:	The exchange has failed and the connection should be
 	 *					dropped.
 	 *--------
 	 */
-	SASLStatus	(*exchange) (void *state, char *input, int inputlen,
+	SASLStatus	(*exchange) (void *state, bool final,
+							 char *input, int inputlen,
 							 char **output, int *outputlen);
 
 	/*--------
diff --git a/src/interfaces/libpq/fe-auth-scram.c b/src/interfaces/libpq/fe-auth-scram.c
index 557e9c568b6..fe18615197f 100644
--- a/src/interfaces/libpq/fe-auth-scram.c
+++ b/src/interfaces/libpq/fe-auth-scram.c
@@ -24,7 +24,8 @@
 /* The exported SCRAM callback mechanism. */
 static void *scram_init(PGconn *conn, const char *password,
 						const char *sasl_mechanism);
-static SASLStatus scram_exchange(void *opaq, char *input, int inputlen,
+static SASLStatus scram_exchange(void *opaq, bool final,
+								 char *input, int inputlen,
 								 char **output, int *outputlen);
 static bool scram_channel_bound(void *opaq);
 static void scram_free(void *opaq);
@@ -205,7 +206,8 @@ scram_free(void *opaq)
  * Exchange a SCRAM message with backend.
  */
 static SASLStatus
-scram_exchange(void *opaq, char *input, int inputlen,
+scram_exchange(void *opaq, bool final,
+			   char *input, int inputlen,
 			   char **output, int *outputlen)
 {
 	fe_scram_state *state = (fe_scram_state *) opaq;
diff --git a/src/interfaces/libpq/fe-auth.c b/src/interfaces/libpq/fe-auth.c
index 70753d8ec29..761ee8f88f7 100644
--- a/src/interfaces/libpq/fe-auth.c
+++ b/src/interfaces/libpq/fe-auth.c
@@ -430,7 +430,7 @@ pg_SSPI_startup(PGconn *conn, int use_negotiate, int payloadlen)
  * Initialize SASL authentication exchange.
  */
 static int
-pg_SASL_init(PGconn *conn, int payloadlen)
+pg_SASL_init(PGconn *conn, int payloadlen, bool *async)
 {
 	char	   *initialresponse = NULL;
 	int			initialresponselen;
@@ -448,7 +448,7 @@ pg_SASL_init(PGconn *conn, int payloadlen)
 		goto error;
 	}
 
-	if (conn->sasl_state)
+	if (conn->sasl_state && !conn->async_auth)
 	{
 		libpq_append_conn_error(conn, "duplicate SASL authentication request");
 		goto error;
@@ -607,26 +607,54 @@ pg_SASL_init(PGconn *conn, int payloadlen)
 
 	Assert(conn->sasl);
 
-	/*
-	 * Initialize the SASL state information with all the information gathered
-	 * during the initial exchange.
-	 *
-	 * Note: Only tls-unique is supported for the moment.
-	 */
-	conn->sasl_state = conn->sasl->init(conn,
-										password,
-										selected_mechanism);
 	if (!conn->sasl_state)
-		goto oom_error;
+	{
+		/*
+		 * Initialize the SASL state information with all the information
+		 * gathered during the initial exchange.
+		 *
+		 * Note: Only tls-unique is supported for the moment.
+		 */
+		conn->sasl_state = conn->sasl->init(conn,
+											password,
+											selected_mechanism);
+		if (!conn->sasl_state)
+			goto oom_error;
+	}
+	else
+	{
+		/*
+		 * This is only possible if we're returning from an async loop.
+		 * Disconnect it now.
+		 */
+		Assert(conn->async_auth);
+		conn->async_auth = NULL;
+	}
 
 	/* Get the mechanism-specific Initial Client Response, if any */
-	status = conn->sasl->exchange(conn->sasl_state,
+	status = conn->sasl->exchange(conn->sasl_state, false,
 								  NULL, -1,
 								  &initialresponse, &initialresponselen);
 
 	if (status == SASL_FAILED)
 		goto error;
 
+	if (status == SASL_ASYNC)
+	{
+		/*
+		 * The mechanism should have set up the necessary callbacks; all we
+		 * need to do is signal the caller.
+		 *
+		 * In non-assertion builds, this postcondition is enforced at time of
+		 * use in PQconnectPoll().
+		 */
+		Assert(conn->async_auth);
+		Assert(conn->cleanup_async_auth);
+
+		*async = true;
+		return STATUS_OK;
+	}
+
 	/*
 	 * Build a SASLInitialResponse message, and send it.
 	 */
@@ -671,7 +699,7 @@ oom_error:
  * the protocol.
  */
 static int
-pg_SASL_continue(PGconn *conn, int payloadlen, bool final)
+pg_SASL_continue(PGconn *conn, int payloadlen, bool final, bool *async)
 {
 	char	   *output;
 	int			outputlen;
@@ -701,11 +729,25 @@ pg_SASL_continue(PGconn *conn, int payloadlen, bool final)
 	/* For safety and convenience, ensure the buffer is NULL-terminated. */
 	challenge[payloadlen] = '\0';
 
-	status = conn->sasl->exchange(conn->sasl_state,
+	status = conn->sasl->exchange(conn->sasl_state, final,
 								  challenge, payloadlen,
 								  &output, &outputlen);
 	free(challenge);			/* don't need the input anymore */
 
+	if (status == SASL_ASYNC)
+	{
+		/*
+		 * The mechanism should have set up the necessary callbacks; all we
+		 * need to do is signal the caller.
+		 */
+		*async = true;
+
+		/*
+		 * The mechanism may optionally generate some output to send before
+		 * switching over to async auth, so continue onwards.
+		 */
+	}
+
 	if (final && status == SASL_CONTINUE)
 	{
 		if (outputlen != 0)
@@ -1013,12 +1055,18 @@ check_expected_areq(AuthRequest areq, PGconn *conn)
  * it. We are responsible for reading any remaining extra data, specific
  * to the authentication method. 'payloadlen' is the remaining length in
  * the message.
+ *
+ * If *async is set to true on return, the client doesn't yet have enough
+ * information to respond, and the caller must temporarily switch to
+ * conn->async_auth() to continue driving the exchange.
  */
 int
-pg_fe_sendauth(AuthRequest areq, int payloadlen, PGconn *conn)
+pg_fe_sendauth(AuthRequest areq, int payloadlen, PGconn *conn, bool *async)
 {
 	int			oldmsglen;
 
+	*async = false;
+
 	if (!check_expected_areq(areq, conn))
 		return STATUS_ERROR;
 
@@ -1176,7 +1224,7 @@ pg_fe_sendauth(AuthRequest areq, int payloadlen, PGconn *conn)
 			 * The request contains the name (as assigned by IANA) of the
 			 * authentication mechanism.
 			 */
-			if (pg_SASL_init(conn, payloadlen) != STATUS_OK)
+			if (pg_SASL_init(conn, payloadlen, async) != STATUS_OK)
 			{
 				/* pg_SASL_init already set the error message */
 				return STATUS_ERROR;
@@ -1185,23 +1233,33 @@ pg_fe_sendauth(AuthRequest areq, int payloadlen, PGconn *conn)
 
 		case AUTH_REQ_SASL_CONT:
 		case AUTH_REQ_SASL_FIN:
-			if (conn->sasl_state == NULL)
 			{
-				appendPQExpBufferStr(&conn->errorMessage,
-									 "fe_sendauth: invalid authentication request from server: AUTH_REQ_SASL_CONT without AUTH_REQ_SASL\n");
-				return STATUS_ERROR;
-			}
-			oldmsglen = conn->errorMessage.len;
-			if (pg_SASL_continue(conn, payloadlen,
-								 (areq == AUTH_REQ_SASL_FIN)) != STATUS_OK)
-			{
-				/* Use this message if pg_SASL_continue didn't supply one */
-				if (conn->errorMessage.len == oldmsglen)
+				bool		final = false;
+
+				if (conn->sasl_state == NULL)
+				{
 					appendPQExpBufferStr(&conn->errorMessage,
-										 "fe_sendauth: error in SASL authentication\n");
-				return STATUS_ERROR;
+										 "fe_sendauth: invalid authentication request from server: AUTH_REQ_SASL_CONT without AUTH_REQ_SASL\n");
+					return STATUS_ERROR;
+				}
+				oldmsglen = conn->errorMessage.len;
+
+				if (areq == AUTH_REQ_SASL_FIN)
+					final = true;
+
+				if (pg_SASL_continue(conn, payloadlen, final, async) != STATUS_OK)
+				{
+					/*
+					 * Append a generic error message unless pg_SASL_continue
+					 * did set a more specific one already.
+					 */
+					if (conn->errorMessage.len == oldmsglen)
+						appendPQExpBufferStr(&conn->errorMessage,
+											 "fe_sendauth: error in SASL authentication\n");
+					return STATUS_ERROR;
+				}
+				break;
 			}
-			break;
 
 		default:
 			libpq_append_conn_error(conn, "authentication method %u not supported", areq);
diff --git a/src/interfaces/libpq/fe-auth.h b/src/interfaces/libpq/fe-auth.h
index df0a68b0b21..1d4991f8996 100644
--- a/src/interfaces/libpq/fe-auth.h
+++ b/src/interfaces/libpq/fe-auth.h
@@ -19,7 +19,8 @@
 
 
 /* Prototypes for functions in fe-auth.c */
-extern int	pg_fe_sendauth(AuthRequest areq, int payloadlen, PGconn *conn);
+extern int	pg_fe_sendauth(AuthRequest areq, int payloadlen, PGconn *conn,
+						   bool *async);
 extern char *pg_fe_getusername(uid_t user_id, PQExpBuffer errorMessage);
 extern char *pg_fe_getauthname(PQExpBuffer errorMessage);
 
diff --git a/src/interfaces/libpq/fe-connect.c b/src/interfaces/libpq/fe-connect.c
index e1cea790f9e..85d1ca2864f 100644
--- a/src/interfaces/libpq/fe-connect.c
+++ b/src/interfaces/libpq/fe-connect.c
@@ -507,6 +507,19 @@ pqDropConnection(PGconn *conn, bool flushInput)
 	conn->cmd_queue_recycle = NULL;
 
 	/* Free authentication/encryption state */
+	if (conn->cleanup_async_auth)
+	{
+		/*
+		 * Any in-progress async authentication should be torn down first so
+		 * that cleanup_async_auth() can depend on the other authentication
+		 * state if necessary.
+		 */
+		conn->cleanup_async_auth(conn);
+		conn->cleanup_async_auth = NULL;
+	}
+	conn->async_auth = NULL;
+	/* cleanup_async_auth() should have done this, but make sure */
+	conn->altsock = PGINVALID_SOCKET;
 #ifdef ENABLE_GSS
 	{
 		OM_uint32	min_s;
@@ -2853,6 +2866,7 @@ PQconnectPoll(PGconn *conn)
 		case CONNECTION_NEEDED:
 		case CONNECTION_GSS_STARTUP:
 		case CONNECTION_CHECK_TARGET:
+		case CONNECTION_AUTHENTICATING:
 			break;
 
 		default:
@@ -3888,6 +3902,7 @@ keep_going:						/* We will come back to here until there is
 				int			avail;
 				AuthRequest areq;
 				int			res;
+				bool		async;
 
 				/*
 				 * Scan the message from current point (note that if we find
@@ -4076,7 +4091,17 @@ keep_going:						/* We will come back to here until there is
 				 * Note that conn->pghost must be non-NULL if we are going to
 				 * avoid the Kerberos code doing a hostname look-up.
 				 */
-				res = pg_fe_sendauth(areq, msgLength, conn);
+				res = pg_fe_sendauth(areq, msgLength, conn, &async);
+
+				if (async && (res == STATUS_OK))
+				{
+					/*
+					 * We'll come back later once we're ready to respond.
+					 * Don't consume the request yet.
+					 */
+					conn->status = CONNECTION_AUTHENTICATING;
+					goto keep_going;
+				}
 
 				/*
 				 * OK, we have processed the message; mark data consumed.  We
@@ -4113,6 +4138,69 @@ keep_going:						/* We will come back to here until there is
 				goto keep_going;
 			}
 
+		case CONNECTION_AUTHENTICATING:
+			{
+				PostgresPollingStatusType status;
+
+				if (!conn->async_auth || !conn->cleanup_async_auth)
+				{
+					/* programmer error; should not happen */
+					libpq_append_conn_error(conn,
+											"internal error: async authentication has no handler");
+					goto error_return;
+				}
+
+				/* Drive some external authentication work. */
+				status = conn->async_auth(conn);
+
+				if (status == PGRES_POLLING_FAILED)
+					goto error_return;
+
+				if (status == PGRES_POLLING_OK)
+				{
+					/* Done. Tear down the async implementation. */
+					conn->cleanup_async_auth(conn);
+					conn->cleanup_async_auth = NULL;
+
+					/*
+					 * Cleanup must unset altsock, both as an indication that
+					 * it's been released, and to stop pqSocketCheck from
+					 * looking at the wrong socket after async auth is done.
+					 */
+					if (conn->altsock != PGINVALID_SOCKET)
+					{
+						Assert(false);
+						libpq_append_conn_error(conn,
+												"internal error: async cleanup did not release polling socket");
+						goto error_return;
+					}
+
+					/*
+					 * Reenter the authentication exchange with the server. We
+					 * didn't consume the message that started external
+					 * authentication, so it'll be reprocessed as if we just
+					 * received it.
+					 */
+					conn->status = CONNECTION_AWAITING_RESPONSE;
+
+					goto keep_going;
+				}
+
+				/*
+				 * Caller needs to poll some more. conn->async_auth() should
+				 * have assigned an altsock to poll on.
+				 */
+				if (conn->altsock == PGINVALID_SOCKET)
+				{
+					Assert(false);
+					libpq_append_conn_error(conn,
+											"internal error: async authentication did not set a socket for polling");
+					goto error_return;
+				}
+
+				return status;
+			}
+
 		case CONNECTION_AUTH_OK:
 			{
 				/*
@@ -4794,6 +4882,7 @@ pqMakeEmptyPGconn(void)
 	conn->verbosity = PQERRORS_DEFAULT;
 	conn->show_context = PQSHOW_CONTEXT_ERRORS;
 	conn->sock = PGINVALID_SOCKET;
+	conn->altsock = PGINVALID_SOCKET;
 	conn->Pfdebug = NULL;
 
 	/*
@@ -7445,6 +7534,8 @@ PQsocket(const PGconn *conn)
 {
 	if (!conn)
 		return -1;
+	if (conn->altsock != PGINVALID_SOCKET)
+		return conn->altsock;
 	return (conn->sock != PGINVALID_SOCKET) ? conn->sock : -1;
 }
 
diff --git a/src/interfaces/libpq/fe-misc.c b/src/interfaces/libpq/fe-misc.c
index 2c60eb5b569..d78445c70af 100644
--- a/src/interfaces/libpq/fe-misc.c
+++ b/src/interfaces/libpq/fe-misc.c
@@ -1049,34 +1049,43 @@ pqWriteReady(PGconn *conn)
  * or both.  Returns >0 if one or more conditions are met, 0 if it timed
  * out, -1 if an error occurred.
  *
- * If SSL is in use, the SSL buffer is checked prior to checking the socket
- * for read data directly.
+ * If an altsock is set for asynchronous authentication, that will be used in
+ * preference to the "server" socket. Otherwise, if SSL is in use, the SSL
+ * buffer is checked prior to checking the socket for read data directly.
  */
 static int
 pqSocketCheck(PGconn *conn, int forRead, int forWrite, pg_usec_time_t end_time)
 {
 	int			result;
+	pgsocket	sock;
 
 	if (!conn)
 		return -1;
-	if (conn->sock == PGINVALID_SOCKET)
+
+	if (conn->altsock != PGINVALID_SOCKET)
+		sock = conn->altsock;
+	else
 	{
-		libpq_append_conn_error(conn, "invalid socket");
-		return -1;
-	}
+		sock = conn->sock;
+		if (sock == PGINVALID_SOCKET)
+		{
+			libpq_append_conn_error(conn, "invalid socket");
+			return -1;
+		}
 
 #ifdef USE_SSL
-	/* Check for SSL library buffering read bytes */
-	if (forRead && conn->ssl_in_use && pgtls_read_pending(conn))
-	{
-		/* short-circuit the select */
-		return 1;
-	}
+		/* Check for SSL library buffering read bytes */
+		if (forRead && conn->ssl_in_use && pgtls_read_pending(conn))
+		{
+			/* short-circuit the select */
+			return 1;
+		}
 #endif
+	}
 
 	/* We will retry as long as we get EINTR */
 	do
-		result = PQsocketPoll(conn->sock, forRead, forWrite, end_time);
+		result = PQsocketPoll(sock, forRead, forWrite, end_time);
 	while (result < 0 && SOCK_ERRNO == EINTR);
 
 	if (result < 0)
diff --git a/src/interfaces/libpq/libpq-fe.h b/src/interfaces/libpq/libpq-fe.h
index cce9ce60c55..a3491faf0c3 100644
--- a/src/interfaces/libpq/libpq-fe.h
+++ b/src/interfaces/libpq/libpq-fe.h
@@ -103,6 +103,8 @@ typedef enum
 	CONNECTION_CHECK_STANDBY,	/* Checking if server is in standby mode. */
 	CONNECTION_ALLOCATED,		/* Waiting for connection attempt to be
 								 * started.  */
+	CONNECTION_AUTHENTICATING,	/* Authentication is in progress with some
+								 * external system. */
 } ConnStatusType;
 
 typedef enum
diff --git a/src/interfaces/libpq/libpq-int.h b/src/interfaces/libpq/libpq-int.h
index e0d5b5fe0be..2546f9f8a50 100644
--- a/src/interfaces/libpq/libpq-int.h
+++ b/src/interfaces/libpq/libpq-int.h
@@ -513,6 +513,12 @@ struct pg_conn
 										 * know which auth response we're
 										 * sending */
 
+	/* Callbacks for external async authentication */
+	PostgresPollingStatusType (*async_auth) (PGconn *conn);
+	void		(*cleanup_async_auth) (PGconn *conn);
+	pgsocket	altsock;		/* alternative socket for client to poll */
+
+
 	/* Transient state needed while establishing connection */
 	PGTargetServerType target_server_type;	/* desired session properties */
 	PGLoadBalanceType load_balance_type;	/* desired load balancing
-- 
2.34.1



  [application/octet-stream] v44-0004-Add-OAUTHBEARER-SASL-mechanism.patch (299.3K, ../../CAOYmi+kJqzo6XsR9TEhvVfeVNQ-TyFM5LATypm9yoQVYk=4Wrw@mail.gmail.com/6-v44-0004-Add-OAUTHBEARER-SASL-mechanism.patch)
  download | inline diff:
From a260d9436f08834cf26e6666a680203815ee6175 Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Wed, 23 Oct 2024 09:37:33 -0700
Subject: [PATCH v44 4/6] Add OAUTHBEARER SASL mechanism

DO NOT USE THIS PROOF OF CONCEPT IN PRODUCTION.

Implement OAUTHBEARER (RFC 7628) and OAuth 2.0 Device Authorization
Grants (RFC 8628). This adds a new auth method, oauth, to pg_hba. When
speaking to a OAuth-enabled server, it looks a bit like this:

    $ psql 'host=example.org oauth_issuer=... oauth_client_id=...'
    Visit https://oauth.example.org/login and enter the code: FPQ2-M4BG

The OAuth issuer must support device authorization. No other OAuth flows
are currently implemented (but clients may provide their own flows).

The client implementation requires libcurl and its development headers.
Pass --with-libcurl/-Dlibcurl=enabled during configuration. The server
implementation does not require additional build-time dependencies, but
an external validator module must be supplied.

Thomas Munro wrote the kqueue() implementation for oauth-curl; thanks!

Several TODOs:
- perform several sanity checks on the OAuth issuer's responses
- improve error debuggability during the OAuth handshake
- fix libcurl initialization thread-safety
- harden the libcurl flow implementation
- fill in documentation stubs
- support protocol "variants" implemented by major providers
- implement more helpful handling of HBA misconfigurations
- use logdetail during auth failures
- ...and more.

Co-authored-by: Daniel Gustafsson <[email protected]>
---
 .cirrus.tasks.yml                             |   15 +-
 config/programs.m4                            |   42 +
 configure                                     |  279 ++
 configure.ac                                  |   41 +
 doc/src/sgml/client-auth.sgml                 |  252 ++
 doc/src/sgml/config.sgml                      |   26 +
 doc/src/sgml/filelist.sgml                    |    1 +
 doc/src/sgml/installation.sgml                |   27 +
 doc/src/sgml/libpq.sgml                       |  393 +++
 doc/src/sgml/oauth-validators.sgml            |  402 +++
 doc/src/sgml/postgres.sgml                    |    1 +
 doc/src/sgml/protocol.sgml                    |  133 +-
 doc/src/sgml/regress.sgml                     |   10 +
 meson.build                                   |   66 +
 meson_options.txt                             |    3 +
 src/Makefile.global.in                        |    1 +
 src/backend/libpq/Makefile                    |    1 +
 src/backend/libpq/auth-oauth.c                |  860 ++++++
 src/backend/libpq/auth.c                      |   10 +-
 src/backend/libpq/hba.c                       |   64 +-
 src/backend/libpq/meson.build                 |    1 +
 src/backend/libpq/pg_hba.conf.sample          |    4 +-
 src/backend/utils/adt/hbafuncs.c              |   19 +
 src/backend/utils/misc/guc_tables.c           |   12 +
 src/backend/utils/misc/postgresql.conf.sample |    3 +
 src/include/common/oauth-common.h             |   19 +
 src/include/libpq/auth.h                      |    1 +
 src/include/libpq/hba.h                       |    7 +-
 src/include/libpq/oauth.h                     |   54 +
 src/include/pg_config.h.in                    |    9 +
 src/interfaces/libpq/Makefile                 |   11 +-
 src/interfaces/libpq/exports.txt              |    3 +
 src/interfaces/libpq/fe-auth-oauth-curl.c     | 2635 +++++++++++++++++
 src/interfaces/libpq/fe-auth-oauth.c          | 1141 +++++++
 src/interfaces/libpq/fe-auth-oauth.h          |   45 +
 src/interfaces/libpq/fe-auth.c                |   36 +-
 src/interfaces/libpq/fe-auth.h                |    3 +
 src/interfaces/libpq/fe-connect.c             |   48 +-
 src/interfaces/libpq/libpq-fe.h               |   82 +
 src/interfaces/libpq/libpq-int.h              |   13 +-
 src/interfaces/libpq/meson.build              |    5 +
 src/makefiles/meson.build                     |    1 +
 src/test/authentication/t/001_password.pl     |    8 +-
 src/test/modules/Makefile                     |    1 +
 src/test/modules/meson.build                  |    1 +
 src/test/modules/oauth_validator/.gitignore   |    4 +
 src/test/modules/oauth_validator/Makefile     |   40 +
 src/test/modules/oauth_validator/README       |   13 +
 .../modules/oauth_validator/fail_validator.c  |   42 +
 src/test/modules/oauth_validator/meson.build  |   69 +
 .../oauth_validator/oauth_hook_client.c       |  264 ++
 .../modules/oauth_validator/t/001_server.pl   |  551 ++++
 .../modules/oauth_validator/t/002_client.pl   |  154 +
 .../modules/oauth_validator/t/OAuth/Server.pm |  140 +
 .../modules/oauth_validator/t/oauth_server.py |  391 +++
 src/test/modules/oauth_validator/validator.c  |  135 +
 src/test/perl/PostgreSQL/Test/Cluster.pm      |   20 +-
 src/tools/pgindent/pgindent                   |   14 +
 src/tools/pgindent/typedefs.list              |   11 +
 59 files changed, 8598 insertions(+), 39 deletions(-)
 create mode 100644 doc/src/sgml/oauth-validators.sgml
 create mode 100644 src/backend/libpq/auth-oauth.c
 create mode 100644 src/include/common/oauth-common.h
 create mode 100644 src/include/libpq/oauth.h
 create mode 100644 src/interfaces/libpq/fe-auth-oauth-curl.c
 create mode 100644 src/interfaces/libpq/fe-auth-oauth.c
 create mode 100644 src/interfaces/libpq/fe-auth-oauth.h
 create mode 100644 src/test/modules/oauth_validator/.gitignore
 create mode 100644 src/test/modules/oauth_validator/Makefile
 create mode 100644 src/test/modules/oauth_validator/README
 create mode 100644 src/test/modules/oauth_validator/fail_validator.c
 create mode 100644 src/test/modules/oauth_validator/meson.build
 create mode 100644 src/test/modules/oauth_validator/oauth_hook_client.c
 create mode 100644 src/test/modules/oauth_validator/t/001_server.pl
 create mode 100644 src/test/modules/oauth_validator/t/002_client.pl
 create mode 100644 src/test/modules/oauth_validator/t/OAuth/Server.pm
 create mode 100755 src/test/modules/oauth_validator/t/oauth_server.py
 create mode 100644 src/test/modules/oauth_validator/validator.c

diff --git a/.cirrus.tasks.yml b/.cirrus.tasks.yml
index 18e944ca89d..8c518c317e7 100644
--- a/.cirrus.tasks.yml
+++ b/.cirrus.tasks.yml
@@ -20,7 +20,7 @@ env:
   MTEST_ARGS: --print-errorlogs --no-rebuild -C build
   PGCTLTIMEOUT: 120 # avoids spurious failures during parallel tests
   TEMP_CONFIG: ${CIRRUS_WORKING_DIR}/src/tools/ci/pg_ci_base.conf
-  PG_TEST_EXTRA: kerberos ldap ssl libpq_encryption load_balance
+  PG_TEST_EXTRA: kerberos ldap ssl libpq_encryption load_balance oauth
 
 
 # What files to preserve in case tests fail
@@ -164,7 +164,7 @@ task:
     chown root:postgres /tmp/cores
     sysctl kern.corefile='/tmp/cores/%N.%P.core'
   setup_additional_packages_script: |
-    #pkg install -y ...
+    pkg install -y curl
 
   # NB: Intentionally build without -Dllvm. The freebsd image size is already
   # large enough to make VM startup slow, and even without llvm freebsd
@@ -219,6 +219,7 @@ LINUX_CONFIGURE_FEATURES: &LINUX_CONFIGURE_FEATURES >-
   --with-gssapi
   --with-icu
   --with-ldap
+  --with-libcurl
   --with-libxml
   --with-libxslt
   --with-llvm
@@ -312,8 +313,10 @@ task:
     EOF
 
   setup_additional_packages_script: |
-    #apt-get update
-    #DEBIAN_FRONTEND=noninteractive apt-get -y install ...
+    apt-get update
+    DEBIAN_FRONTEND=noninteractive apt-get -y install \
+      libcurl4-openssl-dev \
+      libcurl4-openssl-dev:i386 \
 
   matrix:
     - name: Linux - Debian Bookworm - Autoconf
@@ -689,8 +692,8 @@ task:
     folder: $CCACHE_DIR
 
   setup_additional_packages_script: |
-    #apt-get update
-    #DEBIAN_FRONTEND=noninteractive apt-get -y install ...
+    apt-get update
+    DEBIAN_FRONTEND=noninteractive apt-get -y install libcurl4-openssl-dev
 
   ###
   # Test that code can be built with gcc/clang without warnings
diff --git a/config/programs.m4 b/config/programs.m4
index 7b55c2664a6..86a3750f9e5 100644
--- a/config/programs.m4
+++ b/config/programs.m4
@@ -274,3 +274,45 @@ AC_DEFUN([PGAC_CHECK_STRIP],
   AC_SUBST(STRIP_STATIC_LIB)
   AC_SUBST(STRIP_SHARED_LIB)
 ])# PGAC_CHECK_STRIP
+
+
+
+# PGAC_CHECK_LIBCURL
+# ------------------
+# Check for required libraries and headers, and test to see whether the current
+# installation of libcurl is threadsafe.
+
+AC_DEFUN([PGAC_CHECK_LIBCURL],
+[
+  AC_CHECK_HEADER(curl/curl.h, [],
+				  [AC_MSG_ERROR([header file <curl/curl.h> is required for --with-libcurl])])
+  AC_CHECK_LIB(curl, curl_multi_init, [],
+			   [AC_MSG_ERROR([library 'curl' does not provide curl_multi_init])])
+
+  # Check to see whether the current platform supports threadsafe Curl
+  # initialization.
+  AC_CACHE_CHECK([for curl_global_init thread safety], [pgac_cv__libcurl_threadsafe_init],
+  [AC_RUN_IFELSE([AC_LANG_PROGRAM([
+#include <curl/curl.h>
+],[
+    curl_version_info_data *info;
+
+    if (curl_global_init(CURL_GLOBAL_ALL))
+        return -1;
+
+    info = curl_version_info(CURLVERSION_NOW);
+#ifdef CURL_VERSION_THREADSAFE
+    if (info->features & CURL_VERSION_THREADSAFE)
+        return 0;
+#endif
+
+    return 1;
+])],
+  [pgac_cv__libcurl_threadsafe_init=yes],
+  [pgac_cv__libcurl_threadsafe_init=no],
+  [pgac_cv__libcurl_threadsafe_init=unknown])])
+  if test x"$pgac_cv__libcurl_threadsafe_init" = xyes ; then
+    AC_DEFINE(HAVE_THREADSAFE_CURL_GLOBAL_INIT, 1,
+              [Define to 1 if curl_global_init() is guaranteed to be threadsafe.])
+  fi
+])# PGAC_CHECK_LIBCURL
diff --git a/configure b/configure
index ceeef9b0915..115a91f8f4a 100755
--- a/configure
+++ b/configure
@@ -708,6 +708,9 @@ XML2_LIBS
 XML2_CFLAGS
 XML2_CONFIG
 with_libxml
+LIBCURL_LIBS
+LIBCURL_CFLAGS
+with_libcurl
 with_uuid
 with_readline
 with_systemd
@@ -864,6 +867,7 @@ with_readline
 with_libedit_preferred
 with_uuid
 with_ossp_uuid
+with_libcurl
 with_libxml
 with_libxslt
 with_system_tzdata
@@ -894,6 +898,8 @@ PKG_CONFIG_PATH
 PKG_CONFIG_LIBDIR
 ICU_CFLAGS
 ICU_LIBS
+LIBCURL_CFLAGS
+LIBCURL_LIBS
 XML2_CONFIG
 XML2_CFLAGS
 XML2_LIBS
@@ -1574,6 +1580,7 @@ Optional Packages:
                           prefer BSD Libedit over GNU Readline
   --with-uuid=LIB         build contrib/uuid-ossp using LIB (bsd,e2fs,ossp)
   --with-ossp-uuid        obsolete spelling of --with-uuid=ossp
+  --with-libcurl          build with libcurl support
   --with-libxml           build with XML support
   --with-libxslt          use XSLT support when building contrib/xml2
   --with-system-tzdata=DIR
@@ -1607,6 +1614,10 @@ Some influential environment variables:
               path overriding pkg-config's built-in search path
   ICU_CFLAGS  C compiler flags for ICU, overriding pkg-config
   ICU_LIBS    linker flags for ICU, overriding pkg-config
+  LIBCURL_CFLAGS
+              C compiler flags for LIBCURL, overriding pkg-config
+  LIBCURL_LIBS
+              linker flags for LIBCURL, overriding pkg-config
   XML2_CONFIG path to xml2-config utility
   XML2_CFLAGS C compiler flags for XML2, overriding pkg-config
   XML2_LIBS   linker flags for XML2, overriding pkg-config
@@ -8762,6 +8773,157 @@ fi
 
 
 
+#
+# libcurl
+#
+{ $as_echo "$as_me:${as_lineno-$LINENO}: checking whether to build with libcurl support" >&5
+$as_echo_n "checking whether to build with libcurl support... " >&6; }
+
+
+
+# Check whether --with-libcurl was given.
+if test "${with_libcurl+set}" = set; then :
+  withval=$with_libcurl;
+  case $withval in
+    yes)
+
+$as_echo "#define USE_LIBCURL 1" >>confdefs.h
+
+      ;;
+    no)
+      :
+      ;;
+    *)
+      as_fn_error $? "no argument expected for --with-libcurl option" "$LINENO" 5
+      ;;
+  esac
+
+else
+  with_libcurl=no
+
+fi
+
+
+{ $as_echo "$as_me:${as_lineno-$LINENO}: result: $with_libcurl" >&5
+$as_echo "$with_libcurl" >&6; }
+
+
+if test "$with_libcurl" = yes ; then
+  # Check for libcurl 7.61.0 or higher (corresponding to RHEL8 and the ability
+  # to explicitly set TLS 1.3 ciphersuites).
+
+pkg_failed=no
+{ $as_echo "$as_me:${as_lineno-$LINENO}: checking for libcurl >= 7.61.0" >&5
+$as_echo_n "checking for libcurl >= 7.61.0... " >&6; }
+
+if test -n "$LIBCURL_CFLAGS"; then
+    pkg_cv_LIBCURL_CFLAGS="$LIBCURL_CFLAGS"
+ elif test -n "$PKG_CONFIG"; then
+    if test -n "$PKG_CONFIG" && \
+    { { $as_echo "$as_me:${as_lineno-$LINENO}: \$PKG_CONFIG --exists --print-errors \"libcurl >= 7.61.0\""; } >&5
+  ($PKG_CONFIG --exists --print-errors "libcurl >= 7.61.0") 2>&5
+  ac_status=$?
+  $as_echo "$as_me:${as_lineno-$LINENO}: \$? = $ac_status" >&5
+  test $ac_status = 0; }; then
+  pkg_cv_LIBCURL_CFLAGS=`$PKG_CONFIG --cflags "libcurl >= 7.61.0" 2>/dev/null`
+		      test "x$?" != "x0" && pkg_failed=yes
+else
+  pkg_failed=yes
+fi
+ else
+    pkg_failed=untried
+fi
+if test -n "$LIBCURL_LIBS"; then
+    pkg_cv_LIBCURL_LIBS="$LIBCURL_LIBS"
+ elif test -n "$PKG_CONFIG"; then
+    if test -n "$PKG_CONFIG" && \
+    { { $as_echo "$as_me:${as_lineno-$LINENO}: \$PKG_CONFIG --exists --print-errors \"libcurl >= 7.61.0\""; } >&5
+  ($PKG_CONFIG --exists --print-errors "libcurl >= 7.61.0") 2>&5
+  ac_status=$?
+  $as_echo "$as_me:${as_lineno-$LINENO}: \$? = $ac_status" >&5
+  test $ac_status = 0; }; then
+  pkg_cv_LIBCURL_LIBS=`$PKG_CONFIG --libs "libcurl >= 7.61.0" 2>/dev/null`
+		      test "x$?" != "x0" && pkg_failed=yes
+else
+  pkg_failed=yes
+fi
+ else
+    pkg_failed=untried
+fi
+
+
+
+if test $pkg_failed = yes; then
+        { $as_echo "$as_me:${as_lineno-$LINENO}: result: no" >&5
+$as_echo "no" >&6; }
+
+if $PKG_CONFIG --atleast-pkgconfig-version 0.20; then
+        _pkg_short_errors_supported=yes
+else
+        _pkg_short_errors_supported=no
+fi
+        if test $_pkg_short_errors_supported = yes; then
+	        LIBCURL_PKG_ERRORS=`$PKG_CONFIG --short-errors --print-errors --cflags --libs "libcurl >= 7.61.0" 2>&1`
+        else
+	        LIBCURL_PKG_ERRORS=`$PKG_CONFIG --print-errors --cflags --libs "libcurl >= 7.61.0" 2>&1`
+        fi
+	# Put the nasty error message in config.log where it belongs
+	echo "$LIBCURL_PKG_ERRORS" >&5
+
+	as_fn_error $? "Package requirements (libcurl >= 7.61.0) were not met:
+
+$LIBCURL_PKG_ERRORS
+
+Consider adjusting the PKG_CONFIG_PATH environment variable if you
+installed software in a non-standard prefix.
+
+Alternatively, you may set the environment variables LIBCURL_CFLAGS
+and LIBCURL_LIBS to avoid the need to call pkg-config.
+See the pkg-config man page for more details." "$LINENO" 5
+elif test $pkg_failed = untried; then
+        { $as_echo "$as_me:${as_lineno-$LINENO}: result: no" >&5
+$as_echo "no" >&6; }
+	{ { $as_echo "$as_me:${as_lineno-$LINENO}: error: in \`$ac_pwd':" >&5
+$as_echo "$as_me: error: in \`$ac_pwd':" >&2;}
+as_fn_error $? "The pkg-config script could not be found or is too old.  Make sure it
+is in your PATH or set the PKG_CONFIG environment variable to the full
+path to pkg-config.
+
+Alternatively, you may set the environment variables LIBCURL_CFLAGS
+and LIBCURL_LIBS to avoid the need to call pkg-config.
+See the pkg-config man page for more details.
+
+To get pkg-config, see <http://pkg-config.freedesktop.org/>.
+See \`config.log' for more details" "$LINENO" 5; }
+else
+	LIBCURL_CFLAGS=$pkg_cv_LIBCURL_CFLAGS
+	LIBCURL_LIBS=$pkg_cv_LIBCURL_LIBS
+        { $as_echo "$as_me:${as_lineno-$LINENO}: result: yes" >&5
+$as_echo "yes" >&6; }
+
+fi
+
+  # We only care about -I, -D, and -L switches;
+  # note that -lcurl will be added by PGAC_CHECK_LIBCURL below.
+  for pgac_option in $LIBCURL_CFLAGS; do
+    case $pgac_option in
+      -I*|-D*) CPPFLAGS="$CPPFLAGS $pgac_option";;
+    esac
+  done
+  for pgac_option in $LIBCURL_LIBS; do
+    case $pgac_option in
+      -L*) LDFLAGS="$LDFLAGS $pgac_option";;
+    esac
+  done
+
+  # OAuth requires python for testing
+  if test "$with_python" != yes; then
+    { $as_echo "$as_me:${as_lineno-$LINENO}: WARNING: *** OAuth support tests require --with-python to run" >&5
+$as_echo "$as_me: WARNING: *** OAuth support tests require --with-python to run" >&2;}
+  fi
+fi
+
+
 #
 # XML
 #
@@ -12216,6 +12378,123 @@ fi
 
 fi
 
+# XXX libcurl must link after libgssapi_krb5 on FreeBSD to avoid segfaults
+# during gss_acquire_cred(). This is possibly related to Curl's Heimdal
+# dependency on that platform?
+if test "$with_libcurl" = yes ; then
+
+  ac_fn_c_check_header_mongrel "$LINENO" "curl/curl.h" "ac_cv_header_curl_curl_h" "$ac_includes_default"
+if test "x$ac_cv_header_curl_curl_h" = xyes; then :
+
+else
+  as_fn_error $? "header file <curl/curl.h> is required for --with-libcurl" "$LINENO" 5
+fi
+
+
+  { $as_echo "$as_me:${as_lineno-$LINENO}: checking for curl_multi_init in -lcurl" >&5
+$as_echo_n "checking for curl_multi_init in -lcurl... " >&6; }
+if ${ac_cv_lib_curl_curl_multi_init+:} false; then :
+  $as_echo_n "(cached) " >&6
+else
+  ac_check_lib_save_LIBS=$LIBS
+LIBS="-lcurl  $LIBS"
+cat confdefs.h - <<_ACEOF >conftest.$ac_ext
+/* end confdefs.h.  */
+
+/* Override any GCC internal prototype to avoid an error.
+   Use char because int might match the return type of a GCC
+   builtin and then its argument prototype would still apply.  */
+#ifdef __cplusplus
+extern "C"
+#endif
+char curl_multi_init ();
+int
+main ()
+{
+return curl_multi_init ();
+  ;
+  return 0;
+}
+_ACEOF
+if ac_fn_c_try_link "$LINENO"; then :
+  ac_cv_lib_curl_curl_multi_init=yes
+else
+  ac_cv_lib_curl_curl_multi_init=no
+fi
+rm -f core conftest.err conftest.$ac_objext \
+    conftest$ac_exeext conftest.$ac_ext
+LIBS=$ac_check_lib_save_LIBS
+fi
+{ $as_echo "$as_me:${as_lineno-$LINENO}: result: $ac_cv_lib_curl_curl_multi_init" >&5
+$as_echo "$ac_cv_lib_curl_curl_multi_init" >&6; }
+if test "x$ac_cv_lib_curl_curl_multi_init" = xyes; then :
+  cat >>confdefs.h <<_ACEOF
+#define HAVE_LIBCURL 1
+_ACEOF
+
+  LIBS="-lcurl $LIBS"
+
+else
+  as_fn_error $? "library 'curl' does not provide curl_multi_init" "$LINENO" 5
+fi
+
+
+  # Check to see whether the current platform supports threadsafe Curl
+  # initialization.
+  { $as_echo "$as_me:${as_lineno-$LINENO}: checking for curl_global_init thread safety" >&5
+$as_echo_n "checking for curl_global_init thread safety... " >&6; }
+if ${pgac_cv__libcurl_threadsafe_init+:} false; then :
+  $as_echo_n "(cached) " >&6
+else
+  if test "$cross_compiling" = yes; then :
+  pgac_cv__libcurl_threadsafe_init=unknown
+else
+  cat confdefs.h - <<_ACEOF >conftest.$ac_ext
+/* end confdefs.h.  */
+
+#include <curl/curl.h>
+
+int
+main ()
+{
+
+    curl_version_info_data *info;
+
+    if (curl_global_init(CURL_GLOBAL_ALL))
+        return -1;
+
+    info = curl_version_info(CURLVERSION_NOW);
+#ifdef CURL_VERSION_THREADSAFE
+    if (info->features & CURL_VERSION_THREADSAFE)
+        return 0;
+#endif
+
+    return 1;
+
+  ;
+  return 0;
+}
+_ACEOF
+if ac_fn_c_try_run "$LINENO"; then :
+  pgac_cv__libcurl_threadsafe_init=yes
+else
+  pgac_cv__libcurl_threadsafe_init=no
+fi
+rm -f core *.core core.conftest.* gmon.out bb.out conftest$ac_exeext \
+  conftest.$ac_objext conftest.beam conftest.$ac_ext
+fi
+
+fi
+{ $as_echo "$as_me:${as_lineno-$LINENO}: result: $pgac_cv__libcurl_threadsafe_init" >&5
+$as_echo "$pgac_cv__libcurl_threadsafe_init" >&6; }
+  if test x"$pgac_cv__libcurl_threadsafe_init" = xyes ; then
+
+$as_echo "#define HAVE_THREADSAFE_CURL_GLOBAL_INIT 1" >>confdefs.h
+
+  fi
+
+fi
+
 if test "$with_gssapi" = yes ; then
   if test "$PORTNAME" != "win32"; then
     { $as_echo "$as_me:${as_lineno-$LINENO}: checking for library containing gss_store_cred_into" >&5
diff --git a/configure.ac b/configure.ac
index d713360f340..e8f1a7db9de 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1007,6 +1007,40 @@ fi
 AC_SUBST(with_uuid)
 
 
+#
+# libcurl
+#
+AC_MSG_CHECKING([whether to build with libcurl support])
+PGAC_ARG_BOOL(with, libcurl, no, [build with libcurl support],
+              [AC_DEFINE([USE_LIBCURL], 1, [Define to 1 to build with libcurl support. (--with-libcurl)])])
+AC_MSG_RESULT([$with_libcurl])
+AC_SUBST(with_libcurl)
+
+if test "$with_libcurl" = yes ; then
+  # Check for libcurl 7.61.0 or higher (corresponding to RHEL8 and the ability
+  # to explicitly set TLS 1.3 ciphersuites).
+  PKG_CHECK_MODULES(LIBCURL, [libcurl >= 7.61.0])
+
+  # We only care about -I, -D, and -L switches;
+  # note that -lcurl will be added by PGAC_CHECK_LIBCURL below.
+  for pgac_option in $LIBCURL_CFLAGS; do
+    case $pgac_option in
+      -I*|-D*) CPPFLAGS="$CPPFLAGS $pgac_option";;
+    esac
+  done
+  for pgac_option in $LIBCURL_LIBS; do
+    case $pgac_option in
+      -L*) LDFLAGS="$LDFLAGS $pgac_option";;
+    esac
+  done
+
+  # OAuth requires python for testing
+  if test "$with_python" != yes; then
+    AC_MSG_WARN([*** OAuth support tests require --with-python to run])
+  fi
+fi
+
+
 #
 # XML
 #
@@ -1294,6 +1328,13 @@ failure.  It is possible the compiler isn't looking in the proper directory.
 Use --without-zlib to disable zlib support.])])
 fi
 
+# XXX libcurl must link after libgssapi_krb5 on FreeBSD to avoid segfaults
+# during gss_acquire_cred(). This is possibly related to Curl's Heimdal
+# dependency on that platform?
+if test "$with_libcurl" = yes ; then
+  PGAC_CHECK_LIBCURL
+fi
+
 if test "$with_gssapi" = yes ; then
   if test "$PORTNAME" != "win32"; then
     AC_SEARCH_LIBS(gss_store_cred_into, [gssapi_krb5 gss 'gssapi -lkrb5 -lcrypto'], [],
diff --git a/doc/src/sgml/client-auth.sgml b/doc/src/sgml/client-auth.sgml
index 782b49c85ac..f84085dbac4 100644
--- a/doc/src/sgml/client-auth.sgml
+++ b/doc/src/sgml/client-auth.sgml
@@ -656,6 +656,16 @@ include_dir         <replaceable>directory</replaceable>
          </para>
         </listitem>
        </varlistentry>
+
+       <varlistentry>
+        <term><literal>oauth</literal></term>
+        <listitem>
+         <para>
+          Authorize and optionally authenticate using a third-party OAuth 2.0
+          identity provider. See <xref linkend="auth-oauth"/> for details.
+         </para>
+        </listitem>
+       </varlistentry>
       </variablelist>
 
       </para>
@@ -1143,6 +1153,12 @@ omicron         bryanh                  guest1
       only on OpenBSD).
      </para>
     </listitem>
+    <listitem>
+     <para>
+      <link linkend="auth-oauth">OAuth authorization/authentication</link>,
+      which relies on an external OAuth 2.0 identity provider.
+     </para>
+    </listitem>
    </itemizedlist>
   </para>
 
@@ -2329,6 +2345,242 @@ host ... radius radiusservers="server1,server2" radiussecrets="""secret one"",""
    </note>
   </sect1>
 
+  <sect1 id="auth-oauth">
+   <title>OAuth Authorization/Authentication</title>
+
+   <indexterm zone="auth-oauth">
+    <primary>OAuth Authorization/Authentication</primary>
+   </indexterm>
+
+   <para>
+    OAuth 2.0 is an industry-standard framework, defined in
+    <ulink url="https://datatracker.ietf.org/doc/html/rfc6749">RFC 6749</ulink>,
+    to enable third-party applications to obtain limited access to a protected
+    resource.
+
+    OAuth client support has to be enabled when <productname>PostgreSQL</productname>
+    is built, see <xref linkend="installation"/> for more information.
+   </para>
+
+   <para>
+    This documentation uses the following terminology when discussing the OAuth
+    ecosystem:
+
+    <variablelist>
+
+     <varlistentry>
+      <term>Resource Owner (or End User)</term>
+      <listitem>
+       <para>
+        The user or system who owns protected resources and can grant access to
+        them. This documentation also uses the term <emphasis>end user</emphasis>
+        when the resource owner is a person. When you use
+        <application>psql</application> to connect to the database using OAuth,
+        you are the resource owner/end user.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term>Client</term>
+      <listitem>
+       <para>
+        The system which accesses the protected resources using access
+        tokens. Applications using libpq, such as <application>psql</application>,
+        are the OAuth clients when connecting to a
+        <productname>PostgreSQL</productname> cluster.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term>Resource Server</term>
+      <listitem>
+       <para>
+        The system which hosts the protected resources which are
+        accessed by the client. The <productname>PostgreSQL</productname>
+        cluster being connected to is the resource server.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term>Provider</term>
+      <listitem>
+       <para>
+        The organization, product vendor, or other entity which develops and/or
+        administers the OAuth servers and clients for a given application.
+        Different providers typically choose different implementation details
+        for their OAuth systems; a client of one provider is not generally
+        guaranteed to have access to the servers of another.
+       </para>
+       <para>
+        This use of the term "provider" is not standard, but it seems to be in
+        wide use colloquially. (It should not be confused with OpenID's similar
+        term "Identity Provider". While the implementation of OAuth in
+        <productname>PostgreSQL</productname> is intended to be interoperable
+        and compatible with OpenID Connect/OIDC, it is not itself an OIDC client
+        and does not require its use.)
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term>Authorization Server</term>
+      <listitem>
+       <para>
+        The system which receives requests from, and issues access tokens to,
+        the client after the authenticated resource owner has given approval.
+        <productname>PostgreSQL</productname> does not provide an authorization
+        server; it's obtained from the OAuth provider.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term id="auth-oauth-issuer">Issuer</term>
+      <listitem>
+       <para>
+        An identifier for an authorization server, printed as an
+        <literal>https://</literal> URL, which provides a trusted "namespace"
+        for OAuth clients and applications. The issuer identifier allows a
+        single authorization server to talk to the clients of mutually
+        untrusting entities, as long as they maintain separate issuers.
+       </para>
+      </listitem>
+     </varlistentry>
+
+    </variablelist>
+
+    <note>
+     <para>
+      For small deployments, there may not be a meaningful distinction between
+      the "provider", "authorization server", and "issuer". However, for more
+      complicated setups, there may be a one-to-many (or many-to-many)
+      relationship: a provider may rent out multiple issuer identifiers to
+      separate tenants, then provide multiple authorization servers, possibly
+      with different supported feature sets, to interact with their clients.
+     </para>
+    </note>
+   </para>
+
+   <para>
+    <productname>PostgreSQL</productname> supports bearer tokens, defined in
+    <ulink url="https://datatracker.ietf.org/doc/html/rfc6750">RFC 6750</ulink>,
+    which are a type of access token used with OAuth 2.0 where the token is an
+    opaque string.  The format of the access token is implementation specific
+    and is chosen by each authorization server.
+   </para>
+
+   <para>
+    The following configuration options are supported for OAuth:
+    <variablelist>
+     <varlistentry>
+      <term><literal>issuer</literal></term>
+      <listitem>
+       <para>
+        An HTTPS URL which is either the exact
+        <link linkend="auth-oauth-issuer">issuer identifier</link> of the
+        authorization server, as defined by its discovery document, or a
+        well-known URI that points directly to that discovery document. This
+        parameter is required.
+       </para>
+       <para>
+        When an OAuth client connects to the server, a URL for the discovery
+        document will be constructed using the issuer identifier. By default,
+        this URL uses the conventions of OpenID Connect Discovery: the path
+        <literal>/.well-known/openid-configuration</literal> will be appended
+        to the end of the issuer identifier. Alternatively, if the
+        <literal>issuer</literal> contains a <literal>/.well-known/</literal>
+        path segment, that URL will be provided to the client as-is.
+       </para>
+       <warning>
+        <para>
+         The OAuth client in libpq requires the server's issuer setting to
+         exactly match the issuer identifier which is provided in the discovery
+         document, which must in turn match the client's
+         <xref linkend="libpq-connect-oauth-issuer"/> setting. No variations in
+         case or format are permitted.
+        </para>
+       </warning>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term><literal>scope</literal></term>
+      <listitem>
+       <para>
+        A space-separated list of the OAuth scopes needed for the server to
+        both authorize the client and authenticate the user.  Appropriate values
+        are determined by the authorization server and the OAuth validation
+        module used (see <xref linkend="oauth-validators" /> for more
+        information on validators).  This parameter is required.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term><literal>validator</literal></term>
+      <listitem>
+       <para>
+        The library to use for validating bearer tokens. If given, the name must
+        exactly match one of the libraries listed in
+        <xref linkend="guc-oauth-validator-libraries" />.  This parameter is
+        optional unless <literal>oauth_validator_libraries</literal> contains
+        more than one library, in which case it is required.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term><literal>map</literal></term>
+      <listitem>
+       <para>
+        Allows for mapping between OAuth identity provider and database user
+        names.  See <xref linkend="auth-username-maps"/> for details.  If a
+        map is not specified, the user name associated with the token (as
+        determined by the OAuth validator) must exactly match the role name
+        being requested.  This parameter is optional.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term id="auth-oauth-delegate-ident-mapping" xreflabel="delegate_ident_mapping">
+       <literal>delegate_ident_mapping</literal>
+      </term>
+      <listitem>
+       <para>
+        An advanced option which is not intended for common use.
+       </para>
+       <para>
+        When set to <literal>1</literal>, standard user mapping with
+        <filename>pg_ident.conf</filename> is skipped, and the OAuth validator
+        takes full responsibility for mapping end user identities to database
+        roles.  If the validator authorizes the token, the server trusts that
+        the user is allowed to connect under the requested role, and the
+        connection is allowed to proceed regardless of the authentication
+        status of the user.
+       </para>
+       <para>
+        This parameter is incompatible with <literal>map</literal>.
+       </para>
+       <warning>
+        <para>
+         <literal>delegate_ident_mapping</literal> provides additional
+         flexibility in the design of the authentication system, but it also
+         requires careful implementation of the OAuth validator, which must
+         determine whether the provided token carries sufficient end-user
+         privileges in addition to the <link linkend="oauth-validators">standard
+         checks</link> required of all validators.  Use with caution.
+        </para>
+       </warning>
+      </listitem>
+     </varlistentry>
+    </variablelist>
+   </para>
+  </sect1>
+
   <sect1 id="client-authentication-problems">
    <title>Authentication Problems</title>
 
diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml
index a782f109982..d7bac61a7fe 100644
--- a/doc/src/sgml/config.sgml
+++ b/doc/src/sgml/config.sgml
@@ -1209,6 +1209,32 @@ include_dir 'conf.d'
        </para>
       </listitem>
      </varlistentry>
+
+     <varlistentry id="guc-oauth-validator-libraries" xreflabel="oauth_validator_libraries">
+      <term><varname>oauth_validator_libraries</varname> (<type>string</type>)
+      <indexterm>
+       <primary><varname>oauth_validator_libraries</varname> configuration parameter</primary>
+      </indexterm>
+      </term>
+      <listitem>
+       <para>
+        The library/libraries to use for validating OAuth connection tokens. If
+        only one validator library is provided, it will be used by default for
+        any OAuth connections; otherwise, all
+        <link linkend="auth-oauth"><literal>oauth</literal> HBA entries</link>
+        must explicitly set a <literal>validator</literal> chosen from this
+        list. If set to an empty string (the default), OAuth connections will be
+        refused. This parameter can only be set in the
+        <filename>postgresql.conf</filename> file.
+       </para>
+       <para>
+        Validator modules must be implemented/obtained separately;
+        <productname>PostgreSQL</productname> does not ship with any default
+        implementations. For more information on implementing OAuth validators,
+        see <xref linkend="oauth-validators" />.
+       </para>
+      </listitem>
+     </varlistentry>
      </variablelist>
      </sect2>
 
diff --git a/doc/src/sgml/filelist.sgml b/doc/src/sgml/filelist.sgml
index 66e6dccd4c9..25fb99cee69 100644
--- a/doc/src/sgml/filelist.sgml
+++ b/doc/src/sgml/filelist.sgml
@@ -111,6 +111,7 @@
 <!ENTITY generic-wal SYSTEM "generic-wal.sgml">
 <!ENTITY custom-rmgr SYSTEM "custom-rmgr.sgml">
 <!ENTITY backup-manifest SYSTEM "backup-manifest.sgml">
+<!ENTITY oauth-validators SYSTEM "oauth-validators.sgml">
 
 <!-- contrib information -->
 <!ENTITY contrib         SYSTEM "contrib.sgml">
diff --git a/doc/src/sgml/installation.sgml b/doc/src/sgml/installation.sgml
index 3f0a7e9c069..96e433179b9 100644
--- a/doc/src/sgml/installation.sgml
+++ b/doc/src/sgml/installation.sgml
@@ -1143,6 +1143,19 @@ build-postgresql:
        </listitem>
       </varlistentry>
 
+      <varlistentry id="configure-option-with-libcurl">
+       <term><option>--with-libcurl</option></term>
+       <listitem>
+        <para>
+         Build with libcurl support for OAuth 2.0 client flows.
+         This requires the <productname>curl</productname> package to be
+         installed.  Building with this will check for the required header files
+         and libraries to make sure that your <productname>curl</productname>
+         installation is sufficient before proceeding.
+        </para>
+       </listitem>
+      </varlistentry>
+
       <varlistentry id="configure-option-with-libxml">
        <term><option>--with-libxml</option></term>
        <listitem>
@@ -2584,6 +2597,20 @@ ninja install
       </listitem>
      </varlistentry>
 
+     <varlistentry id="configure-with-libcurl-meson">
+      <term><option>-Dlibcurl={ auto | enabled | disabled }</option></term>
+      <listitem>
+       <para>
+        Build with libcurl support for OAuth 2.0 client flows.
+        This requires the <productname>curl</productname> package to be
+        installed.  Building with this will check for the required header files
+        and libraries to make sure that your <productname>curl</productname>
+        installation is sufficient before proceeding. The default for this
+        option is auto.
+       </para>
+      </listitem>
+     </varlistentry>
+
      <varlistentry id="configure-with-libxml-meson">
       <term><option>-Dlibxml={ auto | enabled | disabled }</option></term>
       <listitem>
diff --git a/doc/src/sgml/libpq.sgml b/doc/src/sgml/libpq.sgml
index e04acf1c208..9a69ffbc5b3 100644
--- a/doc/src/sgml/libpq.sgml
+++ b/doc/src/sgml/libpq.sgml
@@ -1385,6 +1385,15 @@ postgresql://%2Fvar%2Flib%2Fpostgresql/dbname
           </listitem>
          </varlistentry>
 
+         <varlistentry>
+          <term><literal>oauth</literal></term>
+          <listitem>
+           <para>
+            The server must request an OAuth bearer token from the client.
+           </para>
+          </listitem>
+         </varlistentry>
+
          <varlistentry>
           <term><literal>none</literal></term>
           <listitem>
@@ -2373,6 +2382,106 @@ postgresql://%2Fvar%2Flib%2Fpostgresql/dbname
        </para>
       </listitem>
      </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-issuer" xreflabel="oauth_issuer">
+      <term><literal>oauth_issuer</literal></term>
+      <listitem>
+       <para>
+        The HTTPS URL of a trusted issuer to contact if the server requests an
+        OAuth token for the connection. This parameter is required for all OAuth
+        connections; it should exactly match the <literal>issuer</literal>
+        setting in <link linkend="auth-oauth">the server's HBA configuration.</link>
+       </para>
+       <para>
+        As part of the standard authentication handshake, <application>libpq</application>
+        will ask the server for a <emphasis>discovery document:</emphasis> a URL
+        providing a set of OAuth configuration parameters. The server must
+        provide a URL that is directly constructed from the components of the
+        <literal>oauth_issuer</literal>, and this value must exactly match the
+        issuer identifier that is declared in the discovery document itself, or
+        the connection will fail. This is required to prevent a class of "mix-up
+        attacks" on OAuth clients.
+       </para>
+       <para>
+        You may also explicitly set <literal>oauth_issuer</literal> to the
+        <literal>/.well-known/</literal> URI used for OAuth discovery. In this
+        case, if the server asks for a different URL, the connection will fail,
+        but a <link linkend="libpq-oauth-authdata-hooks">custom OAuth flow</link>
+        may be able to speed up the standard handshake by using previously
+        cached tokens. (In this case, it is recommended that
+        <xref linkend="libpq-connect-oauth-scope"/> be set as well, since the
+        client will not have a chance to ask the server for a correct scope
+        setting, and the default scopes for a token may not be sufficient to
+        connect.) <application>libpq</application> currently supports the
+        following well-known endpoints:
+        <itemizedlist spacing="compact">
+         <listitem><para><literal>/.well-known/openid-configuration</literal></para></listitem>
+         <listitem><para><literal>/.well-known/oauth-authorization-server</literal></para></listitem>
+        </itemizedlist>
+       </para>
+       <warning>
+        <para>
+         Issuers are highly privileged during the OAuth connection handshake. As
+         a rule of thumb, if you would not trust the operator of a URL to handle
+         access to your servers, or to impersonate you directly, that URL should
+         not be trusted as an <literal>oauth_issuer</literal>.
+        </para>
+       </warning>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-client-id" xreflabel="oauth_client_id">
+      <term><literal>oauth_client_id</literal></term>
+      <listitem>
+       <para>
+        An OAuth 2.0 client identifier, as issued by the authorization server.
+        If the <productname>PostgreSQL</productname> server
+        <link linkend="auth-oauth">requests an OAuth token</link> for the
+        connection (and if no <link linkend="libpq-oauth-authdata-hooks">custom
+        OAuth hook</link> is installed to provide one), then this parameter must
+        be set; otherwise, the connection will fail.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-client-secret" xreflabel="oauth_client_secret">
+      <term><literal>oauth_client_secret</literal></term>
+      <listitem>
+       <para>
+        The client password, if any, to use when contacting the OAuth
+        authorization server. Whether this parameter is required or not is
+        determined by the OAuth provider; "public" clients generally do not use
+        a secret, whereas "confidential" clients generally do.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-scope" xreflabel="oauth_scope">
+      <term><literal>oauth_scope</literal></term>
+      <listitem>
+       <para>
+        The scope of the access request sent to the authorization server,
+        specified as a (possibly empty) space-separated list of OAuth scope
+        identifiers. This parameter is optional and intended for advanced usage.
+       </para>
+       <para>
+        Usually the client will obtain appropriate scope settings from the
+        <productname>PostgreSQL</productname> server. If this parameter is used,
+        the server's requested scope list will be ignored. This can prevent a
+        less-trusted server from requesting inappropriate access scopes from the
+        end user. However, if the client's scope setting does not contain the
+        server's required scopes, the server is likely to reject the issued
+        token, and the connection will fail.
+       </para>
+       <para>
+        The meaning of an empty scope list is provider-dependent. An OAuth
+        authorization server may choose to issue a token with "default scope",
+        whatever that happens to be, or it may reject the token request
+        entirely.
+       </para>
+      </listitem>
+     </varlistentry>
+
     </variablelist>
    </para>
   </sect2>
@@ -10020,6 +10129,278 @@ void PQinitSSL(int do_ssl);
 
  </sect1>
 
+ <sect1 id="libpq-oauth">
+  <title>OAuth Support</title>
+
+  <para>
+   TODO
+  </para>
+
+  <sect2 id="libpq-oauth-authdata-hooks">
+   <title>Authdata Hooks</title>
+
+   <para>
+    The behavior of the OAuth flow may be modified or replaced by a client using
+    the following hook API:
+
+    <variablelist>
+     <varlistentry id="libpq-PQsetAuthDataHook">
+      <term><function>PQsetAuthDataHook</function><indexterm><primary>PQsetAuthDataHook</primary></indexterm></term>
+
+      <listitem>
+       <para>
+        Sets the <symbol>PGauthDataHook</symbol>, overriding
+        <application>libpq</application>'s handling of one or more aspects of
+        its OAuth client flow.
+<synopsis>
+void PQsetAuthDataHook(PQauthDataHook_type hook);
+</synopsis>
+        If <replaceable>hook</replaceable> is <literal>NULL</literal>, the
+        default handler will be reinstalled. Otherwise, the application passes
+        a pointer to a callback function with the signature:
+<programlisting>
+int hook_fn(PGauthData type, PGconn *conn, void *data);
+</programlisting>
+        which <application>libpq</application> will call when when action is
+        required of the application. <replaceable>type</replaceable> describes
+        the request being made, <replaceable>conn</replaceable> is the
+        connection handle being authenticated, and <replaceable>data</replaceable>
+        points to request-specific metadata. The contents of this pointer are
+        determined by <replaceable>type</replaceable>; see
+        <xref linkend="libpq-oauth-authdata-hooks-types"/> for the supported
+        list.
+       </para>
+       <para>
+        Hooks can be chained together to allow cooperative and/or fallback
+        behavior. In general, a hook implementation should examine the incoming
+        <replaceable>type</replaceable> (and, potentially, the request metadata
+        and/or the settings for the particular <replaceable>conn</replaceable>
+        in use) to decide whether or not to handle a specific piece of authdata.
+        If not, it should delegate to the previous hook in the chain
+        (retrievable via <function>PQgetAuthDataHook</function>).
+       </para>
+       <para>
+        Success is indicated by returning an integer greater than zero.
+        Returning a negative integer signals an error condition and abandons the
+        connection attempt. (A zero value is reserved for the default
+        implementation.)
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="libpq-PQgetAuthDataHook">
+      <term><function>PQgetAuthDataHook</function><indexterm><primary>PQgetAuthDataHook</primary></indexterm></term>
+
+      <listitem>
+       <para>
+        Retrieves the current value of <symbol>PGauthDataHook</symbol>.
+<synopsis>
+PQauthDataHook_type PQgetAuthDataHook(void);
+</synopsis>
+        At initialization time (before the first call to
+        <function>PQsetAuthDataHook</function>), this function will return
+        <symbol>PQdefaultAuthDataHook</symbol>.
+       </para>
+      </listitem>
+     </varlistentry>
+    </variablelist>
+   </para>
+
+   <sect3 id="libpq-oauth-authdata-hooks-types">
+    <title>Hook Types</title>
+    <para>
+     The following <symbol>PGauthData</symbol> types and their corresponding
+     <replaceable>data</replaceable> structures are defined:
+
+     <variablelist>
+      <varlistentry id="libpq-oauth-authdata-prompt-oauth-device">
+       <term>
+        <symbol>PQAUTHDATA_PROMPT_OAUTH_DEVICE</symbol>
+        <indexterm><primary>PQAUTHDATA_PROMPT_OAUTH_DEVICE</primary></indexterm>
+       </term>
+       <listitem>
+        <para>
+         Replaces the default user prompt during the builtin device
+         authorization client flow. <replaceable>data</replaceable> points to
+         an instance of <symbol>PGpromptOAuthDevice</symbol>:
+<synopsis>
+typedef struct _PGpromptOAuthDevice
+{
+    const char *verification_uri;   /* verification URI to visit */
+    const char *user_code;          /* user code to enter */
+} PGpromptOAuthDevice;
+</synopsis>
+        </para>
+        <para>
+         The OAuth Device Authorization flow included in <application>libpq</application>
+         requires the end user to visit a URL with a browser, then enter a code
+         which permits <application>libpq</application> to connect to the server
+         on their behalf. The default prompt simply prints the
+         <literal>verification_uri</literal> and <literal>user_code</literal>
+         on standard error. Replacement implementations may display this
+         information using any preferred method, for example with a GUI.
+        </para>
+        <para>
+         This callback is only invoked during the builtin device
+         authorization flow. If the application installs a
+         <link linkend="libpq-oauth-authdata-oauth-bearer-token">custom OAuth
+         flow</link>, this authdata type will not be used.
+        </para>
+       </listitem>
+      </varlistentry>
+
+      <varlistentry id="libpq-oauth-authdata-oauth-bearer-token">
+       <term>
+        <symbol>PQAUTHDATA_OAUTH_BEARER_TOKEN</symbol>
+        <indexterm><primary>PQAUTHDATA_OAUTH_BEARER_TOKEN</primary></indexterm>
+       </term>
+       <listitem>
+        <para>
+         Replaces the entire OAuth flow with a custom implementation. The hook
+         should either directly return a Bearer token for the current
+         user/issuer/scope combination, if one is available without blocking, or
+         else set up an asynchronous callback to retrieve one.
+        </para>
+        <para>
+         <replaceable>data</replaceable> points to an instance
+         of <symbol>PGoauthBearerRequest</symbol>, which should be filled in
+         by the implementation:
+<synopsis>
+typedef struct _PGoauthBearerRequest
+{
+    /* Hook inputs (constant across all calls) */
+    const char *const openid_configuration; /* OIDC discovery URL */
+    const char *const scope;                /* required scope(s), or NULL */
+
+    /* Hook outputs */
+
+    /* Callback implementing a custom asynchronous OAuth flow. */
+    PostgresPollingStatusType (*async) (PGconn *conn,
+                                        struct _PGoauthBearerRequest *request,
+                                        SOCKTYPE *altsock);
+
+    /* Callback to clean up custom allocations. */
+    void        (*cleanup) (PGconn *conn, struct _PGoauthBearerRequest *request);
+
+    char       *token;   /* acquired Bearer token */
+    void       *user;    /* hook-defined allocated data */
+} PGoauthBearerRequest;
+</synopsis>
+        </para>
+        <para>
+         Two pieces of information are provided to the hook by
+         <application>libpq</application>:
+         <replaceable>openid_configuration</replaceable> contains the URL of an
+         OAuth discovery document describing the authorization server's
+         supported flows, and <replaceable>scope</replaceable> contains a
+         (possibly empty) space-separated list of OAuth scopes which are
+         required to access the server. Either or both may be
+         <literal>NULL</literal> to indicate that the information was not
+         discoverable. (In this case, implementations may be able to establish
+         the requirements using some other preconfigured knowledge, or they may
+         choose to fail.)
+        </para>
+        <para>
+         The final output of the hook is <replaceable>token</replaceable>, which
+         must point to a valid Bearer token for use on the connection. (This
+         token should be issued by the
+         <xref linkend="libpq-connect-oauth-issuer"/> and hold the requested
+         scopes, or the connection will be rejected by the server's validator
+         module.) The allocated token string must remain valid until
+         <application>libpq</application> is finished connecting; the hook
+         should set a <replaceable>cleanup</replaceable> callback which will be
+         called when <application>libpq</application> no longer requires it.
+        </para>
+        <para>
+         If an implementation cannot immediately produce a
+         <replaceable>token</replaceable> during the initial call to the hook,
+         it should set the <replaceable>async</replaceable> callback to handle
+         nonblocking communication with the authorization server.
+         <footnote>
+          <para>
+           Performing blocking operations during the
+           <symbol>PQAUTHDATA_OAUTH_BEARER_TOKEN</symbol> hook callback will
+           interfere with nonblocking connection APIs such as
+           <function>PQconnectPoll</function> and prevent concurrent connections
+           from making progress. Applications which only ever use the
+           synchronous connection primitives, such as
+           <function>PQconnectdb</function>, may synchronously retrieve a token
+           during the hook instead of implementing the
+           <replaceable>async</replaceable> callback, but they will necessarily
+           be limited to one connection at a time.
+          </para>
+         </footnote>
+         This will be called to begin the flow immediately upon return from the
+         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
+         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>
+         after setting <replaceable>token</replaceable> when the flow is
+         complete, or <symbol>PGRES_POLLING_FAILED</symbol> to indicate failure.
+        </para>
+        <para>
+         Implementations may wish to store additional data for bookkeeping
+         across calls to the <replaceable>async</replaceable> and
+         <replaceable>cleanup</replaceable> callbacks. The
+         <replaceable>user</replaceable> pointer is provided for this purpose;
+         <application>libpq</application> will not touch its contents and the
+         application may use it at its convenience. (Remember to free any
+         allocations during token cleanup.)
+        </para>
+       </listitem>
+      </varlistentry>
+     </variablelist>
+    </para>
+   </sect3>
+  </sect2>
+
+  <sect2 id="libpq-oauth-debugging">
+   <title>Debugging and Developer Settings</title>
+
+   <para>
+    A "dangerous debugging mode" may be enabled by setting the environment
+    variable <envar>PGOAUTHDEBUG=UNSAFE</envar>. This functionality is provided
+    for ease of local development and testing only. It does several things that
+    you will not want a production system to do:
+
+    <itemizedlist spacing="compact">
+     <listitem>
+      <para>
+       permits the use of unencrypted HTTP during the OAuth provider exchange
+      </para>
+     </listitem>
+     <listitem>
+      <para>
+       allows the system's trusted CA list to be completely replaced using the
+       <envar>PGOAUTHCAFILE</envar> environment variable
+      </para>
+     </listitem>
+     <listitem>
+      <para>
+       sprays HTTP traffic (containing several critical secrets) to standard
+       error during the OAuth flow
+      </para>
+     </listitem>
+     <listitem>
+      <para>
+       permits the use of zero-second retry intervals, which can cause the
+       client to busy-loop and pointlessly consume CPU
+      </para>
+     </listitem>
+    </itemizedlist>
+   </para>
+   <warning>
+    <para>
+     Do not share the output of the OAuth flow traffic with third parties. It
+     contains secrets that can be used to attack your clients and servers.
+    </para>
+   </warning>
+  </sect2>
+ </sect1>
+
 
  <sect1 id="libpq-threading">
   <title>Behavior in Threaded Programs</title>
@@ -10092,6 +10473,18 @@ int PQisthreadsafe();
    <application>libpq</application> source code for a way to do cooperative
    locking between <application>libpq</application> and your application.
   </para>
+
+  <para>
+   Similarly, if you are using Curl inside your application,
+   <emphasis>and</emphasis> you do not already
+   <ulink url="https://curl.se/libcurl/c/curl_global_init.html">initialize
+   libcurl globally</ulink> before starting new threads, you will need to
+   cooperatively lock (again via <function>PQregisterThreadLock</function>)
+   around any code that may initialize libcurl. This restriction is lifted for
+   more recent versions of Curl that are built to support threadsafe
+   initialization; those builds can be identified by the advertisement of a
+   <literal>threadsafe</literal> feature in their version metadata.
+  </para>
  </sect1>
 
 
diff --git a/doc/src/sgml/oauth-validators.sgml b/doc/src/sgml/oauth-validators.sgml
new file mode 100644
index 00000000000..d0bca9196d9
--- /dev/null
+++ b/doc/src/sgml/oauth-validators.sgml
@@ -0,0 +1,402 @@
+<!-- doc/src/sgml/oauth-validators.sgml -->
+
+<chapter id="oauth-validators">
+ <title>OAuth Validator Modules</title>
+ <indexterm zone="oauth-validators">
+  <primary>OAuth Validators</primary>
+ </indexterm>
+ <para>
+  <productname>PostgreSQL</productname> provides infrastructure for creating
+  custom modules to perform server-side validation of OAuth bearer tokens.
+  Because OAuth implementations vary so wildly, and bearer token validation is
+  heavily dependent on the issuing party, the server cannot check the token
+  itself; validator modules provide the glue between the server and the OAuth
+  provider in use.
+ </para>
+ <para>
+  OAuth validator modules must at least consist of an initialization function
+  (see <xref linkend="oauth-validator-init"/>) and the required callback for
+  performing validation (see <xref linkend="oauth-validator-callback-validate"/>).
+ </para>
+ <warning>
+  <para>
+   Since a misbehaving validator might let unauthorized users into the database,
+   correct implementation is critical. See
+   <xref linkend="oauth-validator-design"/> for design considerations.
+  </para>
+ </warning>
+
+ <sect1 id="oauth-validator-design">
+  <title>Safely Designing a Validator Module</title>
+  <warning>
+   <para>
+    Read and understand the entirety of this section before implementing a
+    validator module. A malfunctioning validator is potentially worse than no
+    authentication at all, both because of the false sense of security it
+    provides, and because it may contribute to attacks against other pieces of
+    an OAuth ecosystem.
+   </para>
+  </warning>
+
+  <sect2 id="oauth-validator-design-responsibilities">
+   <title>Validator Responsibilities</title>
+   <para>
+    TODO
+   </para>
+   <variablelist>
+    <varlistentry>
+     <term>Validate the Token</term>
+     <listitem>
+      <para>
+       The validator must first ensure that the presented token is in fact a
+       valid Bearer token for use in client authentication. The correct way to
+       do this depends on the provider, but it generally involves either
+       cryptographic operations to prove that the token was created by a trusted
+       party (offline validation), or the presentation of the token to that
+       trusted party so that it can perform validation for you (online
+       validation).
+      </para>
+      <para>
+       Online validation, usually implemented via
+       <ulink url="https://datatracker.ietf.org/doc/html/rfc7662">OAuth Token
+       Introspection</ulink>, requires fewer steps of a validator module and
+       allows central revocation of a token in the event that it is stolen
+       or misissued. However, it does require the module to make at least one
+       network call per authentication attempt (all of which must complete
+       within the configured <xref linkend="guc-authentication-timeout"/>).
+       Additionally, your provider may not provide introspection endpoints for
+       use by external resource servers.
+      </para>
+      <para>
+       Offline validation is much more involved, typically requiring a validator
+       to maintain a list of trusted signing keys for a provider and then
+       check the token's cryptographic signature along with its contents.
+       Implementations must follow the provider's instructions to the letter,
+       including any verification of issuer ("where is this token from?"),
+       audience ("who is this token for?"), and validity period ("when can this
+       token be used?"). Since there is no communication between the module and
+       the provider, tokens cannot be centrally revoked using this method;
+       offline validator implementations may wish to place restrictions on the
+       maximum length of a token's validity period.
+      </para>
+      <para>
+       If the token cannot be validated, the module should immediately fail.
+       Further authentication/authorization is pointless if the bearer token
+       wasn't issued by a trusted party.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Authorize the Client</term>
+     <listitem>
+      <para>
+       Next the validator must ensure that the end user has given the client
+       permission to access the server on their behalf. This generally involves
+       checking the scopes that have been assigned to the token, to make sure
+       that they cover database access for the current HBA parameters.
+      </para>
+      <para>
+       The purpose of this step is to prevent an OAuth client from obtaining a
+       token under false pretenses. If the validator requires all tokens to
+       carry scopes that cover database access, the provider should then loudly
+       prompt the user to grant that access during the flow. This gives them the
+       opportunity to reject the request if the client isn't supposed to be
+       using their credentials to connect to databases.
+      </para>
+      <para>
+       While it is possible to establish client authorization without explicit
+       scopes by using out-of-band knowledge of the deployed architecture, doing
+       so removes the user from the loop, which prevents them from catching
+       deployment mistakes and allows any such mistakes to be exploited
+       silently. Access to the database must be tightly restricted to only
+       trusted clients
+       <footnote>
+        <para>
+         That is, "trusted" in the sense that the OAuth client and the
+         <productname>PostgreSQL</productname> server are controlled by the same
+         entity. Notably, the Device Authorization client flow supported by
+         libpq does not usually meet this bar, since it's designed for use by
+         public/untrusted clients.
+        </para>
+       </footnote>
+       if users are not prompted for additional scopes.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Authenticate the End User</term>
+     <listitem>
+      <para>
+       Finally, the validator should determine a user identifier for the token,
+       either by asking the provider for this information or by extracting it
+       from the token itself, and return that identifier to the server (which
+       will then make a final authorization decision using the HBA
+       configuration). This identifier will be available within the session via
+       <link linkend="functions-info-session-table"><function>system_user</function></link>
+       and recorded in the server logs if <xref linkend="guc-log-connections"/>
+       is enabled.
+      </para>
+      <para>
+       Different providers may record a variety of different authentication
+       information for an end user, typically referred to as
+       <emphasis>claims</emphasis>. Providers usually document which of these
+       claims are trustworthy enough to use for authorization decisions and
+       which are not. (For instance, it would probably not be wise to use an
+       end user's full name as the identifier for authentication, since many
+       providers allow users to change their display names arbitrarily.)
+       Ultimately, the choice of which claim (or combination of claims) to use
+       comes down to the provider implementation and application requirements.
+      </para>
+      <para>
+       Note that anonymous/pseudonymous login is possible as well, by enabling
+       usermap delegation; see
+       <xref linkend="oauth-validator-design-usermap-delegation"/>.
+      </para>
+     </listitem>
+    </varlistentry>
+   </variablelist>
+  </sect2>
+
+  <sect2 id="oauth-validator-design-guidelines">
+   <title>General Coding Guidelines</title>
+   <para>
+    Developers should keep the following in mind when implementing token
+    validation:
+   </para>
+   <variablelist>
+    <varlistentry>
+     <term>Token Confidentiality</term>
+     <listitem>
+      <para>
+       Modules should not write tokens, or pieces of tokens, into the server
+       log. This is true even if the module considers the token invalid; an
+       attacker who confuses a client into communicating with the wrong provider
+       should not be able to retrieve that (otherwise valid) token from the
+       disk.
+      </para>
+      <para>
+       Implementations that send tokens over the network (for example, to
+       perform online token validation with a provider) must authenticate the
+       peer and ensure that strong transport security is in use.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Logging</term>
+     <listitem>
+      <para>
+       Modules may use the same <link linkend="error-message-reporting">logging
+       facilities</link> as standard extensions; however, the rules for emitting
+       log entries to the client are subtly different during the authentication
+       phase of the connection. Generally speaking, modules should log
+       verification problems at the <symbol>COMMERROR</symbol> level and return
+       normally, instead of using <symbol>ERROR</symbol>/<symbol>FATAL</symbol>
+       to unwind the stack, to avoid leaking information to unauthenticated
+       clients.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Interruptibility</term>
+     <listitem>
+      <para>
+       Modules must remain interruptible by signals so that the server can
+       correctly handle authentication timeouts and shutdown signals from
+       <application>pg_ctl</application>. For example, a module receiving
+       <symbol>EINTR</symbol>/<symbol>EAGAIN</symbol> from a blocking call
+       should call <function>CHECK_FOR_INTERRUPTS()</function> before retrying.
+       The same should be done during any long-running loops. Failure to follow
+       this guidance may result in unresponsive backend sessions.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Testing</term>
+     <listitem>
+      <para>
+       The breadth of testing an OAuth system is well beyond the scope of this
+       documentation, but at minimum, negative testing should be considered
+       mandatory. It's trivial to design a module that lets authorized users in;
+       the whole point of the system is to keep unauthorized users out.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Documentation</term>
+     <listitem>
+      <para>
+       Validator implementations should document the contents and format of the
+       authenticated ID that is reported to the server for each end user, since
+       DBAs may need to use this information to construct pg_ident maps. (For
+       instance, is it an email address? an organizational ID number? a UUID?)
+       They should also document whether or not it is safe to use the module in
+       <symbol>delegate_ident_mapping=1</symbol> mode, and what additional
+       configuration is required in order to do so.
+      </para>
+     </listitem>
+    </varlistentry>
+   </variablelist>
+  </sect2>
+
+  <sect2 id="oauth-validator-design-usermap-delegation">
+   <title>Authorizing Users (Usermap Delegation)</title>
+   <para>
+    The standard deliverable of a validation module is the user identifier,
+    which the server will then compare to any configured
+    <link linkend="auth-username-maps"><filename>pg_ident.conf</filename>
+    mappings</link> and determine whether the end user is authorized to connect.
+    However, OAuth is itself an authorization framework, and tokens may carry
+    information about user privileges. For example, a token may be associated
+    with the organizational groups that a user belongs to, or list the roles
+    that a user may assume, and duplicating that knowledge into local usermaps
+    for every server may not be desirable.
+   </para>
+   <para>
+    To bypass username mapping entirely, and have the validator module assume
+    the additional responsibility of authorizing user connections, the HBA may
+    be configured with <xref linkend="auth-oauth-delegate-ident-mapping"/>.
+    The module may then use token scopes or an equivalent method to decide
+    whether the user is allowed to connect under their desired role. The user
+    identifier will still be recorded by the server, but it plays no part in
+    determining whether to continue the connection.
+   </para>
+   <para>
+    Using this scheme, authentication itself is optional. As long as the module
+    reports that the connection is authorized, login will continue even if there
+    is no recorded user identifier at all. This makes it possible to implement
+    anonymous or pseudonymous access to the database, where the third-party
+    provider performs all necessary authentication but does not provide any
+    user-identifying information to the server. (Some providers may create an
+    anonymized ID number that can be recorded instead, for later auditing.)
+   </para>
+   <para>
+    Usermap delegation provides the most architectural flexibility, but it turns
+    the validator module into a single point of failure for connection
+    authorization. Use with caution.
+   </para>
+  </sect2>
+ </sect1>
+
+ <sect1 id="oauth-validator-init">
+  <title>Initialization Functions</title>
+  <indexterm zone="oauth-validator-init">
+   <primary>_PG_oauth_validator_module_init</primary>
+  </indexterm>
+  <para>
+   An OAuth validator module is loaded by dynamically loading one of the shared
+   libraries listed in <xref linkend="guc-oauth-validator-libraries"/>.
+   The normal library search path is used to locate the library. To
+   provide the validator callbacks and to indicate that the library is an OAuth
+   validator module a function named
+   <function>_PG_oauth_validator_module_init</function> must be provided. The
+   return value of the function must be a pointer to a struct of type
+   <structname>OAuthValidatorCallbacks</structname>, which contains pointers to
+   the module's token validation functions. The returned
+   pointer must be of server lifetime, which is typically achieved by defining
+   it as a <literal>static const</literal> variable in global scope.
+<programlisting>
+typedef struct OAuthValidatorCallbacks
+{
+    ValidatorStartupCB startup_cb;
+    ValidatorShutdownCB shutdown_cb;
+    ValidatorValidateCB validate_cb;
+} OAuthValidatorCallbacks;
+
+typedef const OAuthValidatorCallbacks *(*OAuthValidatorModuleInit) (void);
+</programlisting>
+
+   Only the <function>validate_cb</function> callback is required, the others
+   are optional.
+  </para>
+ </sect1>
+
+ <sect1 id="oauth-validator-callbacks">
+  <title>OAuth Validator Callbacks</title>
+  <para>
+   OAuth validator modules implement their functionality by defining a set of
+   callbacks. The server will call them as required to process the
+   authentication request from the user.
+  </para>
+
+  <sect2 id="oauth-validator-callback-startup">
+   <title>Startup Callback</title>
+   <para>
+    The <function>startup_cb</function> callback is executed directly after
+    loading the module. This callback can be used to set up local state and
+    perform additional initialization if required. If the validator module
+    has state it can use <structfield>state->private_data</structfield> to
+    store it.
+
+<programlisting>
+typedef void (*ValidatorStartupCB) (ValidatorModuleState *state);
+</programlisting>
+   </para>
+  </sect2>
+
+  <sect2 id="oauth-validator-callback-validate">
+   <title>Validate Callback</title>
+   <para>
+    The <function>validate_cb</function> callback is executed during the OAuth
+    exchange when a user attempts to authenticate using OAuth.  Any state set in
+    previous calls will be available in <structfield>state->private_data</structfield>.
+
+<programlisting>
+typedef ValidatorModuleResult *(*ValidatorValidateCB) (ValidatorModuleState *state, const char *token, const char *role);
+</programlisting>
+
+    <replaceable>token</replaceable> will contain the bearer token to validate.
+    The server has ensured that the token is well-formed syntactically, but no
+    other validation has been performed.  <replaceable>role</replaceable> will
+    contain the role the user has requested to log in as.  The callback must
+    return a palloc'd <literal>ValidatorModuleResult</literal> struct, which is
+    defined as below:
+
+<programlisting>
+typedef struct ValidatorModuleResult
+{
+    bool        authorized;
+    char       *authn_id;
+} ValidatorModuleResult;
+</programlisting>
+
+    The connection will only proceed if the module sets
+    <structfield>authorized</structfield> to <literal>true</literal>.  To
+    authenticate the user, the authenticated user name (as determined using the
+    token) shall be palloc'd and returned in the <structfield>authn_id</structfield>
+    field.  Alternatively, <structfield>authn_id</structfield> may be set to
+    NULL if the token is valid but the associated user identity cannot be
+    determined.
+   </para>
+   <para>
+    The caller assumes ownership of the returned memory allocation, the
+    validator module should not in any way access the memory after it has been
+    returned.  A validator may instead return NULL to signal an internal
+    error.
+   </para>
+   <para>
+    The behavior after <function>validate_cb</function> returns depends on the
+    specific HBA setup.  Normally, the <structfield>authn_id</structfield> user
+    name must exactly match the role that the user is logging in as.  (This
+    behavior may be modified with a usermap.)  But when authenticating against
+    an HBA rule with <literal>trust_validator_authz</literal> turned on, the
+    server will not perform any checks on the value of
+    <structfield>authn_id</structfield> at all; in this case it is up to the
+    validator to ensure that the token carries enough privileges for the user to
+    log in under the indicated <replaceable>role</replaceable>.
+   </para>
+  </sect2>
+
+  <sect2 id="oauth-validator-callback-shutdown">
+   <title>Shutdown Callback</title>
+   <para>
+    The <function>shutdown_cb</function> callback is executed when the backend
+    process associated with the connection exits. If the validator module has
+    any state, this callback should free it to avoid resource leaks.
+<programlisting>
+typedef void (*ValidatorShutdownCB) (ValidatorModuleState *state);
+</programlisting>
+   </para>
+  </sect2>
+
+ </sect1>
+</chapter>
diff --git a/doc/src/sgml/postgres.sgml b/doc/src/sgml/postgres.sgml
index 7be25c58507..af476c82fcc 100644
--- a/doc/src/sgml/postgres.sgml
+++ b/doc/src/sgml/postgres.sgml
@@ -229,6 +229,7 @@ break is not needed in a wider output rendering.
   &logicaldecoding;
   &replication-origins;
   &archive-modules;
+  &oauth-validators;
 
  </part>
 
diff --git a/doc/src/sgml/protocol.sgml b/doc/src/sgml/protocol.sgml
index fb5dec1172e..3bd9e68e6ce 100644
--- a/doc/src/sgml/protocol.sgml
+++ b/doc/src/sgml/protocol.sgml
@@ -1688,11 +1688,11 @@ SELCT 1/0;<!-- this typo is intentional -->
 
   <para>
    <firstterm>SASL</firstterm> is a framework for authentication in connection-oriented
-   protocols. At the moment, <productname>PostgreSQL</productname> implements two SASL
-   authentication mechanisms, SCRAM-SHA-256 and SCRAM-SHA-256-PLUS. More
-   might be added in the future. The below steps illustrate how SASL
-   authentication is performed in general, while the next subsection gives
-   more details on SCRAM-SHA-256 and SCRAM-SHA-256-PLUS.
+   protocols. At the moment, <productname>PostgreSQL</productname> implements three
+   SASL authentication mechanisms: SCRAM-SHA-256, SCRAM-SHA-256-PLUS, and
+   OAUTHBEARER. More might be added in the future. The below steps illustrate how SASL
+   authentication is performed in general, while the next subsections give
+   more details on particular mechanisms.
   </para>
 
   <procedure>
@@ -1727,7 +1727,7 @@ SELCT 1/0;<!-- this typo is intentional -->
    <step id="sasl-auth-end">
     <para>
      Finally, when the authentication exchange is completed successfully, the
-     server sends an AuthenticationSASLFinal message, followed
+     server sends an optional AuthenticationSASLFinal message, followed
      immediately by an AuthenticationOk message. The AuthenticationSASLFinal
      contains additional server-to-client data, whose content is particular to the
      selected authentication mechanism. If the authentication mechanism doesn't
@@ -1746,9 +1746,9 @@ SELCT 1/0;<!-- this typo is intentional -->
    <title>SCRAM-SHA-256 Authentication</title>
 
    <para>
-    The implemented SASL mechanisms at the moment
-    are <literal>SCRAM-SHA-256</literal> and its variant with channel
-    binding <literal>SCRAM-SHA-256-PLUS</literal>. They are described in
+    <literal>SCRAM-SHA-256</literal>, and its variant with channel
+    binding <literal>SCRAM-SHA-256-PLUS</literal>, are password-based
+    authentication mechanisms. They are described in
     detail in <ulink url="https://datatracker.ietf.org/doc/html/rfc7677">RFC 7677</ulink>
     and <ulink url="https://datatracker.ietf.org/doc/html/rfc5802">RFC 5802</ulink>.
    </para>
@@ -1850,6 +1850,121 @@ SELCT 1/0;<!-- this typo is intentional -->
     </step>
    </procedure>
   </sect2>
+
+  <sect2 id="sasl-oauthbearer">
+   <title>OAUTHBEARER Authentication</title>
+
+   <para>
+    <literal>OAUTHBEARER</literal> is a token-based mechanism for federated
+    authentication. It is described in detail in
+    <ulink url="https://datatracker.ietf.org/doc/html/rfc7628">RFC 7628</ulink>.
+   </para>
+
+   <para>
+    A typical exchange differs depending on whether or not the client already
+    has a bearer token cached for the current user. If it does not, the exchange
+    will take place over two connections: the first "discovery" connection to
+    obtain OAuth metadata from the server, and the second connection to send
+    the token after the client has obtained it. (libpq does not currently
+    implement a caching method as part of its builtin flow, so it uses the
+    two-connection exchange.)
+   </para>
+
+   <para>
+    This mechanism is client-initiated, like SCRAM. The client initial response
+    consists of the standard "GS2" header used by SCRAM, followed by a list of
+    <literal>key=value</literal> pairs. The only key currently supported by
+    the server is <literal>auth</literal>, which contains the bearer token.
+    <literal>OAUTHBEARER</literal> additionally specifies three optional
+    components of the client initial response (the <literal>authzid</literal> of
+    the GS2 header, and the <structfield>host</structfield> and
+    <structfield>port</structfield> keys) which are currently ignored by the
+    server.
+   </para>
+
+   <para>
+    <literal>OAUTHBEARER</literal> does not support channel binding, and there
+    is no "OAUTHBEARER-PLUS" mechanism. This mechanism does not make use of
+    server data during a successful authentication, so the
+    AuthenticationSASLFinal message is not used in the exchange.
+   </para>
+
+   <procedure>
+    <title>Example</title>
+    <step>
+     <para>
+      During the first exchange, the server sends an AuthenticationSASL message
+      with the <literal>OAUTHBEARER</literal> mechanism advertised.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      The client responds by sending a SASLInitialResponse message which
+      indicates the <literal>OAUTHBEARER</literal> mechanism. Assuming the
+      client does not already have a valid bearer token for the current user,
+      the <structfield>auth</structfield> field is empty, indicating a discovery
+      connection.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      Server sends an AuthenticationSASLContinue message containing an error
+      <literal>status</literal> alongside a well-known URI and scopes that the
+      client should use to conduct an OAuth flow.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      Client sends a SASLResponse message containing the empty set (a single
+      <literal>0x01</literal> byte) to finish its half of the discovery
+      exchange.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      Server sends an ErrorMessage to fail the first exchange.
+     </para>
+     <para>
+      At this point, the client conducts one of many possible OAuth flows to
+      obtain a bearer token, using any metadata that it has been configured with
+      in addition to that provided by the server. (This description is left
+      deliberately vague; <literal>OAUTHBEARER</literal> does not specify or
+      mandate any particular method for obtaining a token.)
+     </para>
+     <para>
+      Once it has a token, the client reconnects to the server for the final
+      exchange:
+     </para>
+    </step>
+
+    <step>
+     <para>
+      The server once again sends an AuthenticationSASL message with the
+      <literal>OAUTHBEARER</literal> mechanism advertised.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      The client responds by sending a SASLInitialResponse message, but this
+      time the <structfield>auth</structfield> field in the message contains the
+      bearer token that was obtained during the client flow.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      The server validates the token according to the instructions of the
+      token provider. If the client is authorized to connect, it sends an
+      AuthenticationOk message to end the SASL exchange.
+     </para>
+    </step>
+   </procedure>
+  </sect2>
  </sect1>
 
  <sect1 id="protocol-replication">
diff --git a/doc/src/sgml/regress.sgml b/doc/src/sgml/regress.sgml
index 7c474559bdf..0e5e8e8f309 100644
--- a/doc/src/sgml/regress.sgml
+++ b/doc/src/sgml/regress.sgml
@@ -347,6 +347,16 @@ make check-world PG_TEST_EXTRA='kerberos ldap ssl load_balance libpq_encryption'
       </para>
      </listitem>
     </varlistentry>
+
+    <varlistentry>
+     <term><literal>oauth</literal></term>
+     <listitem>
+      <para>
+       Runs the test suite under <filename>src/test/modules/oauth_validator</filename>.
+       This opens TCP/IP listen sockets for a test-server running HTTPS.
+      </para>
+     </listitem>
+    </varlistentry>
    </variablelist>
 
    Tests for features that are not supported by the current build
diff --git a/meson.build b/meson.build
index 8e128f4982a..3b35f1f0c9e 100644
--- a/meson.build
+++ b/meson.build
@@ -854,6 +854,67 @@ endif
 
 
 
+###############################################################
+# Library: libcurl
+###############################################################
+
+libcurlopt = get_option('libcurl')
+if not libcurlopt.disabled()
+  # Check for libcurl 7.61.0 or higher (corresponding to RHEL8 and the ability
+  # to explicitly set TLS 1.3 ciphersuites).
+  libcurl = dependency('libcurl', version: '>= 7.61.0', required: libcurlopt)
+  if libcurl.found()
+    cdata.set('USE_LIBCURL', 1)
+
+    # Check to see whether the current platform supports threadsafe Curl
+    # initialization.
+    libcurl_threadsafe_init = false
+
+    if not meson.is_cross_build()
+      r = cc.run('''
+        #include <curl/curl.h>
+
+        int main(void)
+        {
+            curl_version_info_data *info;
+
+            if (curl_global_init(CURL_GLOBAL_ALL))
+                return -1;
+
+            info = curl_version_info(CURLVERSION_NOW);
+        #ifdef CURL_VERSION_THREADSAFE
+            if (info->features & CURL_VERSION_THREADSAFE)
+                return 0;
+        #endif
+
+            return 1;
+        }''',
+        name: 'test for curl_global_init thread safety',
+        dependencies: libcurl,
+      )
+
+      assert(r.compiled())
+      if r.returncode() == 0
+        libcurl_threadsafe_init = true
+        message('curl_global_init is threadsafe')
+      elif r.returncode() == 1
+        message('curl_global_init is not threadsafe')
+      else
+        message('curl_global_init failed; assuming not threadsafe')
+      endif
+    endif
+
+    if libcurl_threadsafe_init
+      cdata.set('HAVE_THREADSAFE_CURL_GLOBAL_INIT', 1)
+    endif
+  endif
+
+else
+  libcurl = not_found_dep
+endif
+
+
+
 ###############################################################
 # Library: libxml
 ###############################################################
@@ -3034,6 +3095,10 @@ libpq_deps += [
 
   gssapi,
   ldap_r,
+  # XXX libcurl must link after libgssapi_krb5 on FreeBSD to avoid segfaults
+  # during gss_acquire_cred(). This is possibly related to Curl's Heimdal
+  # dependency on that platform?
+  libcurl,
   libintl,
   ssl,
 ]
@@ -3704,6 +3769,7 @@ if meson.version().version_compare('>=0.57')
       'gss': gssapi,
       'icu': icu,
       'ldap': ldap,
+      'libcurl': libcurl,
       'libxml': libxml,
       'libxslt': libxslt,
       'llvm': llvm,
diff --git a/meson_options.txt b/meson_options.txt
index d9c7ddccbc4..702c4517145 100644
--- a/meson_options.txt
+++ b/meson_options.txt
@@ -100,6 +100,9 @@ option('icu', type: 'feature', value: 'auto',
 option('ldap', type: 'feature', value: 'auto',
   description: 'LDAP support')
 
+option('libcurl', type : 'feature', value: 'auto',
+  description: 'libcurl support')
+
 option('libedit_preferred', type: 'boolean', value: false,
   description: 'Prefer BSD Libedit over GNU Readline')
 
diff --git a/src/Makefile.global.in b/src/Makefile.global.in
index bbe11e75bf0..3b620bac5ac 100644
--- a/src/Makefile.global.in
+++ b/src/Makefile.global.in
@@ -190,6 +190,7 @@ with_systemd	= @with_systemd@
 with_gssapi	= @with_gssapi@
 with_krb_srvnam	= @with_krb_srvnam@
 with_ldap	= @with_ldap@
+with_libcurl	= @with_libcurl@
 with_libxml	= @with_libxml@
 with_libxslt	= @with_libxslt@
 with_llvm	= @with_llvm@
diff --git a/src/backend/libpq/Makefile b/src/backend/libpq/Makefile
index 6d385fd6a45..98eb2a8242d 100644
--- a/src/backend/libpq/Makefile
+++ b/src/backend/libpq/Makefile
@@ -15,6 +15,7 @@ include $(top_builddir)/src/Makefile.global
 # be-fsstubs is here for historical reasons, probably belongs elsewhere
 
 OBJS = \
+	auth-oauth.o \
 	auth-sasl.o \
 	auth-scram.o \
 	auth.o \
diff --git a/src/backend/libpq/auth-oauth.c b/src/backend/libpq/auth-oauth.c
new file mode 100644
index 00000000000..6155d63a116
--- /dev/null
+++ b/src/backend/libpq/auth-oauth.c
@@ -0,0 +1,860 @@
+/*-------------------------------------------------------------------------
+ *
+ * auth-oauth.c
+ *	  Server-side implementation of the SASL OAUTHBEARER mechanism.
+ *
+ * See the following RFC for more details:
+ * - RFC 7628: https://tools.ietf.org/html/rfc7628
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/backend/libpq/auth-oauth.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include <unistd.h>
+#include <fcntl.h>
+
+#include "common/oauth-common.h"
+#include "fmgr.h"
+#include "lib/stringinfo.h"
+#include "libpq/auth.h"
+#include "libpq/hba.h"
+#include "libpq/oauth.h"
+#include "libpq/sasl.h"
+#include "storage/fd.h"
+#include "storage/ipc.h"
+#include "utils/json.h"
+#include "utils/varlena.h"
+
+/* GUC */
+char	   *oauth_validator_libraries_string = NULL;
+
+static void oauth_get_mechanisms(Port *port, StringInfo buf);
+static void *oauth_init(Port *port, const char *selected_mech, const char *shadow_pass);
+static int	oauth_exchange(void *opaq, const char *input, int inputlen,
+						   char **output, int *outputlen, const char **logdetail);
+
+static void load_validator_library(const char *libname);
+static void shutdown_validator_library(int code, Datum arg);
+
+static ValidatorModuleState *validator_module_state;
+static const OAuthValidatorCallbacks *ValidatorCallbacks;
+
+/* Mechanism declaration */
+const pg_be_sasl_mech pg_be_oauth_mech = {
+	.get_mechanisms = oauth_get_mechanisms,
+	.init = oauth_init,
+	.exchange = oauth_exchange,
+
+	.max_message_length = PG_MAX_AUTH_TOKEN_LENGTH,
+};
+
+/* Valid states for the oauth_exchange() machine. */
+enum oauth_state
+{
+	OAUTH_STATE_INIT = 0,
+	OAUTH_STATE_ERROR,
+	OAUTH_STATE_FINISHED,
+};
+
+/* Mechanism callback state. */
+struct oauth_ctx
+{
+	enum oauth_state state;
+	Port	   *port;
+	const char *issuer;
+	const char *scope;
+};
+
+static char *sanitize_char(char c);
+static char *parse_kvpairs_for_auth(char **input);
+static void generate_error_response(struct oauth_ctx *ctx, char **output, int *outputlen);
+static bool validate(Port *port, const char *auth);
+
+/* Constants seen in an OAUTHBEARER client initial response. */
+#define KVSEP 0x01				/* separator byte for key/value pairs */
+#define AUTH_KEY "auth"			/* key containing the Authorization header */
+#define BEARER_SCHEME "Bearer " /* required header scheme (case-insensitive!) */
+
+/*
+ * Retrieves the OAUTHBEARER mechanism list (currently a single item).
+ *
+ * For a full description of the API, see libpq/sasl.h.
+ */
+static void
+oauth_get_mechanisms(Port *port, StringInfo buf)
+{
+	/* Only OAUTHBEARER is supported. */
+	appendStringInfoString(buf, OAUTHBEARER_NAME);
+	appendStringInfoChar(buf, '\0');
+}
+
+/*
+ * Initializes mechanism state and loads the configured validator module.
+ *
+ * For a full description of the API, see libpq/sasl.h.
+ */
+static void *
+oauth_init(Port *port, const char *selected_mech, const char *shadow_pass)
+{
+	struct oauth_ctx *ctx;
+
+	if (strcmp(selected_mech, OAUTHBEARER_NAME) != 0)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("client selected an invalid SASL authentication mechanism"));
+
+	ctx = palloc0(sizeof(*ctx));
+
+	ctx->state = OAUTH_STATE_INIT;
+	ctx->port = port;
+
+	Assert(port->hba);
+	ctx->issuer = port->hba->oauth_issuer;
+	ctx->scope = port->hba->oauth_scope;
+
+	load_validator_library(port->hba->oauth_validator);
+
+	return ctx;
+}
+
+/*
+ * Implements the OAUTHBEARER SASL exchange (RFC 7628, Sec. 3.2). This pulls
+ * apart the client initial response and validates the Bearer token. It also
+ * handles the dummy error response for a failed handshake, as described in
+ * Sec. 3.2.3.
+ *
+ * For a full description of the API, see libpq/sasl.h.
+ */
+static int
+oauth_exchange(void *opaq, const char *input, int inputlen,
+			   char **output, int *outputlen, const char **logdetail)
+{
+	char	   *input_copy;
+	char	   *p;
+	char		cbind_flag;
+	char	   *auth;
+	int			status;
+
+	struct oauth_ctx *ctx = opaq;
+
+	*output = NULL;
+	*outputlen = -1;
+
+	/*
+	 * If the client didn't include an "Initial Client Response" in the
+	 * SASLInitialResponse message, send an empty challenge, to which the
+	 * client will respond with the same data that usually comes in the
+	 * Initial Client Response.
+	 */
+	if (input == NULL)
+	{
+		Assert(ctx->state == OAUTH_STATE_INIT);
+
+		*output = pstrdup("");
+		*outputlen = 0;
+		return PG_SASL_EXCHANGE_CONTINUE;
+	}
+
+	/*
+	 * Check that the input length agrees with the string length of the input.
+	 */
+	if (inputlen == 0)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("The message is empty."));
+	if (inputlen != strlen(input))
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message length does not match input length."));
+
+	switch (ctx->state)
+	{
+		case OAUTH_STATE_INIT:
+			/* Handle this case below. */
+			break;
+
+		case OAUTH_STATE_ERROR:
+
+			/*
+			 * Only one response is valid for the client during authentication
+			 * failure: a single kvsep.
+			 */
+			if (inputlen != 1 || *input != KVSEP)
+				ereport(ERROR,
+						errcode(ERRCODE_PROTOCOL_VIOLATION),
+						errmsg("malformed OAUTHBEARER message"),
+						errdetail("Client did not send a kvsep response."));
+
+			/* The (failed) handshake is now complete. */
+			ctx->state = OAUTH_STATE_FINISHED;
+			return PG_SASL_EXCHANGE_FAILURE;
+
+		default:
+			elog(ERROR, "invalid OAUTHBEARER exchange state");
+			return PG_SASL_EXCHANGE_FAILURE;
+	}
+
+	/* Handle the client's initial message. */
+	p = input_copy = pstrdup(input);
+
+	/*
+	 * OAUTHBEARER does not currently define a channel binding (so there is no
+	 * OAUTHBEARER-PLUS, and we do not accept a 'p' specifier). We accept a
+	 * 'y' specifier purely for the remote chance that a future specification
+	 * could define one; then future clients can still interoperate with this
+	 * server implementation. 'n' is the expected case.
+	 */
+	cbind_flag = *p;
+	switch (cbind_flag)
+	{
+		case 'p':
+			ereport(ERROR,
+					errcode(ERRCODE_PROTOCOL_VIOLATION),
+					errmsg("malformed OAUTHBEARER message"),
+					errdetail("The server does not support channel binding for OAuth, but the client message includes channel binding data."));
+			break;
+
+		case 'y':				/* fall through */
+		case 'n':
+			p++;
+			if (*p != ',')
+				ereport(ERROR,
+						errcode(ERRCODE_PROTOCOL_VIOLATION),
+						errmsg("malformed OAUTHBEARER message"),
+						errdetail("Comma expected, but found character \"%s\".",
+								  sanitize_char(*p)));
+			p++;
+			break;
+
+		default:
+			ereport(ERROR,
+					errcode(ERRCODE_PROTOCOL_VIOLATION),
+					errmsg("malformed OAUTHBEARER message"),
+					errdetail("Unexpected channel-binding flag \"%s\".",
+							  sanitize_char(cbind_flag)));
+	}
+
+	/*
+	 * Forbid optional authzid (authorization identity).  We don't support it.
+	 */
+	if (*p == 'a')
+		ereport(ERROR,
+				errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				errmsg("client uses authorization identity, but it is not supported"));
+	if (*p != ',')
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Unexpected attribute \"%s\" in client-first-message.",
+						  sanitize_char(*p)));
+	p++;
+
+	/* All remaining fields are separated by the RFC's kvsep (\x01). */
+	if (*p != KVSEP)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Key-value separator expected, but found character \"%s\".",
+						  sanitize_char(*p)));
+	p++;
+
+	auth = parse_kvpairs_for_auth(&p);
+	if (!auth)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message does not contain an auth value."));
+
+	/* We should be at the end of our message. */
+	if (*p)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message contains additional data after the final terminator."));
+
+	if (!validate(ctx->port, auth))
+	{
+		generate_error_response(ctx, output, outputlen);
+
+		ctx->state = OAUTH_STATE_ERROR;
+		status = PG_SASL_EXCHANGE_CONTINUE;
+	}
+	else
+	{
+		ctx->state = OAUTH_STATE_FINISHED;
+		status = PG_SASL_EXCHANGE_SUCCESS;
+	}
+
+	/* Don't let extra copies of the bearer token hang around. */
+	explicit_bzero(input_copy, inputlen);
+
+	return status;
+}
+
+/*
+ * Convert an arbitrary byte to printable form.  For error messages.
+ *
+ * If it's a printable ASCII character, print it as a single character.
+ * otherwise, print it in hex.
+ *
+ * The returned pointer points to a static buffer.
+ */
+static char *
+sanitize_char(char c)
+{
+	static char buf[5];
+
+	if (c >= 0x21 && c <= 0x7E)
+		snprintf(buf, sizeof(buf), "'%c'", c);
+	else
+		snprintf(buf, sizeof(buf), "0x%02x", (unsigned char) c);
+	return buf;
+}
+
+/*
+ * Performs syntactic validation of a key and value from the initial client
+ * response. (Semantic validation of interesting values must be performed
+ * later.)
+ */
+static void
+validate_kvpair(const char *key, const char *val)
+{
+	/*-----
+	 * From Sec 3.1:
+	 *     key            = 1*(ALPHA)
+	 */
+	static const char *key_allowed_set =
+		"abcdefghijklmnopqrstuvwxyz"
+		"ABCDEFGHIJKLMNOPQRSTUVWXYZ";
+
+	size_t		span;
+
+	if (!key[0])
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message contains an empty key name."));
+
+	span = strspn(key, key_allowed_set);
+	if (key[span] != '\0')
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message contains an invalid key name."));
+
+	/*-----
+	 * From Sec 3.1:
+	 *     value          = *(VCHAR / SP / HTAB / CR / LF )
+	 *
+	 * The VCHAR (visible character) class is large; a loop is more
+	 * straightforward than strspn().
+	 */
+	for (; *val; ++val)
+	{
+		if (0x21 <= *val && *val <= 0x7E)
+			continue;			/* VCHAR */
+
+		switch (*val)
+		{
+			case ' ':
+			case '\t':
+			case '\r':
+			case '\n':
+				continue;		/* SP, HTAB, CR, LF */
+
+			default:
+				ereport(ERROR,
+						errcode(ERRCODE_PROTOCOL_VIOLATION),
+						errmsg("malformed OAUTHBEARER message"),
+						errdetail("Message contains an invalid value."));
+		}
+	}
+}
+
+/*
+ * Consumes all kvpairs in an OAUTHBEARER exchange message. If the "auth" key is
+ * found, its value is returned.
+ */
+static char *
+parse_kvpairs_for_auth(char **input)
+{
+	char	   *pos = *input;
+	char	   *auth = NULL;
+
+	/*----
+	 * The relevant ABNF, from Sec. 3.1:
+	 *
+	 *     kvsep          = %x01
+	 *     key            = 1*(ALPHA)
+	 *     value          = *(VCHAR / SP / HTAB / CR / LF )
+	 *     kvpair         = key "=" value kvsep
+	 *   ;;gs2-header     = See RFC 5801
+	 *     client-resp    = (gs2-header kvsep *kvpair kvsep) / kvsep
+	 *
+	 * By the time we reach this code, the gs2-header and initial kvsep have
+	 * already been validated. We start at the beginning of the first kvpair.
+	 */
+
+	while (*pos)
+	{
+		char	   *end;
+		char	   *sep;
+		char	   *key;
+		char	   *value;
+
+		/*
+		 * Find the end of this kvpair. Note that input is null-terminated by
+		 * the SASL code, so the strchr() is bounded.
+		 */
+		end = strchr(pos, KVSEP);
+		if (!end)
+			ereport(ERROR,
+					errcode(ERRCODE_PROTOCOL_VIOLATION),
+					errmsg("malformed OAUTHBEARER message"),
+					errdetail("Message contains an unterminated key/value pair."));
+		*end = '\0';
+
+		if (pos == end)
+		{
+			/* Empty kvpair, signifying the end of the list. */
+			*input = pos + 1;
+			return auth;
+		}
+
+		/*
+		 * Find the end of the key name.
+		 */
+		sep = strchr(pos, '=');
+		if (!sep)
+			ereport(ERROR,
+					errcode(ERRCODE_PROTOCOL_VIOLATION),
+					errmsg("malformed OAUTHBEARER message"),
+					errdetail("Message contains a key without a value."));
+		*sep = '\0';
+
+		/* Both key and value are now safely terminated. */
+		key = pos;
+		value = sep + 1;
+		validate_kvpair(key, value);
+
+		if (strcmp(key, AUTH_KEY) == 0)
+		{
+			if (auth)
+				ereport(ERROR,
+						errcode(ERRCODE_PROTOCOL_VIOLATION),
+						errmsg("malformed OAUTHBEARER message"),
+						errdetail("Message contains multiple auth values."));
+
+			auth = value;
+		}
+		else
+		{
+			/*
+			 * The RFC also defines the host and port keys, but they are not
+			 * required for OAUTHBEARER and we do not use them. Also, per Sec.
+			 * 3.1, any key/value pairs we don't recognize must be ignored.
+			 */
+		}
+
+		/* Move to the next pair. */
+		pos = end + 1;
+	}
+
+	ereport(ERROR,
+			errcode(ERRCODE_PROTOCOL_VIOLATION),
+			errmsg("malformed OAUTHBEARER message"),
+			errdetail("Message did not contain a final terminator."));
+
+	pg_unreachable();
+	return NULL;
+}
+
+/*
+ * Builds the JSON response for failed authentication (RFC 7628, Sec. 3.2.2).
+ * This contains the required scopes for entry and a pointer to the OAuth/OpenID
+ * discovery document, which the client may use to conduct its OAuth flow.
+ */
+static void
+generate_error_response(struct oauth_ctx *ctx, char **output, int *outputlen)
+{
+	StringInfoData buf;
+	StringInfoData issuer;
+
+	/*
+	 * The admin needs to set an issuer and scope for OAuth to work. There's
+	 * not really a way to hide this from the user, either, because we can't
+	 * choose a "default" issuer, so be honest in the failure message.
+	 *
+	 * TODO: see if there's a better place to fail, earlier than this.
+	 */
+	if (!ctx->issuer || !ctx->scope)
+		ereport(FATAL,
+				errcode(ERRCODE_INTERNAL_ERROR),
+				errmsg("OAuth is not properly configured for this user"),
+				errdetail_log("The issuer and scope parameters must be set in pg_hba.conf."));
+
+	/*
+	 * Build a default .well-known URI based on our issuer, unless the HBA has
+	 * already provided one.
+	 */
+	initStringInfo(&issuer);
+	appendStringInfoString(&issuer, ctx->issuer);
+	if (strstr(ctx->issuer, "/.well-known/") == NULL)
+		appendStringInfoString(&issuer, "/.well-known/openid-configuration");
+
+	initStringInfo(&buf);
+
+	/*
+	 * Escaping the string here is belt-and-suspenders defensive programming
+	 * since escapable characters aren't valid in either the issuer URI or the
+	 * scope list, but the HBA doesn't enforce that yet.
+	 */
+	appendStringInfoString(&buf, "{ \"status\": \"invalid_token\", ");
+
+	appendStringInfoString(&buf, "\"openid-configuration\": ");
+	escape_json(&buf, issuer.data);
+	pfree(issuer.data);
+
+	appendStringInfoString(&buf, ", \"scope\": ");
+	escape_json(&buf, ctx->scope);
+
+	appendStringInfoString(&buf, " }");
+
+	*output = buf.data;
+	*outputlen = buf.len;
+}
+
+/*-----
+ * Validates the provided Authorization header and returns the token from
+ * within it. NULL is returned on validation failure.
+ *
+ * Only Bearer tokens are accepted. The ABNF is defined in RFC 6750, Sec.
+ * 2.1:
+ *
+ *      b64token    = 1*( ALPHA / DIGIT /
+ *                        "-" / "." / "_" / "~" / "+" / "/" ) *"="
+ *      credentials = "Bearer" 1*SP b64token
+ *
+ * The "credentials" construction is what we receive in our auth value.
+ *
+ * Since that spec is subordinate to HTTP (i.e. the HTTP Authorization
+ * header format; RFC 9110 Sec. 11), the "Bearer" scheme string must be
+ * compared case-insensitively. (This is not mentioned in RFC 6750, but the
+ * OAUTHBEARER spec points it out: RFC 7628 Sec. 4.)
+ *
+ * Invalid formats are technically a protocol violation, but we shouldn't
+ * reflect any information about the sensitive Bearer token back to the
+ * client; log at COMMERROR instead.
+ */
+static const char *
+validate_token_format(const char *header)
+{
+	size_t		span;
+	const char *token;
+	static const char *const b64token_allowed_set =
+		"abcdefghijklmnopqrstuvwxyz"
+		"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+		"0123456789-._~+/";
+
+	/* Missing auth headers should be handled by the caller. */
+	Assert(header);
+
+	if (header[0] == '\0')
+	{
+		/*
+		 * A completely empty auth header represents a query for
+		 * authentication parameters. The client expects it to fail; there's
+		 * no need to make any extra noise in the logs.
+		 *
+		 * TODO: should we find a way to return STATUS_EOF at the top level,
+		 * to suppress the authentication error entirely?
+		 */
+		return NULL;
+	}
+
+	if (pg_strncasecmp(header, BEARER_SCHEME, strlen(BEARER_SCHEME)))
+	{
+		ereport(COMMERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAuth bearer token"),
+				errdetail_log("Client response indicated a non-Bearer authentication scheme."));
+		return NULL;
+	}
+
+	/* Pull the bearer token out of the auth value. */
+	token = header + strlen(BEARER_SCHEME);
+
+	/* Swallow any additional spaces. */
+	while (*token == ' ')
+		token++;
+
+	/* Tokens must not be empty. */
+	if (!*token)
+	{
+		ereport(COMMERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAuth bearer token"),
+				errdetail_log("Bearer token is empty."));
+		return NULL;
+	}
+
+	/*
+	 * Make sure the token contains only allowed characters. Tokens may end
+	 * with any number of '=' characters.
+	 */
+	span = strspn(token, b64token_allowed_set);
+	while (token[span] == '=')
+		span++;
+
+	if (token[span] != '\0')
+	{
+		/*
+		 * This error message could be more helpful by printing the
+		 * problematic character(s), but that'd be a bit like printing a piece
+		 * of someone's password into the logs.
+		 */
+		ereport(COMMERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAuth bearer token"),
+				errdetail_log("Bearer token is not in the correct format."));
+		return NULL;
+	}
+
+	return token;
+}
+
+/*
+ * Checks that the "auth" kvpair in the client response contains a syntactically
+ * valid Bearer token, then passes it along to the loaded validator module for
+ * authorization. Returns true if validation succeeds.
+ */
+static bool
+validate(Port *port, const char *auth)
+{
+	int			map_status;
+	ValidatorModuleResult *ret;
+	const char *token;
+	bool		status;
+
+	/* Ensure that we have a correct token to validate */
+	if (!(token = validate_token_format(auth)))
+		return false;
+
+	/*
+	 * Ensure that we have a validation library loaded, this should always be
+	 * the case and an error here is indicative of a bug.
+	 */
+	if (!ValidatorCallbacks || !ValidatorCallbacks->validate_cb)
+		ereport(FATAL,
+				errcode(ERRCODE_INTERNAL_ERROR),
+				errmsg("validation of OAuth token requested without a validator loaded"));
+
+	/* Call the validation function from the validator module */
+	ret = ValidatorCallbacks->validate_cb(validator_module_state,
+										  token, port->user_name);
+	if (ret == NULL)
+	{
+		ereport(LOG, errmsg("internal error in OAuth validator module"));
+		return false;
+	}
+
+	/*
+	 * Log any authentication results even if the token isn't authorized; it
+	 * might be useful for auditing or troubleshooting.
+	 */
+	if (ret->authn_id)
+		set_authn_id(port, ret->authn_id);
+
+	if (!ret->authorized)
+	{
+		ereport(LOG,
+				errmsg("OAuth bearer authentication failed for user \"%s\"",
+					   port->user_name),
+				errdetail_log("Validator failed to authorize the provided token."));
+
+		status = false;
+		goto cleanup;
+	}
+
+	if (port->hba->oauth_skip_usermap)
+	{
+		/*
+		 * If the validator is our authorization authority, we're done.
+		 * Authentication may or may not have been performed depending on the
+		 * validator implementation; all that matters is that the validator
+		 * says the user can log in with the target role.
+		 */
+		status = true;
+		goto cleanup;
+	}
+
+	/* Make sure the validator authenticated the user. */
+	if (ret->authn_id == NULL || ret->authn_id[0] == '\0')
+	{
+		ereport(LOG,
+				errmsg("OAuth bearer authentication failed for user \"%s\"",
+					   port->user_name),
+				errdetail_log("Validator provided no identity."));
+
+		status = false;
+		goto cleanup;
+	}
+
+	/* Finally, check the user map. */
+	map_status = check_usermap(port->hba->usermap, port->user_name,
+							   MyClientConnectionInfo.authn_id, false);
+	status = (map_status == STATUS_OK);
+
+cleanup:
+
+	/*
+	 * Clear and free the validation result from the validator module once
+	 * we're done with it.
+	 */
+	if (ret->authn_id != NULL)
+		pfree(ret->authn_id);
+	pfree(ret);
+
+	return status;
+}
+
+/*
+ * load_validator_library
+ *
+ * Load the configured validator library in order to perform token validation.
+ * There is no built-in fallback since validation is implementation specific. If
+ * no validator library is configured, or if it fails to load, then error out
+ * since token validation won't be possible.
+ */
+static void
+load_validator_library(const char *libname)
+{
+	OAuthValidatorModuleInit validator_init;
+
+	Assert(libname && *libname);
+
+	validator_init = (OAuthValidatorModuleInit)
+		load_external_function(libname, "_PG_oauth_validator_module_init",
+							   false, NULL);
+
+	/*
+	 * The validator init function is required since it will set the callbacks
+	 * for the validator library.
+	 */
+	if (validator_init == NULL)
+		ereport(ERROR,
+				errmsg("%s module \"%s\" must define the symbol %s",
+					   "OAuth validator", libname, "_PG_oauth_validator_module_init"));
+
+	ValidatorCallbacks = (*validator_init) ();
+	Assert(ValidatorCallbacks);
+
+	/* Allocate memory for validator library private state data */
+	validator_module_state = (ValidatorModuleState *) palloc0(sizeof(ValidatorModuleState));
+	if (ValidatorCallbacks->startup_cb != NULL)
+		ValidatorCallbacks->startup_cb(validator_module_state);
+
+	before_shmem_exit(shutdown_validator_library, 0);
+}
+
+/*
+ * Call the validator module's shutdown callback, if one is provided. This is
+ * invoked via before_shmem_exit().
+ */
+static void
+shutdown_validator_library(int code, Datum arg)
+{
+	if (ValidatorCallbacks->shutdown_cb != NULL)
+		ValidatorCallbacks->shutdown_cb(validator_module_state);
+}
+
+/*
+ * Ensure an OAuth validator named in the HBA is permitted by the configuration.
+ *
+ * If the validator is currently unset and exactly one library is declared in
+ * oauth_validator_libraries, then that library will be used as the validator.
+ * Otherwise the name must be present in the list of oauth_validator_libraries.
+ */
+bool
+check_oauth_validator(HbaLine *hbaline, int elevel, char **err_msg)
+{
+	int			line_num = hbaline->linenumber;
+	char	   *file_name = hbaline->sourcefile;
+	char	   *rawstring;
+	List	   *elemlist = NIL;
+
+	*err_msg = NULL;
+
+	if (oauth_validator_libraries_string[0] == '\0')
+	{
+		ereport(elevel,
+				errcode(ERRCODE_CONFIG_FILE_ERROR),
+				errmsg("oauth_validator_libraries must be set for authentication method %s",
+					   "oauth"),
+				errcontext("line %d of configuration file \"%s\"",
+						   line_num, file_name));
+		*err_msg = psprintf("oauth_validator_libraries must be set for authentication method %s",
+							"oauth");
+		return false;
+	}
+
+	/* SplitDirectoriesString needs a modifiable copy */
+	rawstring = pstrdup(oauth_validator_libraries_string);
+
+	if (!SplitDirectoriesString(rawstring, ',', &elemlist))
+	{
+		/* syntax error in list */
+		ereport(elevel,
+				errcode(ERRCODE_CONFIG_FILE_ERROR),
+				errmsg("invalid list syntax in parameter \"%s\"",
+					   "oauth_validator_libraries"));
+		*err_msg = psprintf("invalid list syntax in parameter \"%s\"",
+							"oauth_validator_libraries");
+		goto done;
+	}
+
+	if (!hbaline->oauth_validator)
+	{
+		if (elemlist->length == 1)
+		{
+			hbaline->oauth_validator = pstrdup(linitial(elemlist));
+			goto done;
+		}
+
+		ereport(elevel,
+				errcode(ERRCODE_CONFIG_FILE_ERROR),
+				errmsg("authentication method \"oauth\" requires argument \"validator\" to be set when oauth_validator_libraries contains multiple options"),
+				errcontext("line %d of configuration file \"%s\"",
+						   line_num, file_name));
+		*err_msg = "authentication method \"oauth\" requires argument \"validator\" to be set when oauth_validator_libraries contains multiple options";
+		goto done;
+	}
+
+	foreach_ptr(char, allowed, elemlist)
+	{
+		if (strcmp(allowed, hbaline->oauth_validator) == 0)
+			goto done;
+	}
+
+	ereport(elevel,
+			errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+			errmsg("validator \"%s\" is not permitted by %s",
+				   hbaline->oauth_validator, "oauth_validator_libraries"),
+			errcontext("line %d of configuration file \"%s\"",
+					   line_num, file_name));
+	*err_msg = psprintf("validator \"%s\" is not permitted by %s",
+						hbaline->oauth_validator, "oauth_validator_libraries");
+
+done:
+	list_free_deep(elemlist);
+	pfree(rawstring);
+
+	return (*err_msg == NULL);
+}
diff --git a/src/backend/libpq/auth.c b/src/backend/libpq/auth.c
index d6ef32cc823..0f65014e64f 100644
--- a/src/backend/libpq/auth.c
+++ b/src/backend/libpq/auth.c
@@ -29,6 +29,7 @@
 #include "libpq/auth.h"
 #include "libpq/crypt.h"
 #include "libpq/libpq.h"
+#include "libpq/oauth.h"
 #include "libpq/pqformat.h"
 #include "libpq/sasl.h"
 #include "libpq/scram.h"
@@ -45,7 +46,6 @@
  */
 static void auth_failed(Port *port, int status, const char *logdetail);
 static char *recv_password_packet(Port *port);
-static void set_authn_id(Port *port, const char *id);
 
 
 /*----------------------------------------------------------------
@@ -289,6 +289,9 @@ auth_failed(Port *port, int status, const char *logdetail)
 		case uaRADIUS:
 			errstr = gettext_noop("RADIUS authentication failed for user \"%s\"");
 			break;
+		case uaOAuth:
+			errstr = gettext_noop("OAuth bearer authentication failed for user \"%s\"");
+			break;
 		default:
 			errstr = gettext_noop("authentication failed for user \"%s\": invalid authentication method");
 			break;
@@ -324,7 +327,7 @@ auth_failed(Port *port, int status, const char *logdetail)
  * lifetime of MyClientConnectionInfo, so it is safe to pass a string that is
  * managed by an external library.
  */
-static void
+void
 set_authn_id(Port *port, const char *id)
 {
 	Assert(id);
@@ -611,6 +614,9 @@ ClientAuthentication(Port *port)
 		case uaTrust:
 			status = STATUS_OK;
 			break;
+		case uaOAuth:
+			status = CheckSASLAuth(&pg_be_oauth_mech, port, NULL, NULL);
+			break;
 	}
 
 	if ((status == STATUS_OK && port->hba->clientcert == clientCertFull)
diff --git a/src/backend/libpq/hba.c b/src/backend/libpq/hba.c
index 510c9ffc6d7..332fad27835 100644
--- a/src/backend/libpq/hba.c
+++ b/src/backend/libpq/hba.c
@@ -32,6 +32,7 @@
 #include "libpq/hba.h"
 #include "libpq/ifaddr.h"
 #include "libpq/libpq-be.h"
+#include "libpq/oauth.h"
 #include "postmaster/postmaster.h"
 #include "regex/regex.h"
 #include "replication/walsender.h"
@@ -114,7 +115,8 @@ static const char *const UserAuthName[] =
 	"ldap",
 	"cert",
 	"radius",
-	"peer"
+	"peer",
+	"oauth",
 };
 
 /*
@@ -1747,6 +1749,8 @@ parse_hba_line(TokenizedAuthLine *tok_line, int elevel)
 #endif
 	else if (strcmp(token->string, "radius") == 0)
 		parsedline->auth_method = uaRADIUS;
+	else if (strcmp(token->string, "oauth") == 0)
+		parsedline->auth_method = uaOAuth;
 	else
 	{
 		ereport(elevel,
@@ -2039,6 +2043,36 @@ parse_hba_line(TokenizedAuthLine *tok_line, int elevel)
 		parsedline->clientcert = clientCertFull;
 	}
 
+	/*
+	 * Enforce proper configuration of OAuth authentication.
+	 */
+	if (parsedline->auth_method == uaOAuth)
+	{
+		MANDATORY_AUTH_ARG(parsedline->oauth_scope, "scope", "oauth");
+		MANDATORY_AUTH_ARG(parsedline->oauth_issuer, "issuer", "oauth");
+
+		/* Ensure a validator library is set and permitted by the config. */
+		if (!check_oauth_validator(parsedline, elevel, err_msg))
+			return NULL;
+
+		/*
+		 * Supplying a usermap combined with the option to skip usermapping is
+		 * nonsensical and indicates a configuration error.
+		 */
+		if (parsedline->oauth_skip_usermap && parsedline->usermap != NULL)
+		{
+			ereport(elevel,
+					errcode(ERRCODE_CONFIG_FILE_ERROR),
+			/* translator: strings are replaced with hba options */
+					errmsg("%s cannot be used in combination with %s",
+						   "map", "delegate_ident_mapping"),
+					errcontext("line %d of configuration file \"%s\"",
+							   line_num, file_name));
+			*err_msg = "map cannot be used in combination with delegate_ident_mapping";
+			return NULL;
+		}
+	}
+
 	return parsedline;
 }
 
@@ -2066,8 +2100,9 @@ parse_hba_auth_opt(char *name, char *val, HbaLine *hbaline,
 			hbaline->auth_method != uaPeer &&
 			hbaline->auth_method != uaGSS &&
 			hbaline->auth_method != uaSSPI &&
-			hbaline->auth_method != uaCert)
-			INVALID_AUTH_OPTION("map", gettext_noop("ident, peer, gssapi, sspi, and cert"));
+			hbaline->auth_method != uaCert &&
+			hbaline->auth_method != uaOAuth)
+			INVALID_AUTH_OPTION("map", gettext_noop("ident, peer, gssapi, sspi, cert, and oauth"));
 		hbaline->usermap = pstrdup(val);
 	}
 	else if (strcmp(name, "clientcert") == 0)
@@ -2450,6 +2485,29 @@ parse_hba_auth_opt(char *name, char *val, HbaLine *hbaline,
 		hbaline->radiusidentifiers = parsed_identifiers;
 		hbaline->radiusidentifiers_s = pstrdup(val);
 	}
+	else if (strcmp(name, "issuer") == 0)
+	{
+		REQUIRE_AUTH_OPTION(uaOAuth, "issuer", "oauth");
+		hbaline->oauth_issuer = pstrdup(val);
+	}
+	else if (strcmp(name, "scope") == 0)
+	{
+		REQUIRE_AUTH_OPTION(uaOAuth, "scope", "oauth");
+		hbaline->oauth_scope = pstrdup(val);
+	}
+	else if (strcmp(name, "validator") == 0)
+	{
+		REQUIRE_AUTH_OPTION(uaOAuth, "validator", "oauth");
+		hbaline->oauth_validator = pstrdup(val);
+	}
+	else if (strcmp(name, "delegate_ident_mapping") == 0)
+	{
+		REQUIRE_AUTH_OPTION(uaOAuth, "delegate_ident_mapping", "oauth");
+		if (strcmp(val, "1") == 0)
+			hbaline->oauth_skip_usermap = true;
+		else
+			hbaline->oauth_skip_usermap = false;
+	}
 	else
 	{
 		ereport(elevel,
diff --git a/src/backend/libpq/meson.build b/src/backend/libpq/meson.build
index 0f0421037e4..31aa2faae1e 100644
--- a/src/backend/libpq/meson.build
+++ b/src/backend/libpq/meson.build
@@ -1,6 +1,7 @@
 # Copyright (c) 2022-2025, PostgreSQL Global Development Group
 
 backend_sources += files(
+  'auth-oauth.c',
   'auth-sasl.c',
   'auth-scram.c',
   'auth.c',
diff --git a/src/backend/libpq/pg_hba.conf.sample b/src/backend/libpq/pg_hba.conf.sample
index bad13497a34..b64c8dea97c 100644
--- a/src/backend/libpq/pg_hba.conf.sample
+++ b/src/backend/libpq/pg_hba.conf.sample
@@ -53,8 +53,8 @@
 # directly connected to.
 #
 # METHOD can be "trust", "reject", "md5", "password", "scram-sha-256",
-# "gss", "sspi", "ident", "peer", "pam", "ldap", "radius" or "cert".
-# Note that "password" sends passwords in clear text; "md5" or
+# "gss", "sspi", "ident", "peer", "pam", "oauth", "ldap", "radius" or
+# "cert".  Note that "password" sends passwords in clear text; "md5" or
 # "scram-sha-256" are preferred since they send encrypted passwords.
 #
 # OPTIONS are a set of options for the authentication in the format
diff --git a/src/backend/utils/adt/hbafuncs.c b/src/backend/utils/adt/hbafuncs.c
index 03c38e8c451..b62c3d944cf 100644
--- a/src/backend/utils/adt/hbafuncs.c
+++ b/src/backend/utils/adt/hbafuncs.c
@@ -152,6 +152,25 @@ get_hba_options(HbaLine *hba)
 				CStringGetTextDatum(psprintf("radiusports=%s", hba->radiusports_s));
 	}
 
+	if (hba->auth_method == uaOAuth)
+	{
+		if (hba->oauth_issuer)
+			options[noptions++] =
+				CStringGetTextDatum(psprintf("issuer=%s", hba->oauth_issuer));
+
+		if (hba->oauth_scope)
+			options[noptions++] =
+				CStringGetTextDatum(psprintf("scope=%s", hba->oauth_scope));
+
+		if (hba->oauth_validator)
+			options[noptions++] =
+				CStringGetTextDatum(psprintf("validator=%s", hba->oauth_validator));
+
+		if (hba->oauth_skip_usermap)
+			options[noptions++] =
+				CStringGetTextDatum(psprintf("delegate_ident_mapping=true"));
+	}
+
 	/* If you add more options, consider increasing MAX_HBA_OPTIONS. */
 	Assert(noptions <= MAX_HBA_OPTIONS);
 
diff --git a/src/backend/utils/misc/guc_tables.c b/src/backend/utils/misc/guc_tables.c
index 38cb9e970d5..db582d2d62c 100644
--- a/src/backend/utils/misc/guc_tables.c
+++ b/src/backend/utils/misc/guc_tables.c
@@ -49,6 +49,7 @@
 #include "jit/jit.h"
 #include "libpq/auth.h"
 #include "libpq/libpq.h"
+#include "libpq/oauth.h"
 #include "libpq/scram.h"
 #include "nodes/queryjumble.h"
 #include "optimizer/cost.h"
@@ -4823,6 +4824,17 @@ struct config_string ConfigureNamesString[] =
 		check_restrict_nonsystem_relation_kind, assign_restrict_nonsystem_relation_kind, NULL
 	},
 
+	{
+		{"oauth_validator_libraries", PGC_SIGHUP, CONN_AUTH_AUTH,
+			gettext_noop("Lists libraries that may be called to validate OAuth v2 bearer tokens."),
+			NULL,
+			GUC_LIST_INPUT | GUC_LIST_QUOTE | GUC_SUPERUSER_ONLY
+		},
+		&oauth_validator_libraries_string,
+		"",
+		NULL, NULL, NULL
+	},
+
 	/* End-of-list marker */
 	{
 		{NULL, 0, 0, NULL, NULL}, NULL, NULL, NULL, NULL, NULL
diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample
index 079efa1baa7..678de38a1c0 100644
--- a/src/backend/utils/misc/postgresql.conf.sample
+++ b/src/backend/utils/misc/postgresql.conf.sample
@@ -121,6 +121,9 @@
 #ssl_passphrase_command = ''
 #ssl_passphrase_command_supports_reload = off
 
+# OAuth
+#oauth_validator_libraries = ''	# comma-separated list of trusted validator modules
+
 
 #------------------------------------------------------------------------------
 # RESOURCE USAGE (except WAL)
diff --git a/src/include/common/oauth-common.h b/src/include/common/oauth-common.h
new file mode 100644
index 00000000000..8fe56267780
--- /dev/null
+++ b/src/include/common/oauth-common.h
@@ -0,0 +1,19 @@
+/*-------------------------------------------------------------------------
+ *
+ * oauth-common.h
+ *		Declarations for helper functions used for OAuth/OIDC authentication
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/include/common/oauth-common.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef OAUTH_COMMON_H
+#define OAUTH_COMMON_H
+
+/* Name of SASL mechanism per IANA */
+#define OAUTHBEARER_NAME "OAUTHBEARER"
+
+#endif							/* OAUTH_COMMON_H */
diff --git a/src/include/libpq/auth.h b/src/include/libpq/auth.h
index 902c5f6de32..25b5742068f 100644
--- a/src/include/libpq/auth.h
+++ b/src/include/libpq/auth.h
@@ -39,6 +39,7 @@ extern PGDLLIMPORT bool pg_gss_accept_delegation;
 extern void ClientAuthentication(Port *port);
 extern void sendAuthRequest(Port *port, AuthRequest areq, const char *extradata,
 							int extralen);
+extern void set_authn_id(Port *port, const char *id);
 
 /* Hook for plugins to get control in ClientAuthentication() */
 typedef void (*ClientAuthentication_hook_type) (Port *, int);
diff --git a/src/include/libpq/hba.h b/src/include/libpq/hba.h
index b20d0051f7d..3657f182db3 100644
--- a/src/include/libpq/hba.h
+++ b/src/include/libpq/hba.h
@@ -39,7 +39,8 @@ typedef enum UserAuth
 	uaCert,
 	uaRADIUS,
 	uaPeer,
-#define USER_AUTH_LAST uaPeer	/* Must be last value of this enum */
+	uaOAuth,
+#define USER_AUTH_LAST uaOAuth	/* Must be last value of this enum */
 } UserAuth;
 
 /*
@@ -135,6 +136,10 @@ typedef struct HbaLine
 	char	   *radiusidentifiers_s;
 	List	   *radiusports;
 	char	   *radiusports_s;
+	char	   *oauth_issuer;
+	char	   *oauth_scope;
+	char	   *oauth_validator;
+	bool		oauth_skip_usermap;
 } HbaLine;
 
 typedef struct IdentLine
diff --git a/src/include/libpq/oauth.h b/src/include/libpq/oauth.h
new file mode 100644
index 00000000000..4fcdda74305
--- /dev/null
+++ b/src/include/libpq/oauth.h
@@ -0,0 +1,54 @@
+/*-------------------------------------------------------------------------
+ *
+ * oauth.h
+ *	  Interface to libpq/auth-oauth.c
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/include/libpq/oauth.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PG_OAUTH_H
+#define PG_OAUTH_H
+
+#include "libpq/libpq-be.h"
+#include "libpq/sasl.h"
+
+extern PGDLLIMPORT char *oauth_validator_libraries_string;
+
+typedef struct ValidatorModuleState
+{
+	void	   *private_data;
+} ValidatorModuleState;
+
+typedef struct ValidatorModuleResult
+{
+	bool		authorized;
+	char	   *authn_id;
+} ValidatorModuleResult;
+
+typedef void (*ValidatorStartupCB) (ValidatorModuleState *state);
+typedef void (*ValidatorShutdownCB) (ValidatorModuleState *state);
+typedef ValidatorModuleResult *(*ValidatorValidateCB) (ValidatorModuleState *state, const char *token, const char *role);
+
+typedef struct OAuthValidatorCallbacks
+{
+	ValidatorStartupCB startup_cb;
+	ValidatorShutdownCB shutdown_cb;
+	ValidatorValidateCB validate_cb;
+} OAuthValidatorCallbacks;
+
+typedef const OAuthValidatorCallbacks *(*OAuthValidatorModuleInit) (void);
+extern PGDLLEXPORT const OAuthValidatorCallbacks *_PG_oauth_validator_module_init(void);
+
+/* Implementation */
+extern const pg_be_sasl_mech pg_be_oauth_mech;
+
+/*
+ * Ensure a validator named in the HBA is permitted by the configuration.
+ */
+extern bool check_oauth_validator(HbaLine *hba, int elevel, char **err_msg);
+
+#endif							/* PG_OAUTH_H */
diff --git a/src/include/pg_config.h.in b/src/include/pg_config.h.in
index 07b2f798abd..c04ee38d086 100644
--- a/src/include/pg_config.h.in
+++ b/src/include/pg_config.h.in
@@ -229,6 +229,9 @@
 /* Define to 1 if you have the `crypto' library (-lcrypto). */
 #undef HAVE_LIBCRYPTO
 
+/* Define to 1 if you have the `curl' library (-lcurl). */
+#undef HAVE_LIBCURL
+
 /* Define to 1 if you have the `ldap' library (-lldap). */
 #undef HAVE_LIBLDAP
 
@@ -442,6 +445,9 @@
 /* Define to 1 if you have the <termios.h> header file. */
 #undef HAVE_TERMIOS_H
 
+/* Define to 1 if curl_global_init() is guaranteed to be threadsafe. */
+#undef HAVE_THREADSAFE_CURL_GLOBAL_INIT
+
 /* Define to 1 if your compiler understands `typeof' or something similar. */
 #undef HAVE_TYPEOF
 
@@ -663,6 +669,9 @@
 /* Define to 1 to build with LDAP support. (--with-ldap) */
 #undef USE_LDAP
 
+/* Define to 1 to build with libcurl support. (--with-libcurl) */
+#undef USE_LIBCURL
+
 /* Define to 1 to build with XML support. (--with-libxml) */
 #undef USE_LIBXML
 
diff --git a/src/interfaces/libpq/Makefile b/src/interfaces/libpq/Makefile
index 6a0def7273c..e9422888e3e 100644
--- a/src/interfaces/libpq/Makefile
+++ b/src/interfaces/libpq/Makefile
@@ -31,6 +31,7 @@ endif
 
 OBJS = \
 	$(WIN32RES) \
+	fe-auth-oauth.o \
 	fe-auth-scram.o \
 	fe-cancel.o \
 	fe-connect.o \
@@ -63,6 +64,10 @@ OBJS += \
 	fe-secure-gssapi.o
 endif
 
+ifeq ($(with_libcurl),yes)
+OBJS += fe-auth-oauth-curl.o
+endif
+
 ifeq ($(PORTNAME), cygwin)
 override shlib = cyg$(NAME)$(DLSUFFIX)
 endif
@@ -81,7 +86,7 @@ endif
 # that are built correctly for use in a shlib.
 SHLIB_LINK_INTERNAL = -lpgcommon_shlib -lpgport_shlib
 ifneq ($(PORTNAME), win32)
-SHLIB_LINK += $(filter -lcrypt -ldes -lcom_err -lcrypto -lk5crypto -lkrb5 -lgssapi_krb5 -lgss -lgssapi -lssl -lsocket -lnsl -lresolv -lintl -lm, $(LIBS)) $(LDAP_LIBS_FE) $(PTHREAD_LIBS)
+SHLIB_LINK += $(filter -lcrypt -ldes -lcom_err -lcrypto -lk5crypto -lkrb5 -lgssapi_krb5 -lgss -lgssapi -lssl -lcurl -lsocket -lnsl -lresolv -lintl -lm, $(LIBS)) $(LDAP_LIBS_FE) $(PTHREAD_LIBS)
 else
 SHLIB_LINK += $(filter -lcrypt -ldes -lcom_err -lcrypto -lk5crypto -lkrb5 -lgssapi32 -lssl -lsocket -lnsl -lresolv -lintl -lm $(PTHREAD_LIBS), $(LIBS)) $(LDAP_LIBS_FE)
 endif
@@ -110,6 +115,8 @@ backend_src = $(top_srcdir)/src/backend
 # which seems to insert references to that even in pure C code. Excluding
 # __tsan_func_exit is necessary when using ThreadSanitizer data race detector
 # which use this function for instrumentation of function exit.
+# libcurl registers an exit handler in the memory debugging code when running
+# with LeakSanitizer.
 # Skip the test when profiling, as gcc may insert exit() calls for that.
 # Also skip the test on platforms where libpq infrastructure may be provided
 # by statically-linked libraries, as we can't expect them to honor this
@@ -117,7 +124,7 @@ backend_src = $(top_srcdir)/src/backend
 libpq-refs-stamp: $(shlib)
 ifneq ($(enable_coverage), yes)
 ifeq (,$(filter solaris,$(PORTNAME)))
-	@if nm -A -u $< 2>/dev/null | grep -v -e __cxa_atexit -e __tsan_func_exit | grep exit; then \
+	@if nm -A -u $< 2>/dev/null | grep -v -e __cxa_atexit -e __tsan_func_exit -e _atexit | grep exit; then \
 		echo 'libpq must not be calling any function which invokes exit'; exit 1; \
 	fi
 endif
diff --git a/src/interfaces/libpq/exports.txt b/src/interfaces/libpq/exports.txt
index 2ad2cbf5ca3..9b789cbec0b 100644
--- a/src/interfaces/libpq/exports.txt
+++ b/src/interfaces/libpq/exports.txt
@@ -206,3 +206,6 @@ PQsocketPoll              203
 PQsetChunkedRowsMode      204
 PQgetCurrentTimeUSec      205
 PQservice                 206
+PQsetAuthDataHook         207
+PQgetAuthDataHook         208
+PQdefaultAuthDataHook     209
diff --git a/src/interfaces/libpq/fe-auth-oauth-curl.c b/src/interfaces/libpq/fe-auth-oauth-curl.c
new file mode 100644
index 00000000000..2407200ea97
--- /dev/null
+++ b/src/interfaces/libpq/fe-auth-oauth-curl.c
@@ -0,0 +1,2635 @@
+/*-------------------------------------------------------------------------
+ *
+ * fe-auth-oauth-curl.c
+ *	   The libcurl implementation of OAuth/OIDC authentication, using the
+ *	   OAuth Device Authorization Grant (RFC 8628).
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/interfaces/libpq/fe-auth-oauth-curl.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres_fe.h"
+
+#include <curl/curl.h>
+#include <math.h>
+#ifdef HAVE_SYS_EPOLL_H
+#include <sys/epoll.h>
+#include <sys/timerfd.h>
+#endif
+#ifdef HAVE_SYS_EVENT_H
+#include <sys/event.h>
+#endif
+#include <unistd.h>
+
+#include "common/jsonapi.h"
+#include "fe-auth.h"
+#include "fe-auth-oauth.h"
+#include "libpq-int.h"
+#include "mb/pg_wchar.h"
+
+#define MAX_OAUTH_RESPONSE_SIZE (1024 * 1024)
+
+/*
+ * Parsed JSON Representations
+ *
+ * As a general rule, we parse and cache only the fields we're currently using.
+ * When adding new fields, ensure the corresponding free_*() function is updated
+ * too.
+ */
+
+/*
+ * The OpenID Provider configuration (alternatively named "authorization server
+ * metadata") jointly described by OpenID Connect Discovery 1.0 and RFC 8414:
+ *
+ *     https://openid.net/specs/openid-connect-discovery-1_0.html
+ *     https://www.rfc-editor.org/rfc/rfc8414#section-3.2
+ */
+struct provider
+{
+	char	   *issuer;
+	char	   *token_endpoint;
+	char	   *device_authorization_endpoint;
+	struct curl_slist *grant_types_supported;
+};
+
+static void
+free_provider(struct provider *provider)
+{
+	free(provider->issuer);
+	free(provider->token_endpoint);
+	free(provider->device_authorization_endpoint);
+	curl_slist_free_all(provider->grant_types_supported);
+}
+
+/*
+ * The Device Authorization response, described by RFC 8628:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc8628#section-3.2
+ */
+struct device_authz
+{
+	char	   *device_code;
+	char	   *user_code;
+	char	   *verification_uri;
+	char	   *interval_str;
+
+	/* Fields below are parsed from the corresponding string above. */
+	int			interval;
+};
+
+static void
+free_device_authz(struct device_authz *authz)
+{
+	free(authz->device_code);
+	free(authz->user_code);
+	free(authz->verification_uri);
+	free(authz->interval_str);
+}
+
+/*
+ * The Token Endpoint error response, as described by RFC 6749:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc6749#section-5.2
+ *
+ * Note that this response type can also be returned from the Device
+ * Authorization Endpoint.
+ */
+struct token_error
+{
+	char	   *error;
+	char	   *error_description;
+};
+
+static void
+free_token_error(struct token_error *err)
+{
+	free(err->error);
+	free(err->error_description);
+}
+
+/*
+ * The Access Token response, as described by RFC 6749:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc6749#section-4.1.4
+ *
+ * During the Device Authorization flow, several temporary errors are expected
+ * as part of normal operation. To make it easy to handle these in the happy
+ * path, this contains an embedded token_error that is filled in if needed.
+ */
+struct token
+{
+	/* for successful responses */
+	char	   *access_token;
+	char	   *token_type;
+
+	/* for error responses */
+	struct token_error err;
+};
+
+static void
+free_token(struct token *tok)
+{
+	free(tok->access_token);
+	free(tok->token_type);
+	free_token_error(&tok->err);
+}
+
+/*
+ * Asynchronous State
+ */
+
+/* States for the overall async machine. */
+enum OAuthStep
+{
+	OAUTH_STEP_INIT = 0,
+	OAUTH_STEP_DISCOVERY,
+	OAUTH_STEP_DEVICE_AUTHORIZATION,
+	OAUTH_STEP_TOKEN_REQUEST,
+	OAUTH_STEP_WAIT_INTERVAL,
+};
+
+/*
+ * The async_ctx holds onto state that needs to persist across multiple calls
+ * to pg_fe_run_oauth_flow(). Almost everything interacts with this in some
+ * way.
+ */
+struct async_ctx
+{
+	enum OAuthStep step;		/* where are we in the flow? */
+
+#ifdef HAVE_SYS_EPOLL_H
+	int			timerfd;		/* a timerfd for signaling async timeouts */
+#endif
+	pgsocket	mux;			/* the multiplexer socket containing all
+								 * descriptors tracked by libcurl, plus the
+								 * timerfd */
+	CURLM	   *curlm;			/* top-level multi handle for libcurl
+								 * operations */
+	CURL	   *curl;			/* the (single) easy handle for serial
+								 * requests */
+
+	struct curl_slist *headers; /* common headers for all requests */
+	PQExpBufferData work_data;	/* scratch buffer for general use (remember to
+								 * clear out prior contents first!) */
+
+	/*------
+	 * Since a single logical operation may stretch across multiple calls to
+	 * our entry point, errors have three parts:
+	 *
+	 * - errctx:	an optional static string, describing the global operation
+	 *				currently in progress. It'll be translated for you.
+	 *
+	 * - errbuf:	contains the actual error message. Generally speaking, use
+	 *				actx_error[_str] to manipulate this. This must be filled
+	 *				with something useful on an error.
+	 *
+	 * - curl_err:	an optional static error buffer used by libcurl to put
+	 *				detailed information about failures. Unfortunately
+	 *				untranslatable.
+	 *
+	 * These pieces will be combined into a single error message looking
+	 * something like the following, with errctx and/or curl_err omitted when
+	 * absent:
+	 *
+	 *     connection to server ... failed: errctx: errbuf (curl_err)
+	 */
+	const char *errctx;			/* not freed; must point to static allocation */
+	PQExpBufferData errbuf;
+	char		curl_err[CURL_ERROR_SIZE];
+
+	/*
+	 * These documents need to survive over multiple calls, and are therefore
+	 * cached directly in the async_ctx.
+	 */
+	struct provider provider;
+	struct device_authz authz;
+
+	int			running;		/* is asynchronous work in progress? */
+	bool		user_prompted;	/* have we already sent the authz prompt? */
+	bool		used_basic_auth;	/* did we send a client secret? */
+	bool		debugging;		/* can we give unsafe developer assistance? */
+};
+
+/*
+ * Tears down the Curl handles and frees the async_ctx.
+ */
+static void
+free_async_ctx(PGconn *conn, struct async_ctx *actx)
+{
+	/*
+	 * TODO: in general, none of the error cases below should ever happen if
+	 * we have no bugs above. But if we do hit them, surfacing those errors
+	 * somehow might be the only way to have a chance to debug them. What's
+	 * the best way to do that? Assertions? Spraying messages on stderr?
+	 * Bubbling an error code to the top? Appending to the connection's error
+	 * message only helps if the bug caused a connection failure; otherwise
+	 * it'll be buried...
+	 */
+
+	if (actx->curlm && actx->curl)
+	{
+		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));
+	}
+
+	if (actx->curl)
+	{
+		/*
+		 * curl_multi_cleanup() doesn't free any associated easy handles; we
+		 * need to do that separately. We only ever have one easy handle per
+		 * multi handle.
+		 */
+		curl_easy_cleanup(actx->curl);
+	}
+
+	if (actx->curlm)
+	{
+		CURLMcode	err = curl_multi_cleanup(actx->curlm);
+
+		if (err)
+			libpq_append_conn_error(conn,
+									"libcurl multi handle cleanup failed: %s",
+									curl_multi_strerror(err));
+	}
+
+	free_provider(&actx->provider);
+	free_device_authz(&actx->authz);
+
+	curl_slist_free_all(actx->headers);
+	termPQExpBuffer(&actx->work_data);
+	termPQExpBuffer(&actx->errbuf);
+
+	if (actx->mux != PGINVALID_SOCKET)
+		close(actx->mux);
+#ifdef HAVE_SYS_EPOLL_H
+	if (actx->timerfd >= 0)
+		close(actx->timerfd);
+#endif
+
+	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.
+ */
+void
+pg_fe_cleanup_oauth_flow(PGconn *conn)
+{
+	fe_oauth_state *state = conn->sasl_state;
+
+	if (state->async_ctx)
+	{
+		free_async_ctx(conn, state->async_ctx);
+		state->async_ctx = NULL;
+	}
+
+	conn->altsock = PGINVALID_SOCKET;
+}
+
+/*
+ * Macros for manipulating actx->errbuf. actx_error() translates and formats a
+ * string for you; actx_error_str() appends a string directly without
+ * translation.
+ */
+
+#define actx_error(ACTX, FMT, ...) \
+	appendPQExpBuffer(&(ACTX)->errbuf, libpq_gettext(FMT), ##__VA_ARGS__)
+
+#define actx_error_str(ACTX, S) \
+	appendPQExpBufferStr(&(ACTX)->errbuf, S)
+
+/*
+ * Macros for getting and setting state for the connection's two libcurl
+ * handles, so you don't have to write out the error handling every time.
+ */
+
+#define CHECK_MSETOPT(ACTX, OPT, VAL, FAILACTION) \
+	do { \
+		struct async_ctx *_actx = (ACTX); \
+		CURLMcode	_setopterr = curl_multi_setopt(_actx->curlm, OPT, VAL); \
+		if (_setopterr) { \
+			actx_error(_actx, "failed to set %s on OAuth connection: %s",\
+					   #OPT, curl_multi_strerror(_setopterr)); \
+			FAILACTION; \
+		} \
+	} while (0)
+
+#define CHECK_SETOPT(ACTX, OPT, VAL, FAILACTION) \
+	do { \
+		struct async_ctx *_actx = (ACTX); \
+		CURLcode	_setopterr = curl_easy_setopt(_actx->curl, OPT, VAL); \
+		if (_setopterr) { \
+			actx_error(_actx, "failed to set %s on OAuth connection: %s",\
+					   #OPT, curl_easy_strerror(_setopterr)); \
+			FAILACTION; \
+		} \
+	} while (0)
+
+#define CHECK_GETINFO(ACTX, INFO, OUT, FAILACTION) \
+	do { \
+		struct async_ctx *_actx = (ACTX); \
+		CURLcode	_getinfoerr = curl_easy_getinfo(_actx->curl, INFO, OUT); \
+		if (_getinfoerr) { \
+			actx_error(_actx, "failed to get %s from OAuth response: %s",\
+					   #INFO, curl_easy_strerror(_getinfoerr)); \
+			FAILACTION; \
+		} \
+	} while (0)
+
+/*
+ * General JSON Parsing for OAuth Responses
+ */
+
+/*
+ * Represents a single name/value pair in a JSON object. This is the primary
+ * interface to parse_oauth_json().
+ *
+ * All fields are stored internally as strings or lists of strings, so clients
+ * have to explicitly parse other scalar types (though they will have gone
+ * through basic lexical validation). Storing nested objects is not currently
+ * supported, nor is parsing arrays of anything other than strings.
+ */
+struct json_field
+{
+	const char *name;			/* name (key) of the member */
+
+	JsonTokenType type;			/* currently supports JSON_TOKEN_STRING,
+								 * JSON_TOKEN_NUMBER, and
+								 * JSON_TOKEN_ARRAY_START */
+	union
+	{
+		char	  **scalar;		/* for all scalar types */
+		struct curl_slist **array;	/* for type == JSON_TOKEN_ARRAY_START */
+	}			target;
+
+	bool		required;		/* REQUIRED field, or just OPTIONAL? */
+};
+
+/* Documentation macros for json_field.required. */
+#define REQUIRED true
+#define OPTIONAL false
+
+/* Parse state for parse_oauth_json(). */
+struct oauth_parse
+{
+	PQExpBuffer errbuf;			/* detail message for JSON_SEM_ACTION_FAILED */
+	int			nested;			/* nesting level (zero is the top) */
+
+	const struct json_field *fields;	/* field definition array */
+	const struct json_field *active;	/* points inside the fields array */
+};
+
+#define oauth_parse_set_error(ctx, fmt, ...) \
+	appendPQExpBuffer((ctx)->errbuf, libpq_gettext(fmt), ##__VA_ARGS__)
+
+static void
+report_type_mismatch(struct oauth_parse *ctx)
+{
+	char	   *msgfmt;
+
+	Assert(ctx->active);
+
+	/*
+	 * At the moment, the only fields we're interested in are strings,
+	 * numbers, and arrays of strings.
+	 */
+	switch (ctx->active->type)
+	{
+		case JSON_TOKEN_STRING:
+			msgfmt = "field \"%s\" must be a string";
+			break;
+
+		case JSON_TOKEN_NUMBER:
+			msgfmt = "field \"%s\" must be a number";
+			break;
+
+		case JSON_TOKEN_ARRAY_START:
+			msgfmt = "field \"%s\" must be an array of strings";
+			break;
+
+		default:
+			Assert(false);
+			msgfmt = "field \"%s\" has unexpected type";
+	}
+
+	oauth_parse_set_error(ctx, msgfmt, ctx->active->name);
+}
+
+static JsonParseErrorType
+oauth_json_object_start(void *state)
+{
+	struct oauth_parse *ctx = state;
+
+	if (ctx->active)
+	{
+		/*
+		 * Currently, none of the fields we're interested in can be or contain
+		 * objects, so we can reject this case outright.
+		 */
+		report_type_mismatch(ctx);
+		return JSON_SEM_ACTION_FAILED;
+	}
+
+	++ctx->nested;
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_object_field_start(void *state, char *name, bool isnull)
+{
+	struct oauth_parse *ctx = state;
+
+	/* We care only about the top-level fields. */
+	if (ctx->nested == 1)
+	{
+		const struct json_field *field = ctx->fields;
+
+		/*
+		 * We should never start parsing a new field while a previous one is
+		 * still active.
+		 *
+		 * TODO: this code relies on assertions too much. We need to exit
+		 * sanely on internal logic errors, to avoid turning bugs into
+		 * vulnerabilities.
+		 */
+		Assert(!ctx->active);
+
+		while (field->name)
+		{
+			if (strcmp(name, field->name) == 0)
+			{
+				ctx->active = field;
+				break;
+			}
+
+			++field;
+		}
+
+		/*
+		 * We don't allow duplicate field names; error out if the target has
+		 * already been set.
+		 */
+		if (ctx->active)
+		{
+			field = ctx->active;
+
+			if ((field->type == JSON_TOKEN_ARRAY_START && *field->target.array)
+				|| (field->type != JSON_TOKEN_ARRAY_START && *field->target.scalar))
+			{
+				oauth_parse_set_error(ctx, "field \"%s\" is duplicated",
+									  field->name);
+				return JSON_SEM_ACTION_FAILED;
+			}
+		}
+	}
+
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_object_end(void *state)
+{
+	struct oauth_parse *ctx = state;
+
+	--ctx->nested;
+	if (!ctx->nested)
+		Assert(!ctx->active);	/* all fields should be fully processed */
+
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_array_start(void *state)
+{
+	struct oauth_parse *ctx = state;
+
+	if (!ctx->nested)
+	{
+		oauth_parse_set_error(ctx, "top-level element must be an object");
+		return JSON_SEM_ACTION_FAILED;
+	}
+
+	if (ctx->active)
+	{
+		if (ctx->active->type != JSON_TOKEN_ARRAY_START
+		/* The arrays we care about must not have arrays as values. */
+			|| ctx->nested > 1)
+		{
+			report_type_mismatch(ctx);
+			return JSON_SEM_ACTION_FAILED;
+		}
+	}
+
+	++ctx->nested;
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_array_end(void *state)
+{
+	struct oauth_parse *ctx = state;
+
+	if (ctx->active)
+	{
+		/*
+		 * This assumes that no target arrays can contain other arrays, which
+		 * we check in the array_start callback.
+		 */
+		Assert(ctx->nested == 2);
+		Assert(ctx->active->type == JSON_TOKEN_ARRAY_START);
+
+		ctx->active = NULL;
+	}
+
+	--ctx->nested;
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_scalar(void *state, char *token, JsonTokenType type)
+{
+	struct oauth_parse *ctx = state;
+
+	if (!ctx->nested)
+	{
+		oauth_parse_set_error(ctx, "top-level element must be an object");
+		return JSON_SEM_ACTION_FAILED;
+	}
+
+	if (ctx->active)
+	{
+		const struct json_field *field = ctx->active;
+		JsonTokenType expected = field->type;
+
+		/* Make sure this matches what the active field expects. */
+		if (expected == JSON_TOKEN_ARRAY_START)
+		{
+			/* Are we actually inside an array? */
+			if (ctx->nested < 2)
+			{
+				report_type_mismatch(ctx);
+				return JSON_SEM_ACTION_FAILED;
+			}
+
+			/* Currently, arrays can only contain strings. */
+			expected = JSON_TOKEN_STRING;
+		}
+
+		if (type != expected)
+		{
+			report_type_mismatch(ctx);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		if (field->type != JSON_TOKEN_ARRAY_START)
+		{
+			Assert(ctx->nested == 1);
+			Assert(!*field->target.scalar);
+
+			*field->target.scalar = strdup(token);
+			if (!*field->target.scalar)
+				return JSON_OUT_OF_MEMORY;
+
+			ctx->active = NULL;
+
+			return JSON_SUCCESS;
+		}
+		else
+		{
+			struct curl_slist *temp;
+
+			Assert(ctx->nested == 2);
+
+			/* Note that curl_slist_append() makes a copy of the token. */
+			temp = curl_slist_append(*field->target.array, token);
+			if (!temp)
+				return JSON_OUT_OF_MEMORY;
+
+			*field->target.array = temp;
+		}
+	}
+	else
+	{
+		/* otherwise we just ignore it */
+	}
+
+	return JSON_SUCCESS;
+}
+
+/*
+ * Checks the Content-Type header against the expected type. Parameters are
+ * allowed but ignored.
+ */
+static bool
+check_content_type(struct async_ctx *actx, const char *type)
+{
+	const size_t type_len = strlen(type);
+	char	   *content_type;
+
+	CHECK_GETINFO(actx, CURLINFO_CONTENT_TYPE, &content_type, return false);
+
+	if (!content_type)
+	{
+		actx_error(actx, "no content type was provided");
+		return false;
+	}
+
+	/*
+	 * We need to perform a length limited comparison and not compare the
+	 * whole string.
+	 */
+	if (pg_strncasecmp(content_type, type, type_len) != 0)
+		goto fail;
+
+	/* On an exact match, we're done. */
+	Assert(strlen(content_type) >= type_len);
+	if (content_type[type_len] == '\0')
+		return true;
+
+	/*
+	 * Only a semicolon (optionally preceded by HTTP optional whitespace) is
+	 * acceptable after the prefix we checked. This marks the start of media
+	 * type parameters, which we currently have no use for.
+	 */
+	for (size_t i = type_len; content_type[i]; ++i)
+	{
+		switch (content_type[i])
+		{
+			case ';':
+				return true;	/* success! */
+
+			case ' ':
+			case '\t':
+				/* HTTP optional whitespace allows only spaces and htabs. */
+				break;
+
+			default:
+				goto fail;
+		}
+	}
+
+fail:
+	actx_error(actx, "unexpected content type: \"%s\"", content_type);
+	return false;
+}
+
+/*
+ * A helper function for general JSON parsing. fields is the array of field
+ * definitions with their backing pointers. The response will be parsed from
+ * actx->curl and actx->work_data (as set up by start_request()), and any
+ * parsing errors will be placed into actx->errbuf.
+ */
+static bool
+parse_oauth_json(struct async_ctx *actx, const struct json_field *fields)
+{
+	PQExpBuffer resp = &actx->work_data;
+	JsonLexContext lex = {0};
+	JsonSemAction sem = {0};
+	JsonParseErrorType err;
+	struct oauth_parse ctx = {0};
+	bool		success = false;
+
+	if (!check_content_type(actx, "application/json"))
+		return false;
+
+	if (strlen(resp->data) != resp->len)
+	{
+		actx_error(actx, "response contains embedded NULLs");
+		return false;
+	}
+
+	/*
+	 * pg_parse_json doesn't validate the incoming UTF-8, so we have to check
+	 * that up front.
+	 */
+	if (pg_encoding_verifymbstr(PG_UTF8, resp->data, resp->len) != resp->len)
+	{
+		actx_error(actx, "response is not valid UTF-8");
+		return false;
+	}
+
+	makeJsonLexContextCstringLen(&lex, resp->data, resp->len, PG_UTF8, true);
+	setJsonLexContextOwnsTokens(&lex, true);	/* must not leak on error */
+
+	ctx.errbuf = &actx->errbuf;
+	ctx.fields = fields;
+	sem.semstate = &ctx;
+
+	sem.object_start = oauth_json_object_start;
+	sem.object_field_start = oauth_json_object_field_start;
+	sem.object_end = oauth_json_object_end;
+	sem.array_start = oauth_json_array_start;
+	sem.array_end = oauth_json_array_end;
+	sem.scalar = oauth_json_scalar;
+
+	err = pg_parse_json(&lex, &sem);
+
+	if (err != JSON_SUCCESS)
+	{
+		/*
+		 * For JSON_SEM_ACTION_FAILED, we've already written the error
+		 * message. Other errors come directly from pg_parse_json(), already
+		 * translated.
+		 */
+		if (err != JSON_SEM_ACTION_FAILED)
+			actx_error_str(actx, json_errdetail(err, &lex));
+
+		goto cleanup;
+	}
+
+	/* Check all required fields. */
+	while (fields->name)
+	{
+		if (fields->required
+			&& !*fields->target.scalar
+			&& !*fields->target.array)
+		{
+			actx_error(actx, "field \"%s\" is missing", fields->name);
+			goto cleanup;
+		}
+
+		fields++;
+	}
+
+	success = true;
+
+cleanup:
+	freeJsonLexContext(&lex);
+	return success;
+}
+
+/*
+ * JSON Parser Definitions
+ */
+
+/*
+ * Parses authorization server metadata. Fields are defined by OIDC Discovery
+ * 1.0 and RFC 8414.
+ */
+static bool
+parse_provider(struct async_ctx *actx, struct provider *provider)
+{
+	struct json_field fields[] = {
+		{"issuer", JSON_TOKEN_STRING, {&provider->issuer}, REQUIRED},
+		{"token_endpoint", JSON_TOKEN_STRING, {&provider->token_endpoint}, REQUIRED},
+
+		/*----
+		 * The following fields are technically REQUIRED, but we don't use
+		 * them anywhere yet:
+		 *
+		 * - jwks_uri
+		 * - response_types_supported
+		 * - subject_types_supported
+		 * - id_token_signing_alg_values_supported
+		 */
+
+		{"device_authorization_endpoint", JSON_TOKEN_STRING, {&provider->device_authorization_endpoint}, OPTIONAL},
+		{"grant_types_supported", JSON_TOKEN_ARRAY_START, {.array = &provider->grant_types_supported}, OPTIONAL},
+
+		{0},
+	};
+
+	return parse_oauth_json(actx, fields);
+}
+
+/*
+ * Parses the "interval" JSON number, corresponding to the number of seconds to
+ * wait between token endpoint requests.
+ *
+ * RFC 8628 is pretty silent on sanity checks for the interval. As a matter of
+ * practicality, round any fractional intervals up to the next second, and clamp
+ * the result at a minimum of one. (Zero-second intervals would result in an
+ * expensive network polling loop.) Tests may remove the lower bound with
+ * PGOAUTHDEBUG, for improved performance.
+ *
+ * TODO: maybe clamp the upper bound too, based on the libpq timeout and/or the
+ * code expiration time?
+ */
+static int
+parse_interval(struct async_ctx *actx, const char *interval_str)
+{
+	double		parsed;
+	int			cnt;
+
+	/*
+	 * The JSON lexer has already validated the number, which is stricter than
+	 * the %f format, so we should be good to use sscanf().
+	 */
+	cnt = sscanf(interval_str, "%lf", &parsed);
+
+	if (cnt != 1)
+	{
+		/*
+		 * Either the lexer screwed up or our assumption above isn't true, and
+		 * either way a developer needs to take a look.
+		 */
+		Assert(cnt == 1);
+		return 1;				/* don't fall through in release builds */
+	}
+
+	parsed = ceil(parsed);
+
+	if (parsed < 1)
+		return actx->debugging ? 0 : 1;
+
+	else if (INT_MAX <= parsed)
+		return INT_MAX;
+
+	return parsed;
+}
+
+/*
+ * Parses the Device Authorization Response (RFC 8628, Sec. 3.2).
+ */
+static bool
+parse_device_authz(struct async_ctx *actx, struct device_authz *authz)
+{
+	struct json_field fields[] = {
+		{"device_code", JSON_TOKEN_STRING, {&authz->device_code}, REQUIRED},
+		{"user_code", JSON_TOKEN_STRING, {&authz->user_code}, REQUIRED},
+		{"verification_uri", JSON_TOKEN_STRING, {&authz->verification_uri}, REQUIRED},
+
+		/*
+		 * Some services (Google, Azure) spell verification_uri differently.
+		 * We accept either.
+		 */
+		{"verification_url", JSON_TOKEN_STRING, {&authz->verification_uri}, REQUIRED},
+
+		/*
+		 * The following fields are technically REQUIRED, but we don't use
+		 * them anywhere yet:
+		 *
+		 * - expires_in
+		 */
+
+		{"interval", JSON_TOKEN_NUMBER, {&authz->interval_str}, OPTIONAL},
+
+		{0},
+	};
+
+	if (!parse_oauth_json(actx, fields))
+		return false;
+
+	/*
+	 * Parse our numeric fields. Lexing has already completed by this time, so
+	 * we at least know they're valid JSON numbers.
+	 */
+	if (authz->interval_str)
+		authz->interval = parse_interval(actx, authz->interval_str);
+	else
+	{
+		/*
+		 * RFC 8628 specifies 5 seconds as the default value if the server
+		 * doesn't provide an interval.
+		 */
+		authz->interval = 5;
+	}
+
+	return true;
+}
+
+/*
+ * Parses the device access token error response (RFC 8628, Sec. 3.5, which
+ * uses the error response defined in RFC 6749, Sec. 5.2).
+ */
+static bool
+parse_token_error(struct async_ctx *actx, struct token_error *err)
+{
+	bool		result;
+	struct json_field fields[] = {
+		{"error", JSON_TOKEN_STRING, {&err->error}, REQUIRED},
+
+		{"error_description", JSON_TOKEN_STRING, {&err->error_description}, OPTIONAL},
+
+		{0},
+	};
+
+	result = parse_oauth_json(actx, fields);
+
+	/*
+	 * Since token errors are parsed during other active error paths, only
+	 * override the errctx if parsing explicitly fails.
+	 */
+	if (!result)
+		actx->errctx = "failed to parse token error response";
+
+	return result;
+}
+
+/*
+ * Constructs a message from the token error response and puts it into
+ * actx->errbuf.
+ */
+static void
+record_token_error(struct async_ctx *actx, const struct token_error *err)
+{
+	if (err->error_description)
+		appendPQExpBuffer(&actx->errbuf, "%s ", err->error_description);
+	else
+	{
+		/*
+		 * Try to get some more helpful detail into the error string. A 401
+		 * status in particular implies that the oauth_client_secret is
+		 * missing or wrong.
+		 */
+		long		response_code;
+
+		CHECK_GETINFO(actx, CURLINFO_RESPONSE_CODE, &response_code, response_code = 0);
+
+		if (response_code == 401)
+		{
+			actx_error(actx, actx->used_basic_auth
+					   ? "provider rejected the oauth_client_secret"
+					   : "provider requires client authentication, and no oauth_client_secret is set");
+			actx_error_str(actx, " ");
+		}
+	}
+
+	appendPQExpBuffer(&actx->errbuf, "(%s)", err->error);
+}
+
+/*
+ * Parses the device access token response (RFC 8628, Sec. 3.5, which uses the
+ * success response defined in RFC 6749, Sec. 5.1).
+ */
+static bool
+parse_access_token(struct async_ctx *actx, struct token *tok)
+{
+	struct json_field fields[] = {
+		{"access_token", JSON_TOKEN_STRING, {&tok->access_token}, REQUIRED},
+		{"token_type", JSON_TOKEN_STRING, {&tok->token_type}, REQUIRED},
+
+		/*
+		 * The following fields are technically REQUIRED, but we don't use
+		 * them anywhere yet:
+		 *
+		 * - scope (only required if different than requested -- TODO check)
+		 */
+
+		{0},
+	};
+
+	return parse_oauth_json(actx, fields);
+}
+
+/*
+ * libcurl Multi Setup/Callbacks
+ */
+
+/*
+ * Sets up the actx->mux, which is the altsock that PQconnectPoll clients will
+ * select() on instead of the Postgres socket during OAuth negotiation.
+ *
+ * This is just an epoll set or kqueue abstracting multiple other descriptors.
+ * A timerfd is always part of the set when using epoll; it's just disabled
+ * when we're not using it.
+ */
+static bool
+setup_multiplexer(struct async_ctx *actx)
+{
+#ifdef HAVE_SYS_EPOLL_H
+	struct epoll_event ev = {.events = EPOLLIN};
+
+	actx->mux = epoll_create1(EPOLL_CLOEXEC);
+	if (actx->mux < 0)
+	{
+		actx_error(actx, "failed to create epoll set: %m");
+		return false;
+	}
+
+	actx->timerfd = timerfd_create(CLOCK_MONOTONIC, TFD_CLOEXEC);
+	if (actx->timerfd < 0)
+	{
+		actx_error(actx, "failed to create timerfd: %m");
+		return false;
+	}
+
+	if (epoll_ctl(actx->mux, EPOLL_CTL_ADD, actx->timerfd, &ev) < 0)
+	{
+		actx_error(actx, "failed to add timerfd to epoll set: %m");
+		return false;
+	}
+
+	return true;
+#endif
+#ifdef HAVE_SYS_EVENT_H
+	actx->mux = kqueue();
+	if (actx->mux < 0)
+	{
+		actx_error(actx, "failed to create kqueue: %m");
+		return false;
+	}
+
+	return true;
+#endif
+
+	actx_error(actx, "libpq does not support the Device Authorization flow on this platform");
+	return false;
+}
+
+/*
+ * Adds and removes sockets from the multiplexer set, as directed by the
+ * libcurl multi handle.
+ */
+static int
+register_socket(CURL *curl, curl_socket_t socket, int what, void *ctx,
+				void *socketp)
+{
+#ifdef HAVE_SYS_EPOLL_H
+	struct async_ctx *actx = ctx;
+	struct epoll_event ev = {0};
+	int			res;
+	int			op = EPOLL_CTL_ADD;
+
+	switch (what)
+	{
+		case CURL_POLL_IN:
+			ev.events = EPOLLIN;
+			break;
+
+		case CURL_POLL_OUT:
+			ev.events = EPOLLOUT;
+			break;
+
+		case CURL_POLL_INOUT:
+			ev.events = EPOLLIN | EPOLLOUT;
+			break;
+
+		case CURL_POLL_REMOVE:
+			op = EPOLL_CTL_DEL;
+			break;
+
+		default:
+			actx_error(actx, "unknown libcurl socket operation: %d", what);
+			return -1;
+	}
+
+	res = epoll_ctl(actx->mux, op, socket, &ev);
+	if (res < 0 && errno == EEXIST)
+	{
+		/* We already had this socket in the pollset. */
+		op = EPOLL_CTL_MOD;
+		res = epoll_ctl(actx->mux, op, socket, &ev);
+	}
+
+	if (res < 0)
+	{
+		switch (op)
+		{
+			case EPOLL_CTL_ADD:
+				actx_error(actx, "could not add to epoll set: %m");
+				break;
+
+			case EPOLL_CTL_DEL:
+				actx_error(actx, "could not delete from epoll set: %m");
+				break;
+
+			default:
+				actx_error(actx, "could not update epoll set: %m");
+		}
+
+		return -1;
+	}
+#endif
+#ifdef HAVE_SYS_EVENT_H
+	struct async_ctx *actx = ctx;
+	struct kevent ev[2] = {{0}};
+	struct kevent ev_out[2];
+	struct timespec timeout = {0};
+	int			nev = 0;
+	int			res;
+
+	switch (what)
+	{
+		case CURL_POLL_IN:
+			EV_SET(&ev[nev], socket, EVFILT_READ, EV_ADD | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			break;
+
+		case CURL_POLL_OUT:
+			EV_SET(&ev[nev], socket, EVFILT_WRITE, EV_ADD | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			break;
+
+		case CURL_POLL_INOUT:
+			EV_SET(&ev[nev], socket, EVFILT_READ, EV_ADD | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			EV_SET(&ev[nev], socket, EVFILT_WRITE, EV_ADD | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			break;
+
+		case CURL_POLL_REMOVE:
+
+			/*
+			 * We don't know which of these is currently registered, perhaps
+			 * both, so we try to remove both.  This means we need to tolerate
+			 * ENOENT below.
+			 */
+			EV_SET(&ev[nev], socket, EVFILT_READ, EV_DELETE | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			EV_SET(&ev[nev], socket, EVFILT_WRITE, EV_DELETE | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			break;
+
+		default:
+			actx_error(actx, "unknown libcurl socket operation: %d", what);
+			return -1;
+	}
+
+	res = kevent(actx->mux, ev, nev, ev_out, lengthof(ev_out), &timeout);
+	if (res < 0)
+	{
+		actx_error(actx, "could not modify kqueue: %m");
+		return -1;
+	}
+
+	/*
+	 * We can't use the simple errno version of kevent, because we need to
+	 * skip over ENOENT while still allowing a second change to be processed.
+	 * So we need a longer-form error checking loop.
+	 */
+	for (int i = 0; i < res; ++i)
+	{
+		/*
+		 * EV_RECEIPT should guarantee one EV_ERROR result for every change,
+		 * whether successful or not. Failed entries contain a non-zero errno
+		 * in the data field.
+		 */
+		Assert(ev_out[i].flags & EV_ERROR);
+
+		errno = ev_out[i].data;
+		if (errno && errno != ENOENT)
+		{
+			switch (what)
+			{
+				case CURL_POLL_REMOVE:
+					actx_error(actx, "could not delete from kqueue: %m");
+					break;
+				default:
+					actx_error(actx, "could not add to kqueue: %m");
+			}
+			return -1;
+		}
+	}
+#endif
+
+	return 0;
+}
+
+/*
+ * Enables or disables the timer in the multiplexer set. The timeout value is
+ * in milliseconds (negative values disable the timer). Rather than continually
+ * adding and removing the timer, we keep it in the set at all times and just
+ * disarm it when it's not needed.
+ */
+static bool
+set_timer(struct async_ctx *actx, long timeout)
+{
+#if HAVE_SYS_EPOLL_H
+	struct itimerspec spec = {0};
+
+	if (timeout < 0)
+	{
+		/* the zero itimerspec will disarm the timer below */
+	}
+	else if (timeout == 0)
+	{
+		/*
+		 * A zero timeout means libcurl wants us to call back immediately.
+		 * That's not technically an option for timerfd, but we can make the
+		 * timeout ridiculously short.
+		 */
+		spec.it_value.tv_nsec = 1;
+	}
+	else
+	{
+		spec.it_value.tv_sec = timeout / 1000;
+		spec.it_value.tv_nsec = (timeout % 1000) * 1000000;
+	}
+
+	if (timerfd_settime(actx->timerfd, 0 /* no flags */ , &spec, NULL) < 0)
+	{
+		actx_error(actx, "setting timerfd to %ld: %m", timeout);
+		return false;
+	}
+#endif
+#ifdef HAVE_SYS_EVENT_H
+	struct kevent ev;
+
+	EV_SET(&ev, 1, EVFILT_TIMER, timeout < 0 ? EV_DELETE : EV_ADD,
+		   0, timeout, 0);
+	if (kevent(actx->mux, &ev, 1, NULL, 0, NULL) < 0 && errno != ENOENT)
+	{
+		actx_error(actx, "setting kqueue timer to %ld: %m", timeout);
+		return false;
+	}
+#endif
+
+	return true;
+}
+
+/*
+ * Adds or removes timeouts from the multiplexer set, as directed by the
+ * libcurl multi handle.
+ */
+static int
+register_timer(CURLM *curlm, long timeout, void *ctx)
+{
+	struct async_ctx *actx = ctx;
+
+	/*
+	 * TODO: maybe just signal drive_request() to immediately call back in the
+	 * (timeout == 0) case?
+	 */
+	if (!set_timer(actx, timeout))
+		return -1;				/* actx_error already called */
+
+	return 0;
+}
+
+/*
+ * Prints Curl request debugging information to stderr.
+ *
+ * Note that this will expose a number of critical secrets, so users have to opt
+ * into this (see PGOAUTHDEBUG).
+ */
+static int
+debug_callback(CURL *handle, curl_infotype type, char *data, size_t size,
+			   void *clientp)
+{
+	const char *const end = data + size;
+	const char *prefix;
+
+	/* Prefixes are modeled off of the default libcurl debug output. */
+	switch (type)
+	{
+		case CURLINFO_TEXT:
+			prefix = "*";
+			break;
+
+		case CURLINFO_HEADER_IN:	/* fall through */
+		case CURLINFO_DATA_IN:
+			prefix = "<";
+			break;
+
+		case CURLINFO_HEADER_OUT:	/* fall through */
+		case CURLINFO_DATA_OUT:
+			prefix = ">";
+			break;
+
+		default:
+			return 0;
+	}
+
+	/*
+	 * Split the output into lines for readability; sometimes multiple headers
+	 * are included in a single call.
+	 */
+	while (data < end)
+	{
+		size_t		len = end - data;
+		char	   *eol = memchr(data, '\n', len);
+
+		if (eol)
+			len = eol - data + 1;
+
+		/* TODO: handle unprintables */
+		fprintf(stderr, "%s %.*s%s", prefix, (int) len, data,
+				eol ? "" : "\n");
+
+		data += len;
+	}
+
+	return 0;
+}
+
+/*
+ * Initializes the two libcurl handles in the async_ctx. The multi handle,
+ * actx->curlm, is what drives the asynchronous engine and tells us what to do
+ * next. The easy handle, actx->curl, encapsulates the state for a single
+ * request/response. It's added to the multi handle as needed, during
+ * start_request().
+ */
+static bool
+setup_curl_handles(struct async_ctx *actx)
+{
+	curl_version_info_data *curl_info;
+
+	/*
+	 * Create our multi handle. This encapsulates the entire conversation with
+	 * libcurl for this connection.
+	 */
+	actx->curlm = curl_multi_init();
+	if (!actx->curlm)
+	{
+		/* We don't get a lot of feedback on the failure reason. */
+		actx_error(actx, "failed to create libcurl multi handle");
+		return false;
+	}
+
+	/*
+	 * Extract information about the libcurl we are linked against.
+	 */
+	curl_info = curl_version_info(CURLVERSION_NOW);
+
+	/*
+	 * The multi handle tells us what to wait on using two callbacks. These
+	 * will manipulate actx->mux as needed.
+	 */
+	CHECK_MSETOPT(actx, CURLMOPT_SOCKETFUNCTION, register_socket, return false);
+	CHECK_MSETOPT(actx, CURLMOPT_SOCKETDATA, actx, return false);
+	CHECK_MSETOPT(actx, CURLMOPT_TIMERFUNCTION, register_timer, return false);
+	CHECK_MSETOPT(actx, CURLMOPT_TIMERDATA, actx, return false);
+
+	/*
+	 * Set up an easy handle. All of our requests are made serially, so we
+	 * only ever need to keep track of one.
+	 */
+	actx->curl = curl_easy_init();
+	if (!actx->curl)
+	{
+		actx_error(actx, "failed to create libcurl handle");
+		return false;
+	}
+
+	/*
+	 * Multi-threaded applications must set CURLOPT_NOSIGNAL. This requires us
+	 * to handle the possibility of SIGPIPE ourselves using pq_block_sigpipe;
+	 * see pg_fe_run_oauth_flow().
+	 */
+	CHECK_SETOPT(actx, CURLOPT_NOSIGNAL, 1L, return false);
+	if (!curl_info->ares_num)
+	{
+		/* No alternative resolver, TODO: warn about timeouts */
+	}
+
+	if (actx->debugging)
+	{
+		/*
+		 * Set a callback for retrieving error information from libcurl, the
+		 * function only takes effect when CURLOPT_VERBOSE has been set so
+		 * make sure the order is kept.
+		 */
+		CHECK_SETOPT(actx, CURLOPT_DEBUGFUNCTION, debug_callback, return false);
+		CHECK_SETOPT(actx, CURLOPT_VERBOSE, 1L, return false);
+	}
+
+	CHECK_SETOPT(actx, CURLOPT_ERRORBUFFER, actx->curl_err, return false);
+
+	/*
+	 * Only HTTPS is allowed. (Debug mode additionally allows HTTP; this is
+	 * intended for testing only.)
+	 *
+	 * There's a bit of unfortunate complexity around the choice of
+	 * CURLoption. CURLOPT_PROTOCOLS is deprecated in modern Curls, but its
+	 * replacement didn't show up until relatively recently.
+	 */
+	{
+#if CURL_AT_LEAST_VERSION(7, 85, 0)
+		const CURLoption popt = CURLOPT_PROTOCOLS_STR;
+		const char *protos = "https";
+		const char *const unsafe = "https,http";
+#else
+		const CURLoption popt = CURLOPT_PROTOCOLS;
+		long		protos = CURLPROTO_HTTPS;
+		const long	unsafe = CURLPROTO_HTTPS | CURLPROTO_HTTP;
+#endif
+
+		if (actx->debugging)
+			protos = unsafe;
+
+		CHECK_SETOPT(actx, popt, protos, return false);
+	}
+
+	/* TODO: would anyone use this in "real" situations, or just testing? */
+	if (actx->debugging)
+	{
+		const char *env;
+
+		if ((env = getenv("PGOAUTHCAFILE")) != NULL)
+			CHECK_SETOPT(actx, CURLOPT_CAINFO, env, return false);
+	}
+
+	/*
+	 * Suppress the Accept header to make our request as minimal as possible.
+	 * (Ideally we would set it to "application/json" instead, but OpenID is
+	 * pretty strict when it comes to provider behavior, so we have to check
+	 * what comes back anyway.)
+	 */
+	actx->headers = curl_slist_append(actx->headers, "Accept:");
+	if (actx->headers == NULL)
+	{
+		actx_error(actx, "out of memory");
+		return false;
+	}
+	CHECK_SETOPT(actx, CURLOPT_HTTPHEADER, actx->headers, return false);
+
+	return true;
+}
+
+/*
+ * Generic HTTP Request Handlers
+ */
+
+/*
+ * Response callback from libcurl which appends the response body into
+ * actx->work_data (see start_request()). The maximum size of the data is
+ * defined by CURL_MAX_WRITE_SIZE which by default is 16kb (and can only be
+ * changed by recompiling libcurl).
+ */
+static size_t
+append_data(char *buf, size_t size, size_t nmemb, void *userdata)
+{
+	struct async_ctx *actx = userdata;
+	PQExpBuffer resp = &actx->work_data;
+	size_t		len = size * nmemb;
+
+	/* In case we receive data over the threshold, abort the transfer */
+	if ((resp->len + len) > MAX_OAUTH_RESPONSE_SIZE)
+	{
+		actx_error(actx, "response is too large");
+		return 0;
+	}
+
+	/* The data passed from libcurl is not null-terminated */
+	appendBinaryPQExpBuffer(resp, buf, len);
+
+	/*
+	 * Signal an error in order to abort the transfer in case we ran out of
+	 * memory in accepting the data.
+	 */
+	if (PQExpBufferBroken(resp))
+	{
+		actx_error(actx, "out of memory");
+		return 0;
+	}
+
+	return len;
+}
+
+/*
+ * Begins an HTTP request on the multi handle. The caller should have set up all
+ * request-specific options on actx->curl first. The server's response body will
+ * be accumulated in actx->work_data (which will be reset, so don't store
+ * anything important there across this call).
+ *
+ * Once a request is queued, it can be driven to completion via drive_request().
+ * If actx->running is zero upon return, the request has already finished and
+ * drive_request() can be called without returning control to the client.
+ */
+static bool
+start_request(struct async_ctx *actx)
+{
+	CURLMcode	err;
+
+	resetPQExpBuffer(&actx->work_data);
+	CHECK_SETOPT(actx, CURLOPT_WRITEFUNCTION, append_data, return false);
+	CHECK_SETOPT(actx, CURLOPT_WRITEDATA, actx, return false);
+
+	err = curl_multi_add_handle(actx->curlm, actx->curl);
+	if (err)
+	{
+		actx_error(actx, "failed to queue HTTP request: %s",
+				   curl_multi_strerror(err));
+		return false;
+	}
+
+	/*
+	 * actx->running tracks the number of running handles, so we can
+	 * immediately call back if no waiting is needed.
+	 *
+	 * Even though this is nominally an asynchronous process, there are some
+	 * operations that can synchronously fail by this point (e.g. connections
+	 * to closed local ports) or even synchronously succeed if the stars align
+	 * (all the libcurl connection caches hit and the server is fast).
+	 */
+	err = curl_multi_socket_action(actx->curlm, CURL_SOCKET_TIMEOUT, 0, &actx->running);
+	if (err)
+	{
+		actx_error(actx, "asynchronous HTTP request failed: %s",
+				   curl_multi_strerror(err));
+		return false;
+	}
+
+	return true;
+}
+
+/*
+ * CURL_IGNORE_DEPRECATION was added in 7.87.0. If it's not defined, we can make
+ * it a no-op.
+ */
+#ifndef CURL_IGNORE_DEPRECATION
+#define CURL_IGNORE_DEPRECATION(x) x
+#endif
+
+/*
+ * Drives the multi handle towards completion. The caller should have already
+ * set up an asynchronous request via start_request().
+ */
+static PostgresPollingStatusType
+drive_request(struct async_ctx *actx)
+{
+	CURLMcode	err;
+	CURLMsg    *msg;
+	int			msgs_left;
+	bool		done;
+
+	if (actx->running)
+	{
+		/*---
+		 * There's an async request in progress. Pump the multi handle.
+		 *
+		 * curl_multi_socket_all() is officially deprecated, because it's
+		 * inefficient and pointless if your event loop has already handed you
+		 * the exact sockets that are ready. But that's not our use case --
+		 * our client has no way to tell us which sockets are ready. (They
+		 * don't even know there are sockets to begin with.)
+		 *
+		 * We can grab the list of triggered events from the multiplexer
+		 * ourselves, but that's effectively what curl_multi_socket_all() is
+		 * going to do. And there are currently no plans for the Curl project
+		 * to remove or break this API, so ignore the deprecation. See
+		 *
+		 *    https://curl.se/mail/lib-2024-11/0028.html
+		 *
+		 */
+		CURL_IGNORE_DEPRECATION(
+			err = curl_multi_socket_all(actx->curlm, &actx->running);
+		)
+
+		if (err)
+		{
+			actx_error(actx, "asynchronous HTTP request failed: %s",
+					   curl_multi_strerror(err));
+			return PGRES_POLLING_FAILED;
+		}
+
+		if (actx->running)
+		{
+			/* We'll come back again. */
+			return PGRES_POLLING_READING;
+		}
+	}
+
+	done = false;
+	while ((msg = curl_multi_info_read(actx->curlm, &msgs_left)) != NULL)
+	{
+		if (msg->msg != CURLMSG_DONE)
+		{
+			/*
+			 * Future libcurl versions may define new message types; we don't
+			 * know how to handle them, so we'll ignore them.
+			 */
+			continue;
+		}
+
+		/* First check the status of the request itself. */
+		if (msg->data.result != CURLE_OK)
+		{
+			/*
+			 * If a more specific error hasn't already been reported, use
+			 * libcurl's description.
+			 */
+			if (actx->errbuf.len == 0)
+				actx_error_str(actx, curl_easy_strerror(msg->data.result));
+
+			return PGRES_POLLING_FAILED;
+		}
+
+		/* Now remove the finished handle; we'll add it back later if needed. */
+		err = curl_multi_remove_handle(actx->curlm, msg->easy_handle);
+		if (err)
+		{
+			actx_error(actx, "libcurl easy handle removal failed: %s",
+					   curl_multi_strerror(err));
+			return PGRES_POLLING_FAILED;
+		}
+
+		done = true;
+	}
+
+	/* Sanity check. */
+	if (!done)
+	{
+		actx_error(actx, "no result was retrieved for the finished handle");
+		return PGRES_POLLING_FAILED;
+	}
+
+	return PGRES_POLLING_OK;
+}
+
+/*
+ * URL-Encoding Helpers
+ */
+
+/*
+ * Encodes a string using the application/x-www-form-urlencoded format, and
+ * appends it to the given buffer.
+ */
+static void
+append_urlencoded(PQExpBuffer buf, const char *s)
+{
+	char	   *escaped;
+	char	   *haystack;
+	char	   *match;
+
+	escaped = curl_easy_escape(NULL, s, 0);
+	if (!escaped)
+	{
+		termPQExpBuffer(buf);	/* mark the buffer broken */
+		return;
+	}
+
+	/*
+	 * curl_easy_escape() almost does what we want, but we need the
+	 * query-specific flavor which uses '+' instead of '%20' for spaces. The
+	 * Curl command-line tool does this with a simple search-and-replace, so
+	 * follow its lead.
+	 */
+	haystack = escaped;
+
+	while ((match = strstr(haystack, "%20")) != NULL)
+	{
+		/* Append the unmatched portion, followed by the plus sign. */
+		appendBinaryPQExpBuffer(buf, haystack, match - haystack);
+		appendPQExpBufferChar(buf, '+');
+
+		/* Keep searching after the match. */
+		haystack = match + 3 /* strlen("%20") */ ;
+	}
+
+	/* Push the remainder of the string onto the buffer. */
+	appendPQExpBufferStr(buf, haystack);
+
+	curl_free(escaped);
+}
+
+/*
+ * Convenience wrapper for encoding a single string. Returns NULL on allocation
+ * failure.
+ */
+static char *
+urlencode(const char *s)
+{
+	PQExpBufferData buf;
+
+	initPQExpBuffer(&buf);
+	append_urlencoded(&buf, s);
+
+	return PQExpBufferDataBroken(buf) ? NULL : buf.data;
+}
+
+/*
+ * Appends a key/value pair to the end of an application/x-www-form-urlencoded
+ * list.
+ */
+static void
+build_urlencoded(PQExpBuffer buf, const char *key, const char *value)
+{
+	if (buf->len)
+		appendPQExpBufferChar(buf, '&');
+
+	append_urlencoded(buf, key);
+	appendPQExpBufferChar(buf, '=');
+	append_urlencoded(buf, value);
+}
+
+/*
+ * Specific HTTP Request Handlers
+ *
+ * This is finally the beginning of the actual application logic. Generally
+ * speaking, a single request consists of a start_* and a finish_* step, with
+ * drive_request() pumping the machine in between.
+ */
+
+/*
+ * Queue an OpenID Provider Configuration Request:
+ *
+ *     https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationRequest
+ *     https://www.rfc-editor.org/rfc/rfc8414#section-3.1
+ *
+ * This is done first to get the endpoint URIs we need to contact and to make
+ * sure the provider provides a device authorization flow. finish_discovery()
+ * will fill in actx->provider.
+ */
+static bool
+start_discovery(struct async_ctx *actx, const char *discovery_uri)
+{
+	CHECK_SETOPT(actx, CURLOPT_HTTPGET, 1L, return false);
+	CHECK_SETOPT(actx, CURLOPT_URL, discovery_uri, return false);
+
+	return start_request(actx);
+}
+
+static bool
+finish_discovery(struct async_ctx *actx)
+{
+	long		response_code;
+
+	/*----
+	 * Now check the response. OIDC Discovery 1.0 is pretty strict:
+	 *
+	 *     A successful response MUST use the 200 OK HTTP status code and
+	 *     return a JSON object using the application/json content type that
+	 *     contains a set of Claims as its members that are a subset of the
+	 *     Metadata values defined in Section 3.
+	 *
+	 * Compared to standard HTTP semantics, this makes life easy -- we don't
+	 * need to worry about redirections (which would call the Issuer host
+	 * validation into question), or non-authoritative responses, or any other
+	 * complications.
+	 */
+	CHECK_GETINFO(actx, CURLINFO_RESPONSE_CODE, &response_code, return false);
+
+	if (response_code != 200)
+	{
+		actx_error(actx, "unexpected response code %ld", response_code);
+		return false;
+	}
+
+	/*
+	 * Pull the fields we care about from the document.
+	 */
+	actx->errctx = "failed to parse OpenID discovery document";
+	if (!parse_provider(actx, &actx->provider))
+		return false;			/* error message already set */
+
+	/*
+	 * Fill in any defaults for OPTIONAL/RECOMMENDED fields we care about.
+	 */
+	if (!actx->provider.grant_types_supported)
+	{
+		/*
+		 * Per Section 3, the default is ["authorization_code", "implicit"].
+		 */
+		struct curl_slist *temp = actx->provider.grant_types_supported;
+
+		temp = curl_slist_append(temp, "authorization_code");
+		if (temp)
+		{
+			temp = curl_slist_append(temp, "implicit");
+		}
+
+		if (!temp)
+		{
+			actx_error(actx, "out of memory");
+			return false;
+		}
+
+		actx->provider.grant_types_supported = temp;
+	}
+
+	return true;
+}
+
+/*
+ * Ensure that the discovery document is provided by the expected issuer.
+ * Currently, issuers are statically configured in the connection string.
+ */
+static bool
+check_issuer(struct async_ctx *actx, PGconn *conn)
+{
+	const struct provider *provider = &actx->provider;
+
+	Assert(conn->oauth_issuer_id);	/* ensured by setup_oauth_parameters() */
+	Assert(provider->issuer);	/* ensured by parse_provider() */
+
+	/*---
+	 * We require strict equality for issuer identifiers -- no path or case
+	 * normalization, no substitution of default ports and schemes, etc. This
+	 * is done to match the rules in OIDC Discovery Sec. 4.3 for config
+	 * validation:
+	 *
+	 *    The issuer value returned MUST be identical to the Issuer URL that
+	 *    was used as the prefix to /.well-known/openid-configuration to
+	 *    retrieve the configuration information.
+	 *
+	 * as well as the rules set out in RFC 9207 for avoiding mix-up attacks:
+	 *
+	 *    Clients MUST then [...] compare the result to the issuer identifier
+	 *    of the authorization server where the authorization request was
+	 *    sent to. This comparison MUST use simple string comparison as defined
+	 *    in Section 6.2.1 of [RFC3986].
+	 *
+	 * TODO: Encoding support?
+	 */
+	if (strcmp(conn->oauth_issuer_id, provider->issuer) != 0)
+	{
+		actx_error(actx,
+				   "the issuer identifier (%s) does not match oauth_issuer (%s)",
+				   provider->issuer, conn->oauth_issuer_id);
+		return false;
+	}
+
+	return true;
+}
+
+#define HTTPS_SCHEME "https://"
+#define OAUTH_GRANT_TYPE_DEVICE_CODE "urn:ietf:params:oauth:grant-type:device_code"
+
+/*
+ * Ensure that the provider supports the Device Authorization flow (i.e. it
+ * provides an authorization endpoint, and both the token and authorization
+ * endpoint URLs seem reasonable).
+ */
+static bool
+check_for_device_flow(struct async_ctx *actx)
+{
+	const struct provider *provider = &actx->provider;
+
+	Assert(provider->issuer);	/* ensured by parse_provider() */
+	Assert(provider->token_endpoint);	/* ensured by parse_provider() */
+
+	if (!provider->device_authorization_endpoint)
+	{
+		actx_error(actx,
+				   "issuer \"%s\" does not provide a device authorization endpoint",
+				   provider->issuer);
+		return false;
+	}
+
+	/*
+	 * The original implementation checked that OAUTH_GRANT_TYPE_DEVICE_CODE
+	 * was present in the discovery document's grant_types_supported list. MS
+	 * Entra does not advertise this grant type, though, and since it doesn't
+	 * make sense to stand up a device_authorization_endpoint without also
+	 * accepting device codes at the token_endpoint, that's the only thing we
+	 * currently require.
+	 */
+
+	/*
+	 * Although libcurl will fail later if the URL contains an unsupported
+	 * scheme, that error message is going to be a bit opaque. This is a
+	 * decent time to bail out if we're not using HTTPS for the endpoints
+	 * we'll use for the flow.
+	 */
+	if (!actx->debugging)
+	{
+		if (pg_strncasecmp(provider->device_authorization_endpoint,
+						   HTTPS_SCHEME, strlen(HTTPS_SCHEME)) != 0)
+		{
+			actx_error(actx,
+					   "device authorization endpoint \"%s\" must use HTTPS",
+					   provider->device_authorization_endpoint);
+			return false;
+		}
+
+		if (pg_strncasecmp(provider->token_endpoint,
+						   HTTPS_SCHEME, strlen(HTTPS_SCHEME)) != 0)
+		{
+			actx_error(actx,
+					   "token endpoint \"%s\" must use HTTPS",
+					   provider->token_endpoint);
+			return false;
+		}
+	}
+
+	return true;
+}
+
+/*
+ * Adds the client ID (and secret, if provided) to the current request, using
+ * either HTTP headers or the request body.
+ */
+static bool
+add_client_identification(struct async_ctx *actx, PQExpBuffer reqbody, PGconn *conn)
+{
+	bool		success = false;
+	char	   *username = NULL;
+	char	   *password = NULL;
+
+	if (conn->oauth_client_secret)	/* Zero-length secrets are permitted! */
+	{
+		/*----
+		 * Use HTTP Basic auth to send the client_id and secret. Per RFC 6749,
+		 * Sec. 2.3.1,
+		 *
+		 *   Including the client credentials in the request-body using the
+		 *   two parameters is NOT RECOMMENDED and SHOULD be limited to
+		 *   clients unable to directly utilize the HTTP Basic authentication
+		 *   scheme (or other password-based HTTP authentication schemes).
+		 *
+		 * Additionally:
+		 *
+		 *   The client identifier is encoded using the
+		 *   "application/x-www-form-urlencoded" encoding algorithm per Appendix
+		 *   B, and the encoded value is used as the username; the client
+		 *   password is encoded using the same algorithm and used as the
+		 *   password.
+		 *
+		 * (Appendix B modifies application/x-www-form-urlencoded by requiring
+		 * an initial UTF-8 encoding step. Since the client ID and secret must
+		 * both be 7-bit ASCII -- RFC 6749 Appendix A -- we don't worry about
+		 * that in this function.)
+		 *
+		 * client_id is not added to the request body in this case. Not only
+		 * would it be redundant, but some providers in the wild (e.g. Okta)
+		 * refuse to accept it.
+		 */
+		username = urlencode(conn->oauth_client_id);
+		password = urlencode(conn->oauth_client_secret);
+
+		if (!username || !password)
+		{
+			actx_error(actx, "out of memory");
+			goto cleanup;
+		}
+
+		CHECK_SETOPT(actx, CURLOPT_HTTPAUTH, CURLAUTH_BASIC, goto cleanup);
+		CHECK_SETOPT(actx, CURLOPT_USERNAME, username, goto cleanup);
+		CHECK_SETOPT(actx, CURLOPT_PASSWORD, password, goto cleanup);
+
+		actx->used_basic_auth = true;
+	}
+	else
+	{
+		/*
+		 * If we're not otherwise authenticating, client_id is REQUIRED in the
+		 * request body.
+		 */
+		build_urlencoded(reqbody, "client_id", conn->oauth_client_id);
+
+		CHECK_SETOPT(actx, CURLOPT_HTTPAUTH, CURLAUTH_NONE, goto cleanup);
+		actx->used_basic_auth = false;
+	}
+
+	success = true;
+
+cleanup:
+	free(username);
+	free(password);
+
+	return success;
+}
+
+/*
+ * Queue a Device Authorization Request:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc8628#section-3.1
+ *
+ * This is the second step. We ask the provider to verify the end user out of
+ * band and authorize us to act on their behalf; it will give us the required
+ * nonces for us to later poll the request status, which we'll grab in
+ * finish_device_authz().
+ */
+static bool
+start_device_authz(struct async_ctx *actx, PGconn *conn)
+{
+	const char *device_authz_uri = actx->provider.device_authorization_endpoint;
+	PQExpBuffer work_buffer = &actx->work_data;
+
+	Assert(conn->oauth_client_id);	/* ensured by setup_oauth_parameters() */
+	Assert(device_authz_uri);	/* ensured by check_for_device_flow() */
+
+	/* Construct our request body. */
+	resetPQExpBuffer(work_buffer);
+	if (conn->oauth_scope && conn->oauth_scope[0])
+		build_urlencoded(work_buffer, "scope", conn->oauth_scope);
+
+	if (!add_client_identification(actx, work_buffer, conn))
+		return false;
+
+	if (PQExpBufferBroken(work_buffer))
+	{
+		actx_error(actx, "out of memory");
+		return false;
+	}
+
+	/* Make our request. */
+	CHECK_SETOPT(actx, CURLOPT_URL, device_authz_uri, return false);
+	CHECK_SETOPT(actx, CURLOPT_COPYPOSTFIELDS, work_buffer->data, return false);
+
+	return start_request(actx);
+}
+
+static bool
+finish_device_authz(struct async_ctx *actx)
+{
+	long		response_code;
+
+	CHECK_GETINFO(actx, CURLINFO_RESPONSE_CODE, &response_code, return false);
+
+	/*
+	 * Per RFC 8628, Section 3, a successful device authorization response
+	 * uses 200 OK.
+	 */
+	if (response_code == 200)
+	{
+		actx->errctx = "failed to parse device authorization";
+		if (!parse_device_authz(actx, &actx->authz))
+			return false;		/* error message already set */
+
+		return true;
+	}
+
+	/*
+	 * The device authorization endpoint uses the same error response as the
+	 * token endpoint, so the error handling roughly follows
+	 * finish_token_request(). The key difference is that an error here is
+	 * immediately fatal.
+	 */
+	if (response_code == 400 || response_code == 401)
+	{
+		struct token_error err = {0};
+
+		if (!parse_token_error(actx, &err))
+		{
+			free_token_error(&err);
+			return false;
+		}
+
+		record_token_error(actx, &err);
+
+		free_token_error(&err);
+		return false;
+	}
+
+	/* Any other response codes are considered invalid */
+	actx_error(actx, "unexpected response code %ld", response_code);
+	return false;
+}
+
+/*
+ * Queue an Access Token Request:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc6749#section-4.1.3
+ *
+ * This is the final step. We continually poll the token endpoint to see if the
+ * user has authorized us yet. finish_token_request() will pull either the token
+ * or a (ideally temporary) error status from the provider.
+ */
+static bool
+start_token_request(struct async_ctx *actx, PGconn *conn)
+{
+	const char *token_uri = actx->provider.token_endpoint;
+	const char *device_code = actx->authz.device_code;
+	PQExpBuffer work_buffer = &actx->work_data;
+
+	Assert(conn->oauth_client_id);	/* ensured by setup_oauth_parameters() */
+	Assert(token_uri);			/* ensured by parse_provider() */
+	Assert(device_code);		/* ensured by parse_device_authz() */
+
+	/* Construct our request body. */
+	resetPQExpBuffer(work_buffer);
+	build_urlencoded(work_buffer, "device_code", device_code);
+	build_urlencoded(work_buffer, "grant_type", OAUTH_GRANT_TYPE_DEVICE_CODE);
+
+	if (!add_client_identification(actx, work_buffer, conn))
+		return false;
+
+	if (PQExpBufferBroken(work_buffer))
+	{
+		actx_error(actx, "out of memory");
+		return false;
+	}
+
+	/* Make our request. */
+	CHECK_SETOPT(actx, CURLOPT_URL, token_uri, return false);
+	CHECK_SETOPT(actx, CURLOPT_COPYPOSTFIELDS, work_buffer->data, return false);
+
+	return start_request(actx);
+}
+
+static bool
+finish_token_request(struct async_ctx *actx, struct token *tok)
+{
+	long		response_code;
+
+	CHECK_GETINFO(actx, CURLINFO_RESPONSE_CODE, &response_code, return false);
+
+	/*
+	 * Per RFC 6749, Section 5, a successful response uses 200 OK.
+	 */
+	if (response_code == 200)
+	{
+		actx->errctx = "failed to parse access token response";
+		if (!parse_access_token(actx, tok))
+			return false;		/* error message already set */
+
+		return true;
+	}
+
+	/*
+	 * An error response uses either 400 Bad Request or 401 Unauthorized.
+	 * There are references online to implementations using 403 for error
+	 * return which would violate the specification. For now we stick to the
+	 * specification but we might have to revisit this.
+	 */
+	if (response_code == 400 || response_code == 401)
+	{
+		if (!parse_token_error(actx, &tok->err))
+			return false;
+
+		return true;
+	}
+
+	/* Any other response codes are considered invalid */
+	actx_error(actx, "unexpected response code %ld", response_code);
+	return false;
+}
+
+/*
+ * Finishes the token request and examines the response. If the flow has
+ * completed, a valid token will be returned via the parameter list. Otherwise,
+ * the token parameter remains unchanged, and the caller needs to wait for
+ * another interval (which will have been increased in response to a slow_down
+ * message from the server) before starting a new token request.
+ *
+ * False is returned only for permanent error conditions.
+ */
+static bool
+handle_token_response(struct async_ctx *actx, char **token)
+{
+	bool		success = false;
+	struct token tok = {0};
+	const struct token_error *err;
+
+	if (!finish_token_request(actx, &tok))
+		goto token_cleanup;
+
+	/* A successful token request gives either a token or an in-band error. */
+	Assert(tok.access_token || tok.err.error);
+
+	if (tok.access_token)
+	{
+		*token = tok.access_token;
+		tok.access_token = NULL;
+
+		success = true;
+		goto token_cleanup;
+	}
+
+	/*
+	 * authorization_pending and slow_down are the only acceptable errors;
+	 * anything else and we bail. These are defined in RFC 8628, Sec. 3.5.
+	 */
+	err = &tok.err;
+	if (strcmp(err->error, "authorization_pending") != 0 &&
+		strcmp(err->error, "slow_down") != 0)
+	{
+		record_token_error(actx, err);
+		goto token_cleanup;
+	}
+
+	/*
+	 * A slow_down error requires us to permanently increase our retry
+	 * interval by five seconds.
+	 */
+	if (strcmp(err->error, "slow_down") == 0)
+	{
+		int			prev_interval = actx->authz.interval;
+
+		actx->authz.interval += 5;
+		if (actx->authz.interval < prev_interval)
+		{
+			actx_error(actx, "slow_down interval overflow");
+			goto token_cleanup;
+		}
+	}
+
+	success = true;
+
+token_cleanup:
+	free_token(&tok);
+	return success;
+}
+
+/*
+ * Displays a device authorization prompt for action by the end user, either via
+ * the PQauthDataHook, or by a message on standard error if no hook is set.
+ */
+static bool
+prompt_user(struct async_ctx *actx, PGconn *conn)
+{
+	int			res;
+	PGpromptOAuthDevice prompt = {
+		.verification_uri = actx->authz.verification_uri,
+		.user_code = actx->authz.user_code,
+		/* TODO: optional fields */
+	};
+
+	res = PQauthDataHook(PQAUTHDATA_PROMPT_OAUTH_DEVICE, conn, &prompt);
+
+	if (!res)
+	{
+		/*
+		 * translator: The first %s is a URL for the user to visit in a
+		 * browser, and the second %s is a code to be copy-pasted there.
+		 */
+		fprintf(stderr, libpq_gettext("Visit %s and enter the code: %s\n"),
+				prompt.verification_uri, prompt.user_code);
+	}
+	else if (res < 0)
+	{
+		actx_error(actx, "device prompt failed");
+		return false;
+	}
+
+	return true;
+}
+
+/*
+ * Calls curl_global_init() in a thread-safe way.
+ *
+ * libcurl has stringent requirements for the thread context in which you call
+ * curl_global_init(), because it's going to try initializing a bunch of other
+ * libraries (OpenSSL, Winsock, etc). Recent versions of libcurl have improved
+ * the thread-safety situation, but there's a chicken-and-egg problem at
+ * runtime: you can't check the thread safety until you've initialized libcurl,
+ * which you can't do from within a thread unless you know it's thread-safe...
+ *
+ * Returns true if initialization was successful. Successful or not, this
+ * function will not try to reinitialize Curl on successive calls.
+ */
+static bool
+initialize_curl(PGconn *conn)
+{
+	/*
+	 * Don't let the compiler play tricks with this variable. In the
+	 * HAVE_THREADSAFE_CURL_GLOBAL_INIT case, we don't care if two threads
+	 * enter simultaneously, but we do care if this gets set transiently to
+	 * PG_BOOL_YES/NO in cases where that's not the final answer.
+	 */
+	static volatile PGTernaryBool init_successful = PG_BOOL_UNKNOWN;
+#if HAVE_THREADSAFE_CURL_GLOBAL_INIT
+	curl_version_info_data *info;
+#endif
+
+#if !HAVE_THREADSAFE_CURL_GLOBAL_INIT
+
+	/*
+	 * Lock around the whole function. If a libpq client performs its own work
+	 * with libcurl, it must either ensure that Curl is initialized safely
+	 * before calling us (in which case our call will be a no-op), or else it
+	 * must guard its own calls to curl_global_init() with a registered
+	 * threadlock handler. See PQregisterThreadLock().
+	 */
+	pglock_thread();
+#endif
+
+	/*
+	 * Skip initialization if we've already done it. (Curl tracks the number
+	 * of calls; there's no point in incrementing the counter every time we
+	 * connect.)
+	 */
+	if (init_successful == PG_BOOL_YES)
+		goto done;
+	else if (init_successful == PG_BOOL_NO)
+	{
+		libpq_append_conn_error(conn,
+								"curl_global_init previously failed during OAuth setup");
+		goto done;
+	}
+
+	/*
+	 * We know we've already initialized Winsock by this point (see
+	 * pqMakeEmptyPGconn()), so we should be able to safely skip that bit. But
+	 * we have to tell libcurl to initialize everything else, because other
+	 * pieces of our client executable may already be using libcurl for their
+	 * own purposes. If we initialize libcurl with only a subset of its
+	 * features, we could break those other clients nondeterministically, and
+	 * that would probably be a nightmare to debug.
+	 *
+	 * If some other part of the program has already called this, it's a
+	 * no-op.
+	 */
+	if (curl_global_init(CURL_GLOBAL_ALL & ~CURL_GLOBAL_WIN32) != CURLE_OK)
+	{
+		libpq_append_conn_error(conn,
+								"curl_global_init failed during OAuth setup");
+		init_successful = PG_BOOL_NO;
+		goto done;
+	}
+
+#if HAVE_THREADSAFE_CURL_GLOBAL_INIT
+
+	/*
+	 * If we determined at configure time that the Curl installation is
+	 * threadsafe, our job here is much easier. We simply initialize above
+	 * without any locking (concurrent or duplicated calls are fine in that
+	 * situation), then double-check to make sure the runtime setting agrees,
+	 * to try to catch silent downgrades.
+	 */
+	info = curl_version_info(CURLVERSION_NOW);
+	if (!(info->features & CURL_VERSION_THREADSAFE))
+	{
+		/*
+		 * 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 threadsafe\n"
+								"\tCurl initialization was reported threadsafe 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;
+	}
+#endif
+
+	init_successful = PG_BOOL_YES;
+
+done:
+#if !HAVE_THREADSAFE_CURL_GLOBAL_INIT
+	pgunlock_thread();
+#endif
+	return (init_successful == PG_BOOL_YES);
+}
+
+/*
+ * The core nonblocking libcurl implementation. This will be called several
+ * times to pump the async engine.
+ *
+ * The architecture is based on PQconnectPoll(). The first half drives the
+ * connection state forward as necessary, returning if we're not ready to
+ * proceed to the next step yet. The second half performs the actual transition
+ * between states.
+ *
+ * You can trace the overall OAuth flow through the second half. It's linear
+ * until we get to the end, where we flip back and forth between
+ * OAUTH_STEP_TOKEN_REQUEST and OAUTH_STEP_WAIT_INTERVAL to regularly ping the
+ * provider.
+ */
+static PostgresPollingStatusType
+pg_fe_run_oauth_flow_impl(PGconn *conn)
+{
+	fe_oauth_state *state = conn->sasl_state;
+	struct async_ctx *actx;
+
+	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;
+#ifdef HAVE_SYS_EPOLL_H
+		actx->timerfd = -1;
+#endif
+
+		/* 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. */
+		conn->altsock = actx->mux;
+
+		switch (actx->step)
+		{
+			case OAUTH_STEP_INIT:
+				break;
+
+			case OAUTH_STEP_DISCOVERY:
+			case OAUTH_STEP_DEVICE_AUTHORIZATION:
+			case OAUTH_STEP_TOKEN_REQUEST:
+				{
+					PostgresPollingStatusType status;
+
+					status = drive_request(actx);
+
+					if (status == PGRES_POLLING_FAILED)
+						goto error_return;
+					else if (status != PGRES_POLLING_OK)
+					{
+						/* not done yet */
+						return status;
+					}
+				}
+
+			case OAUTH_STEP_WAIT_INTERVAL:
+				/* TODO check that the timer has expired */
+				break;
+		}
+
+		/*
+		 * Each case here must ensure that actx->running is set while we're
+		 * waiting on some asynchronous work. Most cases rely on
+		 * start_request() to do that for them.
+		 */
+		switch (actx->step)
+		{
+			case OAUTH_STEP_INIT:
+				actx->errctx = "failed to fetch OpenID discovery document";
+				if (!start_discovery(actx, conn->oauth_discovery_uri))
+					goto error_return;
+
+				actx->step = OAUTH_STEP_DISCOVERY;
+				break;
+
+			case OAUTH_STEP_DISCOVERY:
+				if (!finish_discovery(actx))
+					goto error_return;
+
+				if (!check_issuer(actx, conn))
+					goto error_return;
+
+				actx->errctx = "cannot run OAuth device authorization";
+				if (!check_for_device_flow(actx))
+					goto error_return;
+
+				actx->errctx = "failed to obtain device authorization";
+				if (!start_device_authz(actx, conn))
+					goto error_return;
+
+				actx->step = OAUTH_STEP_DEVICE_AUTHORIZATION;
+				break;
+
+			case OAUTH_STEP_DEVICE_AUTHORIZATION:
+				if (!finish_device_authz(actx))
+					goto error_return;
+
+				actx->errctx = "failed to obtain access token";
+				if (!start_token_request(actx, conn))
+					goto error_return;
+
+				actx->step = OAUTH_STEP_TOKEN_REQUEST;
+				break;
+
+			case OAUTH_STEP_TOKEN_REQUEST:
+				if (!handle_token_response(actx, &conn->oauth_token))
+					goto error_return;
+
+				if (!actx->user_prompted)
+				{
+					/*
+					 * Now that we know the token endpoint isn't broken, give
+					 * the user the login instructions.
+					 */
+					if (!prompt_user(actx, conn))
+						goto error_return;
+
+					actx->user_prompted = true;
+				}
+
+				if (conn->oauth_token)
+					break;		/* done! */
+
+				/*
+				 * Wait for the required interval before issuing the next
+				 * request.
+				 */
+				if (!set_timer(actx, actx->authz.interval * 1000))
+					goto error_return;
+
+#ifdef HAVE_SYS_EPOLL_H
+
+				/*
+				 * No Curl requests are running, so we can simplify by having
+				 * the client wait directly on the timerfd rather than the
+				 * multiplexer. (This isn't possible for kqueue.)
+				 */
+				conn->altsock = actx->timerfd;
+#endif
+
+				actx->step = OAUTH_STEP_WAIT_INTERVAL;
+				actx->running = 1;
+				break;
+
+			case OAUTH_STEP_WAIT_INTERVAL:
+				actx->errctx = "failed to obtain access token";
+				if (!start_token_request(actx, conn))
+					goto error_return;
+
+				actx->step = OAUTH_STEP_TOKEN_REQUEST;
+				break;
+		}
+
+		/*
+		 * The vast majority of the time, if we don't have a token at this
+		 * point, actx->running will be set. But there are some corner cases
+		 * where we can immediately loop back around; see start_request().
+		 */
+	} while (!conn->oauth_token && !actx->running);
+
+	/* If we've stored a token, we're done. Otherwise come back later. */
+	return conn->oauth_token ? PGRES_POLLING_OK : PGRES_POLLING_READING;
+
+error_return:
+
+	/*
+	 * Assemble the three parts of our error: context, body, and detail. See
+	 * also the documentation for struct async_ctx.
+	 */
+	if (actx->errctx)
+	{
+		appendPQExpBufferStr(&conn->errorMessage,
+							 libpq_gettext(actx->errctx));
+		appendPQExpBufferStr(&conn->errorMessage, ": ");
+	}
+
+	if (PQExpBufferDataBroken(actx->errbuf))
+		appendPQExpBufferStr(&conn->errorMessage,
+							 libpq_gettext("out of memory"));
+	else
+		appendPQExpBufferStr(&conn->errorMessage, actx->errbuf.data);
+
+	if (actx->curl_err[0])
+	{
+		size_t		len;
+
+		appendPQExpBuffer(&conn->errorMessage, " (%s)", actx->curl_err);
+
+		/* Sometimes libcurl adds a newline to the error buffer. :( */
+		len = conn->errorMessage.len;
+		if (len >= 2 && conn->errorMessage.data[len - 2] == '\n')
+		{
+			conn->errorMessage.data[len - 2] = ')';
+			conn->errorMessage.data[len - 1] = '\0';
+			conn->errorMessage.len--;
+		}
+	}
+
+	appendPQExpBufferStr(&conn->errorMessage, "\n");
+
+	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.
+ */
+PostgresPollingStatusType
+pg_fe_run_oauth_flow(PGconn *conn)
+{
+	PostgresPollingStatusType result;
+#ifndef WIN32
+	sigset_t	osigset;
+	bool		sigpipe_pending;
+	bool		masked;
+
+	/*---
+	 * Ignore SIGPIPE on this thread during all Curl processing.
+	 *
+	 * Because we support multiple threads, we have to set up libcurl with
+	 * CURLOPT_NOSIGNAL, which disables its default global handling of
+	 * SIGPIPE. From the Curl docs:
+	 *
+	 *     libcurl makes an effort to never cause such SIGPIPE signals to
+	 *     trigger, but some operating systems have no way to avoid them and
+	 *     even on those that have there are some corner cases when they may
+	 *     still happen, contrary to our desire.
+	 *
+	 * Note that libcurl is also at the mercy of its DNS resolution and SSL
+	 * libraries; if any of them forget a MSG_NOSIGNAL then we're in trouble.
+	 * Modern platforms and libraries seem to get it right, so this is a
+	 * difficult corner case to exercise in practice, and unfortunately it's
+	 * not really clear whether it's necessary in all cases.
+	 */
+	masked = (pq_block_sigpipe(&osigset, &sigpipe_pending) == 0);
+#endif
+
+	result = pg_fe_run_oauth_flow_impl(conn);
+
+#ifndef WIN32
+	if (masked)
+	{
+		/*
+		 * Undo the SIGPIPE mask. Assume we may have gotten EPIPE (we have no
+		 * way of knowing at this level).
+		 */
+		pq_reset_sigpipe(&osigset, sigpipe_pending, true /* EPIPE, maybe */ );
+	}
+#endif
+
+	return result;
+}
diff --git a/src/interfaces/libpq/fe-auth-oauth.c b/src/interfaces/libpq/fe-auth-oauth.c
new file mode 100644
index 00000000000..cc53e2bdd1a
--- /dev/null
+++ b/src/interfaces/libpq/fe-auth-oauth.c
@@ -0,0 +1,1141 @@
+/*-------------------------------------------------------------------------
+ *
+ * fe-auth-oauth.c
+ *	   The front-end (client) implementation of OAuth/OIDC authentication
+ *	   using the SASL OAUTHBEARER mechanism.
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/interfaces/libpq/fe-auth-oauth.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres_fe.h"
+
+#include "common/base64.h"
+#include "common/hmac.h"
+#include "common/jsonapi.h"
+#include "common/oauth-common.h"
+#include "fe-auth.h"
+#include "fe-auth-oauth.h"
+#include "mb/pg_wchar.h"
+
+/* The exported OAuth callback mechanism. */
+static void *oauth_init(PGconn *conn, const char *password,
+						const char *sasl_mechanism);
+static SASLStatus oauth_exchange(void *opaq, bool final,
+								 char *input, int inputlen,
+								 char **output, int *outputlen);
+static bool oauth_channel_bound(void *opaq);
+static void oauth_free(void *opaq);
+
+const pg_fe_sasl_mech pg_oauth_mech = {
+	oauth_init,
+	oauth_exchange,
+	oauth_channel_bound,
+	oauth_free,
+};
+
+/*
+ * Initializes mechanism state for OAUTHBEARER.
+ *
+ * For a full description of the API, see libpq/fe-auth-sasl.h.
+ */
+static void *
+oauth_init(PGconn *conn, const char *password,
+		   const char *sasl_mechanism)
+{
+	fe_oauth_state *state;
+
+	/*
+	 * We only support one SASL mechanism here; anything else is programmer
+	 * error.
+	 */
+	Assert(sasl_mechanism != NULL);
+	Assert(strcmp(sasl_mechanism, OAUTHBEARER_NAME) == 0);
+
+	state = calloc(1, sizeof(*state));
+	if (!state)
+		return NULL;
+
+	state->step = FE_OAUTH_INIT;
+	state->conn = conn;
+
+	return state;
+}
+
+/*
+ * Frees the state allocated by oauth_init().
+ *
+ * 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().
+ */
+static void
+oauth_free(void *opaq)
+{
+	fe_oauth_state *state = opaq;
+
+	/* Any async authentication state should have been cleaned up already. */
+	Assert(!state->async_ctx);
+
+	free(state);
+}
+
+#define kvsep "\x01"
+
+/*
+ * Constructs an OAUTHBEARER client initial response (RFC 7628, Sec. 3.1).
+ *
+ * If discover is true, the initial response will contain a request for the
+ * server's required OAuth parameters (Sec. 4.3). Otherwise, conn->token must
+ * be set; it will be sent as the connection's bearer token.
+ *
+ * Returns the response as a null-terminated string, or NULL on error.
+ */
+static char *
+client_initial_response(PGconn *conn, bool discover)
+{
+	static const char *const resp_format = "n,," kvsep "auth=%s%s" kvsep kvsep;
+
+	PQExpBufferData buf;
+	const char *authn_scheme;
+	char	   *response = NULL;
+	const char *token = conn->oauth_token;
+
+	if (discover)
+	{
+		/* Parameter discovery uses a completely empty auth value. */
+		authn_scheme = token = "";
+	}
+	else
+	{
+		/*
+		 * Use a Bearer authentication scheme (RFC 6750, Sec. 2.1). A trailing
+		 * space is used as a separator.
+		 */
+		authn_scheme = "Bearer ";
+
+		/* conn->token must have been set in this case. */
+		if (!token)
+		{
+			Assert(false);
+			libpq_append_conn_error(conn,
+									"internal error: no OAuth token was set for the connection");
+			return NULL;
+		}
+	}
+
+	initPQExpBuffer(&buf);
+	appendPQExpBuffer(&buf, resp_format, authn_scheme, token);
+
+	if (!PQExpBufferDataBroken(buf))
+		response = strdup(buf.data);
+	termPQExpBuffer(&buf);
+
+	if (!response)
+		libpq_append_conn_error(conn, "out of memory");
+
+	return response;
+}
+
+/*
+ * JSON Parser (for the OAUTHBEARER error result)
+ */
+
+/* Relevant JSON fields in the error result object. */
+#define ERROR_STATUS_FIELD "status"
+#define ERROR_SCOPE_FIELD "scope"
+#define ERROR_OPENID_CONFIGURATION_FIELD "openid-configuration"
+
+struct json_ctx
+{
+	char	   *errmsg;			/* any non-NULL value stops all processing */
+	PQExpBufferData errbuf;		/* backing memory for errmsg */
+	int			nested;			/* nesting level (zero is the top) */
+
+	const char *target_field_name;	/* points to a static allocation */
+	char	  **target_field;	/* see below */
+
+	/* target_field, if set, points to one of the following: */
+	char	   *status;
+	char	   *scope;
+	char	   *discovery_uri;
+};
+
+#define oauth_json_has_error(ctx) \
+	(PQExpBufferDataBroken((ctx)->errbuf) || (ctx)->errmsg)
+
+#define oauth_json_set_error(ctx, ...) \
+	do { \
+		appendPQExpBuffer(&(ctx)->errbuf, __VA_ARGS__); \
+		(ctx)->errmsg = (ctx)->errbuf.data; \
+	} while (0)
+
+static JsonParseErrorType
+oauth_json_object_start(void *state)
+{
+	struct json_ctx *ctx = state;
+
+	if (ctx->target_field)
+	{
+		Assert(ctx->nested == 1);
+
+		oauth_json_set_error(ctx,
+							 libpq_gettext("field \"%s\" must be a string"),
+							 ctx->target_field_name);
+	}
+
+	++ctx->nested;
+	return oauth_json_has_error(ctx) ? JSON_SEM_ACTION_FAILED : JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_object_end(void *state)
+{
+	struct json_ctx *ctx = state;
+
+	--ctx->nested;
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_object_field_start(void *state, char *name, bool isnull)
+{
+	struct json_ctx *ctx = state;
+
+	if (ctx->nested == 1)
+	{
+		if (strcmp(name, ERROR_STATUS_FIELD) == 0)
+		{
+			ctx->target_field_name = ERROR_STATUS_FIELD;
+			ctx->target_field = &ctx->status;
+		}
+		else if (strcmp(name, ERROR_SCOPE_FIELD) == 0)
+		{
+			ctx->target_field_name = ERROR_SCOPE_FIELD;
+			ctx->target_field = &ctx->scope;
+		}
+		else if (strcmp(name, ERROR_OPENID_CONFIGURATION_FIELD) == 0)
+		{
+			ctx->target_field_name = ERROR_OPENID_CONFIGURATION_FIELD;
+			ctx->target_field = &ctx->discovery_uri;
+		}
+	}
+
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_array_start(void *state)
+{
+	struct json_ctx *ctx = state;
+
+	if (!ctx->nested)
+	{
+		ctx->errmsg = libpq_gettext("top-level element must be an object");
+	}
+	else if (ctx->target_field)
+	{
+		Assert(ctx->nested == 1);
+
+		oauth_json_set_error(ctx,
+							 libpq_gettext("field \"%s\" must be a string"),
+							 ctx->target_field_name);
+	}
+
+	return oauth_json_has_error(ctx) ? JSON_SEM_ACTION_FAILED : JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_scalar(void *state, char *token, JsonTokenType type)
+{
+	struct json_ctx *ctx = state;
+
+	if (!ctx->nested)
+	{
+		ctx->errmsg = libpq_gettext("top-level element must be an object");
+		return JSON_SEM_ACTION_FAILED;
+	}
+
+	if (ctx->target_field)
+	{
+		Assert(ctx->nested == 1);
+
+		/*
+		 * We don't allow duplicate field names; error out if the target has
+		 * already been set.
+		 */
+		if (*ctx->target_field)
+		{
+			oauth_json_set_error(ctx,
+								 libpq_gettext("field \"%s\" is duplicated"),
+								 ctx->target_field_name);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		/* The only fields we support are strings. */
+		if (type != JSON_TOKEN_STRING)
+		{
+			oauth_json_set_error(ctx,
+								 libpq_gettext("field \"%s\" must be a string"),
+								 ctx->target_field_name);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		*ctx->target_field = strdup(token);
+		if (!*ctx->target_field)
+			return JSON_OUT_OF_MEMORY;
+
+		ctx->target_field = NULL;
+		ctx->target_field_name = NULL;
+	}
+	else
+	{
+		/* otherwise we just ignore it */
+	}
+
+	return JSON_SUCCESS;
+}
+
+#define HTTPS_SCHEME "https://"
+#define HTTP_SCHEME "http://"
+
+/* We support both well-known suffixes defined by RFC 8414. */
+#define WK_PREFIX "/.well-known/"
+#define OPENID_WK_SUFFIX "openid-configuration"
+#define OAUTH_WK_SUFFIX "oauth-authorization-server"
+
+/*
+ * Derives an issuer identifier from one of our recognized .well-known URIs,
+ * using the rules in RFC 8414.
+ */
+static char *
+issuer_from_well_known_uri(PGconn *conn, const char *wkuri)
+{
+	const char *authority_start = NULL;
+	const char *wk_start;
+	const char *wk_end;
+	char	   *issuer;
+	ptrdiff_t	start_offset,
+				end_offset;
+	size_t		end_len;
+
+	/*
+	 * https:// is required for issuer identifiers (RFC 8414, Sec. 2; OIDC
+	 * Discovery 1.0, Sec. 3). This is a case-insensitive comparison at this
+	 * level (but issuer identifier comparison at the level above this is
+	 * case-sensitive, so in practice it's probably moot).
+	 */
+	if (pg_strncasecmp(wkuri, HTTPS_SCHEME, strlen(HTTPS_SCHEME)) == 0)
+		authority_start = wkuri + strlen(HTTPS_SCHEME);
+
+	if (!authority_start
+		&& oauth_unsafe_debugging_enabled()
+		&& pg_strncasecmp(wkuri, HTTP_SCHEME, strlen(HTTP_SCHEME)) == 0)
+	{
+		/* Allow http:// for testing only. */
+		authority_start = wkuri + strlen(HTTP_SCHEME);
+	}
+
+	if (!authority_start)
+	{
+		libpq_append_conn_error(conn,
+								"OAuth discovery URI \"%s\" must use HTTPS",
+								wkuri);
+		return NULL;
+	}
+
+	/*
+	 * Well-known URIs in general may support queries and fragments, but the
+	 * two types we support here do not. (They must be constructed from the
+	 * components of issuer identifiers, which themselves may not contain any
+	 * queries or fragments.)
+	 *
+	 * It's important to check this first, to avoid getting tricked later by a
+	 * prefix buried inside a query or fragment.
+	 */
+	if (strpbrk(authority_start, "?#") != NULL)
+	{
+		libpq_append_conn_error(conn,
+								"OAuth discovery URI \"%s\" must not contain query or fragment components",
+								wkuri);
+		return NULL;
+	}
+
+	/*
+	 * Find the start of the .well-known prefix. IETF rules (RFC 8615) state
+	 * this must be at the beginning of the path component, but OIDC defined
+	 * it at the end instead (OIDC Discovery 1.0, Sec. 4), so we have to
+	 * search for it anywhere.
+	 */
+	wk_start = strstr(authority_start, WK_PREFIX);
+	if (!wk_start)
+	{
+		libpq_append_conn_error(conn,
+								"OAuth discovery URI \"%s\" is not a .well-known URI",
+								wkuri);
+		return NULL;
+	}
+
+	/*
+	 * Now find the suffix type. We only support the two defined in OIDC
+	 * Discovery 1.0 and RFC 8414.
+	 */
+	wk_end = wk_start + strlen(WK_PREFIX);
+
+	if (strncmp(wk_end, OPENID_WK_SUFFIX, strlen(OPENID_WK_SUFFIX)) == 0)
+		wk_end += strlen(OPENID_WK_SUFFIX);
+	else if (strncmp(wk_end, OAUTH_WK_SUFFIX, strlen(OAUTH_WK_SUFFIX)) == 0)
+		wk_end += strlen(OAUTH_WK_SUFFIX);
+	else
+		wk_end = NULL;
+
+	/*
+	 * Even if there's a match, we still need to check to make sure the suffix
+	 * takes up the entire path segment, to weed out constructions like
+	 * "/.well-known/openid-configuration-bad".
+	 */
+	if (!wk_end || (*wk_end != '/' && *wk_end != '\0'))
+	{
+		libpq_append_conn_error(conn,
+								"OAuth discovery URI \"%s\" uses an unsupported .well-known suffix",
+								wkuri);
+		return NULL;
+	}
+
+	/*
+	 * Finally, make sure the .well-known components are provided either as a
+	 * prefix (IETF style) or as a postfix (OIDC style). In other words,
+	 * "https://localhost/a/.well-known/openid-configuration/b" is not allowed
+	 * to claim association with "https://localhost/a/b".
+	 */
+	if (*wk_end != '\0')
+	{
+		/*
+		 * It's not at the end, so it's required to be at the beginning at the
+		 * path. Find the starting slash.
+		 */
+		const char *path_start;
+
+		path_start = strchr(authority_start, '/');
+		Assert(path_start);		/* otherwise we wouldn't have found WK_PREFIX */
+
+		if (wk_start != path_start)
+		{
+			libpq_append_conn_error(conn,
+									"OAuth discovery URI \"%s\" uses an invalid format",
+									wkuri);
+			return NULL;
+		}
+	}
+
+	/* Checks passed! Now build the issuer. */
+	issuer = strdup(wkuri);
+	if (!issuer)
+	{
+		libpq_append_conn_error(conn, "out of memory");
+		return NULL;
+	}
+
+	/*
+	 * The .well-known components are from [wk_start, wk_end). Remove those to
+	 * form the issuer ID, by shifting the path suffix (which may be empty)
+	 * leftwards.
+	 */
+	start_offset = wk_start - wkuri;
+	end_offset = wk_end - wkuri;
+	end_len = strlen(wk_end) + 1;	/* move the NULL terminator too */
+
+	memmove(issuer + start_offset, issuer + end_offset, end_len);
+
+	return issuer;
+}
+
+/*
+ * Parses the server error result (RFC 7628, Sec. 3.2.2) contained in msg and
+ * stores any discovered openid_configuration and scope settings for the
+ * connection.
+ */
+static bool
+handle_oauth_sasl_error(PGconn *conn, const char *msg, int msglen)
+{
+	JsonLexContext lex = {0};
+	JsonSemAction sem = {0};
+	JsonParseErrorType err;
+	struct json_ctx ctx = {0};
+	char	   *errmsg = NULL;
+	bool		success = false;
+
+	Assert(conn->oauth_issuer_id);	/* ensured by setup_oauth_parameters() */
+
+	/* Sanity check. */
+	if (strlen(msg) != msglen)
+	{
+		libpq_append_conn_error(conn,
+								"server's error message contained an embedded NULL, and was discarded");
+		return false;
+	}
+
+	/*
+	 * pg_parse_json doesn't validate the incoming UTF-8, so we have to check
+	 * that up front.
+	 */
+	if (pg_encoding_verifymbstr(PG_UTF8, msg, msglen) != msglen)
+	{
+		libpq_append_conn_error(conn,
+								"server's error response is not valid UTF-8");
+		return false;
+	}
+
+	makeJsonLexContextCstringLen(&lex, msg, msglen, PG_UTF8, true);
+	setJsonLexContextOwnsTokens(&lex, true);	/* must not leak on error */
+
+	initPQExpBuffer(&ctx.errbuf);
+	sem.semstate = &ctx;
+
+	sem.object_start = oauth_json_object_start;
+	sem.object_end = oauth_json_object_end;
+	sem.object_field_start = oauth_json_object_field_start;
+	sem.array_start = oauth_json_array_start;
+	sem.scalar = oauth_json_scalar;
+
+	err = pg_parse_json(&lex, &sem);
+
+	if (err == JSON_SEM_ACTION_FAILED)
+	{
+		if (PQExpBufferDataBroken(ctx.errbuf))
+			errmsg = libpq_gettext("out of memory");
+		else if (ctx.errmsg)
+			errmsg = ctx.errmsg;
+		else
+		{
+			/*
+			 * Developer error: one of the action callbacks didn't call
+			 * oauth_json_set_error() before erroring out.
+			 */
+			Assert(oauth_json_has_error(&ctx));
+			errmsg = "<unexpected empty error>";
+		}
+	}
+	else if (err != JSON_SUCCESS)
+		errmsg = json_errdetail(err, &lex);
+
+	if (errmsg)
+		libpq_append_conn_error(conn,
+								"failed to parse server's error response: %s",
+								errmsg);
+
+	/* Don't need the error buffer or the JSON lexer anymore. */
+	termPQExpBuffer(&ctx.errbuf);
+	freeJsonLexContext(&lex);
+
+	if (errmsg)
+		goto cleanup;
+
+	if (ctx.discovery_uri)
+	{
+		char	   *discovery_issuer;
+
+		/*
+		 * The URI MUST correspond to our existing issuer, to avoid mix-ups.
+		 *
+		 * Issuer comparison is done byte-wise, rather than performing any URL
+		 * normalization; this follows the suggestions for issuer comparison
+		 * in RFC 9207 Sec. 2.4 (which requires simple string comparison) and
+		 * vastly simplifies things. Since this is the key protection against
+		 * a rogue server sending the client to an untrustworthy location,
+		 * simpler is better.
+		 */
+		discovery_issuer = issuer_from_well_known_uri(conn, ctx.discovery_uri);
+		if (!discovery_issuer)
+			goto cleanup;		/* error message already set */
+
+		if (strcmp(conn->oauth_issuer_id, discovery_issuer) != 0)
+		{
+			libpq_append_conn_error(conn,
+									"server's discovery document at %s (issuer \"%s\") is incompatible with oauth_issuer (%s)",
+									ctx.discovery_uri, discovery_issuer,
+									conn->oauth_issuer_id);
+
+			free(discovery_issuer);
+			goto cleanup;
+		}
+
+		free(discovery_issuer);
+
+		if (!conn->oauth_discovery_uri)
+		{
+			conn->oauth_discovery_uri = ctx.discovery_uri;
+			ctx.discovery_uri = NULL;
+		}
+		else
+		{
+			/* This must match the URI we'd previously determined. */
+			if (strcmp(conn->oauth_discovery_uri, ctx.discovery_uri) != 0)
+			{
+				libpq_append_conn_error(conn,
+										"server's discovery document has moved to %s (previous location was %s)",
+										ctx.discovery_uri,
+										conn->oauth_discovery_uri);
+				goto cleanup;
+			}
+		}
+	}
+
+	if (ctx.scope)
+	{
+		/* Servers may not override a previously set oauth_scope. */
+		if (!conn->oauth_scope)
+		{
+			conn->oauth_scope = ctx.scope;
+			ctx.scope = NULL;
+		}
+	}
+
+	if (!ctx.status)
+	{
+		libpq_append_conn_error(conn,
+								"server sent error response without a status");
+		goto cleanup;
+	}
+
+	if (strcmp(ctx.status, "invalid_token") != 0)
+	{
+		/*
+		 * invalid_token is the only error code we'll automatically retry for;
+		 * otherwise, just bail out now.
+		 */
+		libpq_append_conn_error(conn,
+								"server rejected OAuth bearer token: %s",
+								ctx.status);
+		goto cleanup;
+	}
+
+	success = true;
+
+cleanup:
+	free(ctx.status);
+	free(ctx.scope);
+	free(ctx.discovery_uri);
+
+	return success;
+}
+
+/*
+ * Callback implementation of conn->async_auth() for a user-defined OAuth flow.
+ * Delegates the retrieval of the token to the application's 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_*
+ * statuses for use by PQconnectPoll().
+ */
+static PostgresPollingStatusType
+run_user_oauth_flow(PGconn *conn)
+{
+	fe_oauth_state *state = conn->sasl_state;
+	PGoauthBearerRequest *request = state->async_ctx;
+	PostgresPollingStatusType status;
+
+	if (!request->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);
+	if (status == PGRES_POLLING_FAILED)
+	{
+		libpq_append_conn_error(conn, "user-defined OAuth flow failed");
+		return status;
+	}
+	else if (status == PGRES_POLLING_OK)
+	{
+		/*
+		 * 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.)
+		 */
+		if (!request->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);
+		if (!conn->oauth_token)
+		{
+			libpq_append_conn_error(conn, "out of memory");
+			return PGRES_POLLING_FAILED;
+		}
+
+		return PGRES_POLLING_OK;
+	}
+
+	/* The hook wants the client to poll the altsock. Make sure it set one. */
+	if (conn->altsock == PGINVALID_SOCKET)
+	{
+		libpq_append_conn_error(conn,
+								"user-defined OAuth flow did not provide a socket for polling");
+		return PGRES_POLLING_FAILED;
+	}
+
+	return status;
+}
+
+/*
+ * Cleanup callback for the async user flow. Delegates most of its job to the
+ * user-provided cleanup implementation, then disconnects the altsock.
+ */
+static void
+cleanup_user_oauth_flow(PGconn *conn)
+{
+	fe_oauth_state *state = conn->sasl_state;
+	PGoauthBearerRequest *request = state->async_ctx;
+
+	Assert(request);
+
+	if (request->cleanup)
+		request->cleanup(conn, request);
+	conn->altsock = PGINVALID_SOCKET;
+
+	free(request);
+	state->async_ctx = NULL;
+}
+
+/*
+ * Chooses an OAuth client flow for the connection, which will retrieve a Bearer
+ * 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
+ * asynchronous callbacks which will be managed by run_user_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.)
+ *
+ * If neither a custom handler nor the builtin flow is available, the connection
+ * fails here.
+ */
+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,
+	};
+
+	Assert(request.openid_configuration);
+
+	/* The client may have overridden the OAuth flow. */
+	res = PQauthDataHook(PQAUTHDATA_OAUTH_BEARER_TOKEN, conn, &request);
+	if (res > 0)
+	{
+		PGoauthBearerRequest *request_copy;
+
+		if (request.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);
+			if (!conn->oauth_token)
+			{
+				libpq_append_conn_error(conn, "out of memory");
+				goto fail;
+			}
+
+			/* short-circuit */
+			if (request.cleanup)
+				request.cleanup(conn, &request);
+			return true;
+		}
+
+		request_copy = malloc(sizeof(*request_copy));
+		if (!request_copy)
+		{
+			libpq_append_conn_error(conn, "out of memory");
+			goto fail;
+		}
+
+		memcpy(request_copy, &request, sizeof(request));
+
+		conn->async_auth = run_user_oauth_flow;
+		conn->cleanup_async_auth = cleanup_user_oauth_flow;
+		state->async_ctx = request_copy;
+	}
+	else if (res < 0)
+	{
+		libpq_append_conn_error(conn, "user-defined OAuth flow failed");
+		goto fail;
+	}
+	else
+	{
+#if USE_LIBCURL
+		/* Hand off to our built-in OAuth flow. */
+		conn->async_auth = pg_fe_run_oauth_flow;
+		conn->cleanup_async_auth = pg_fe_cleanup_oauth_flow;
+
+#else
+		libpq_append_conn_error(conn, "no custom OAuth flows are available, and libpq was not built with libcurl support");
+		goto fail;
+
+#endif
+	}
+
+	return true;
+
+fail:
+	if (request.cleanup)
+		request.cleanup(conn, &request);
+	return false;
+}
+
+/*
+ * Fill in our issuer identifier and discovery URI, if possible, using the
+ * connection parameters. If conn->oauth_discovery_uri can't be populated in
+ * this function, it will be requested from the server.
+ */
+static bool
+setup_oauth_parameters(PGconn *conn)
+{
+	/*---
+	 * To talk to a server, we require the user to provide issuer and client
+	 * identifiers.
+	 *
+	 * While it's possible for an OAuth client to support multiple issuers, it
+	 * requires additional effort to make sure the flows in use are safe -- to
+	 * quote RFC 9207,
+	 *
+	 *     OAuth clients that interact with only one authorization server are
+	 *     not vulnerable to mix-up attacks. However, when such clients decide
+	 *     to add support for a second authorization server in the future, they
+	 *     become vulnerable and need to apply countermeasures to mix-up
+	 *     attacks.
+	 *
+	 * For now, we allow only one.
+	 */
+	if (!conn->oauth_issuer || !conn->oauth_client_id)
+	{
+		libpq_append_conn_error(conn,
+								"server requires OAuth authentication, but oauth_issuer and oauth_client_id are not both set");
+		return false;
+	}
+
+	/*
+	 * oauth_issuer is interpreted differently if it's a well-known discovery
+	 * URI rather than just an issuer identifier.
+	 */
+	if (strstr(conn->oauth_issuer, WK_PREFIX) != NULL)
+	{
+		/*
+		 * Convert the URI back to an issuer identifier. (This also performs
+		 * validation of the URI format.)
+		 */
+		conn->oauth_issuer_id = issuer_from_well_known_uri(conn,
+														   conn->oauth_issuer);
+		if (!conn->oauth_issuer_id)
+			return false;		/* error message already set */
+
+		conn->oauth_discovery_uri = strdup(conn->oauth_issuer);
+		if (!conn->oauth_discovery_uri)
+		{
+			libpq_append_conn_error(conn, "out of memory");
+			return false;
+		}
+	}
+	else
+	{
+		/*
+		 * Treat oauth_issuer as an issuer identifier. We'll ask the server
+		 * for the discovery URI.
+		 */
+		conn->oauth_issuer_id = strdup(conn->oauth_issuer);
+		if (!conn->oauth_issuer_id)
+		{
+			libpq_append_conn_error(conn, "out of memory");
+			return false;
+		}
+	}
+
+	return true;
+}
+
+/*
+ * Implements the OAUTHBEARER SASL exchange (RFC 7628, Sec. 3.2).
+ *
+ * If the necessary OAuth parameters are set up on the connection, this will run
+ * the client flow asynchronously and present the resulting token to the server.
+ * Otherwise, an empty discovery response will be sent and any parameters sent
+ * back by the server will be stored for a second attempt.
+ *
+ * For a full description of the API, see libpq/sasl.h.
+ */
+static SASLStatus
+oauth_exchange(void *opaq, bool final,
+			   char *input, int inputlen,
+			   char **output, int *outputlen)
+{
+	fe_oauth_state *state = opaq;
+	PGconn	   *conn = state->conn;
+	bool		discover = false;
+
+	*output = NULL;
+	*outputlen = 0;
+
+	switch (state->step)
+	{
+		case FE_OAUTH_INIT:
+			/* We begin in the initial response phase. */
+			Assert(inputlen == -1);
+
+			if (!setup_oauth_parameters(conn))
+				return SASL_FAILED;
+
+			if (conn->oauth_token)
+			{
+				/*
+				 * A previous connection already fetched the token; we'll use
+				 * it below.
+				 */
+			}
+			else if (conn->oauth_discovery_uri)
+			{
+				/*
+				 * We don't have a token, but we have a discovery URI already
+				 * stored. Decide whether we're using a user-provided OAuth
+				 * flow or the one we have built in.
+				 */
+				if (!setup_token_request(conn, state))
+					return SASL_FAILED;
+
+				if (conn->oauth_token)
+				{
+					/*
+					 * A really smart user implementation may have already
+					 * given us the token (e.g. if there was an unexpired copy
+					 * already cached), and we can use it immediately.
+					 */
+				}
+				else
+				{
+					/*
+					 * Otherwise, we'll have to hand the connection over to
+					 * our OAuth implementation.
+					 *
+					 * This could take a while, since it generally involves a
+					 * user in the loop. To avoid consuming the server's
+					 * authentication timeout, we'll continue this handshake
+					 * to the end, so that the server can close its side of
+					 * the connection. We'll open a second connection later
+					 * once we've retrieved a token.
+					 */
+					discover = true;
+				}
+			}
+			else
+			{
+				/*
+				 * If we don't have a token, and we don't have a discovery URI
+				 * to be able to request a token, we ask the server for one
+				 * explicitly.
+				 */
+				discover = true;
+			}
+
+			/*
+			 * Generate an initial response. This either contains a token, if
+			 * we have one, or an empty discovery response which is doomed to
+			 * fail.
+			 */
+			*output = client_initial_response(conn, discover);
+			if (!*output)
+				return SASL_FAILED;
+
+			*outputlen = strlen(*output);
+			state->step = FE_OAUTH_BEARER_SENT;
+
+			if (conn->oauth_token)
+			{
+				/*
+				 * For the purposes of require_auth, our side of
+				 * authentication is done at this point; the server will
+				 * either accept the connection or send an error. Unlike
+				 * SCRAM, there is no additional server data to check upon
+				 * success.
+				 */
+				conn->client_finished_auth = true;
+			}
+
+			return SASL_CONTINUE;
+
+		case FE_OAUTH_BEARER_SENT:
+			if (final)
+			{
+				/*
+				 * OAUTHBEARER does not make use of additional data with a
+				 * successful SASL exchange, so we shouldn't get an
+				 * AuthenticationSASLFinal message.
+				 */
+				libpq_append_conn_error(conn,
+										"server sent unexpected additional OAuth data");
+				return SASL_FAILED;
+			}
+
+			/*
+			 * An error message was sent by the server. Respond with the
+			 * required dummy message (RFC 7628, sec. 3.2.3).
+			 */
+			*output = strdup(kvsep);
+			if (unlikely(!*output))
+			{
+				libpq_append_conn_error(conn, "out of memory");
+				return SASL_FAILED;
+			}
+			*outputlen = strlen(*output);	/* == 1 */
+
+			/* Grab the settings from discovery. */
+			if (!handle_oauth_sasl_error(conn, input, inputlen))
+				return SASL_FAILED;
+
+			if (conn->oauth_token)
+			{
+				/*
+				 * The server rejected our token. Continue onwards towards the
+				 * expected FATAL message, but mark our state to catch any
+				 * unexpected "success" from the server.
+				 */
+				state->step = FE_OAUTH_SERVER_ERROR;
+				return SASL_CONTINUE;
+			}
+
+			if (!conn->async_auth)
+			{
+				/*
+				 * No OAuth flow is set up yet. Did we get enough information
+				 * from the server to create one?
+				 */
+				if (!conn->oauth_discovery_uri)
+				{
+					libpq_append_conn_error(conn,
+											"server requires OAuth authentication, but no discovery metadata was provided");
+					return SASL_FAILED;
+				}
+
+				/* Yes. Set up the flow now. */
+				if (!setup_token_request(conn, state))
+					return SASL_FAILED;
+
+				if (conn->oauth_token)
+				{
+					/*
+					 * A token was available in a custom flow's cache. Skip
+					 * the asynchronous processing.
+					 */
+					goto reconnect;
+				}
+			}
+
+			/*
+			 * Time to retrieve a token. This involves a number of HTTP
+			 * connections and timed waits, so we escape the synchronous auth
+			 * processing and tell PQconnectPoll to transfer control to our
+			 * async implementation.
+			 */
+			Assert(conn->async_auth);	/* should have been set already */
+			state->step = FE_OAUTH_REQUESTING_TOKEN;
+			return SASL_ASYNC;
+
+		case FE_OAUTH_REQUESTING_TOKEN:
+
+			/*
+			 * We've returned successfully from token retrieval. Double-check
+			 * that we have what we need for the next connection.
+			 */
+			if (!conn->oauth_token)
+			{
+				Assert(false);	/* should have failed before this point! */
+				libpq_append_conn_error(conn,
+										"internal error: OAuth flow did not set a token");
+				return SASL_FAILED;
+			}
+
+			goto reconnect;
+
+		case FE_OAUTH_SERVER_ERROR:
+
+			/*
+			 * After an error, the server should send an error response to
+			 * fail the SASL handshake, which is handled in higher layers.
+			 *
+			 * If we get here, the server either sent *another* challenge
+			 * which isn't defined in the RFC, or completed the handshake
+			 * successfully after telling us it was going to fail. Neither is
+			 * acceptable.
+			 */
+			libpq_append_conn_error(conn,
+									"server sent additional OAuth data after error");
+			return SASL_FAILED;
+
+		default:
+			libpq_append_conn_error(conn, "invalid OAuth exchange state");
+			break;
+	}
+
+	Assert(false);				/* should never get here */
+	return SASL_FAILED;
+
+reconnect:
+
+	/*
+	 * Despite being a failure from the point of view of SASL, we have enough
+	 * information to restart with a new connection.
+	 */
+	libpq_append_conn_error(conn, "retrying connection with new bearer token");
+	conn->oauth_want_retry = true;
+	return SASL_FAILED;
+}
+
+static bool
+oauth_channel_bound(void *opaq)
+{
+	/* This mechanism does not support channel binding. */
+	return false;
+}
+
+/*
+ * Fully clears out any stored OAuth token. This is done proactively upon
+ * successful connection as well as during pqClosePGconn().
+ */
+void
+pqClearOAuthToken(PGconn *conn)
+{
+	if (!conn->oauth_token)
+		return;
+
+	explicit_bzero(conn->oauth_token, strlen(conn->oauth_token));
+	free(conn->oauth_token);
+	conn->oauth_token = NULL;
+}
+
+/*
+ * Returns true if the PGOAUTHDEBUG=UNSAFE flag is set in the environment.
+ */
+bool
+oauth_unsafe_debugging_enabled(void)
+{
+	const char *env = getenv("PGOAUTHDEBUG");
+
+	return (env && strcmp(env, "UNSAFE") == 0);
+}
diff --git a/src/interfaces/libpq/fe-auth-oauth.h b/src/interfaces/libpq/fe-auth-oauth.h
new file mode 100644
index 00000000000..32598721686
--- /dev/null
+++ b/src/interfaces/libpq/fe-auth-oauth.h
@@ -0,0 +1,45 @@
+/*-------------------------------------------------------------------------
+ *
+ * fe-auth-oauth.h
+ *
+ *	  Definitions for OAuth authentication implementations
+ *
+ * Portions Copyright (c) 2024, PostgreSQL Global Development Group
+ *
+ * src/interfaces/libpq/fe-auth-oauth.h
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#ifndef FE_AUTH_OAUTH_H
+#define FE_AUTH_OAUTH_H
+
+#include "libpq-fe.h"
+#include "libpq-int.h"
+
+
+enum fe_oauth_step
+{
+	FE_OAUTH_INIT,
+	FE_OAUTH_BEARER_SENT,
+	FE_OAUTH_REQUESTING_TOKEN,
+	FE_OAUTH_SERVER_ERROR,
+};
+
+typedef struct
+{
+	enum fe_oauth_step step;
+
+	PGconn	   *conn;
+	void	   *async_ctx;
+} fe_oauth_state;
+
+extern PostgresPollingStatusType pg_fe_run_oauth_flow(PGconn *conn);
+extern void pg_fe_cleanup_oauth_flow(PGconn *conn);
+extern void pqClearOAuthToken(PGconn *conn);
+extern bool oauth_unsafe_debugging_enabled(void);
+
+/* Mechanisms in fe-auth-oauth.c */
+extern const pg_fe_sasl_mech pg_oauth_mech;
+
+#endif							/* FE_AUTH_OAUTH_H */
diff --git a/src/interfaces/libpq/fe-auth.c b/src/interfaces/libpq/fe-auth.c
index 761ee8f88f7..ec7a9236044 100644
--- a/src/interfaces/libpq/fe-auth.c
+++ b/src/interfaces/libpq/fe-auth.c
@@ -40,9 +40,11 @@
 #endif
 
 #include "common/md5.h"
+#include "common/oauth-common.h"
 #include "common/scram-common.h"
 #include "fe-auth.h"
 #include "fe-auth-sasl.h"
+#include "fe-auth-oauth.h"
 #include "libpq-fe.h"
 
 #ifdef ENABLE_GSS
@@ -535,6 +537,13 @@ pg_SASL_init(PGconn *conn, int payloadlen, bool *async)
 			conn->sasl = &pg_scram_mech;
 			conn->password_needed = true;
 		}
+		else if (strcmp(mechanism_buf.data, OAUTHBEARER_NAME) == 0 &&
+				 !selected_mechanism)
+		{
+			selected_mechanism = OAUTHBEARER_NAME;
+			conn->sasl = &pg_oauth_mech;
+			conn->password_needed = false;
+		}
 	}
 
 	if (!selected_mechanism)
@@ -559,13 +568,6 @@ pg_SASL_init(PGconn *conn, int payloadlen, bool *async)
 
 		if (!allowed)
 		{
-			/*
-			 * TODO: this is dead code until a second SASL mechanism is added;
-			 * the connection can't have proceeded past check_expected_areq()
-			 * if no SASL methods are allowed.
-			 */
-			Assert(false);
-
 			libpq_append_conn_error(conn, "authentication method requirement \"%s\" failed: server requested %s authentication",
 									conn->require_auth, selected_mechanism);
 			goto error;
@@ -1580,3 +1582,23 @@ PQchangePassword(PGconn *conn, const char *user, const char *passwd)
 		}
 	}
 }
+
+PQauthDataHook_type PQauthDataHook = PQdefaultAuthDataHook;
+
+PQauthDataHook_type
+PQgetAuthDataHook(void)
+{
+	return PQauthDataHook;
+}
+
+void
+PQsetAuthDataHook(PQauthDataHook_type hook)
+{
+	PQauthDataHook = hook ? hook : PQdefaultAuthDataHook;
+}
+
+int
+PQdefaultAuthDataHook(PGauthData type, PGconn *conn, void *data)
+{
+	return 0;					/* handle nothing */
+}
diff --git a/src/interfaces/libpq/fe-auth.h b/src/interfaces/libpq/fe-auth.h
index 1d4991f8996..de98e0d20c4 100644
--- a/src/interfaces/libpq/fe-auth.h
+++ b/src/interfaces/libpq/fe-auth.h
@@ -18,6 +18,9 @@
 #include "libpq-int.h"
 
 
+extern PQauthDataHook_type PQauthDataHook;
+
+
 /* Prototypes for functions in fe-auth.c */
 extern int	pg_fe_sendauth(AuthRequest areq, int payloadlen, PGconn *conn,
 						   bool *async);
diff --git a/src/interfaces/libpq/fe-connect.c b/src/interfaces/libpq/fe-connect.c
index 85d1ca2864f..d5051f5e820 100644
--- a/src/interfaces/libpq/fe-connect.c
+++ b/src/interfaces/libpq/fe-connect.c
@@ -28,6 +28,7 @@
 #include "common/scram-common.h"
 #include "common/string.h"
 #include "fe-auth.h"
+#include "fe-auth-oauth.h"
 #include "libpq-fe.h"
 #include "libpq-int.h"
 #include "mb/pg_wchar.h"
@@ -373,6 +374,23 @@ static const internalPQconninfoOption PQconninfoOptions[] = {
 	{"scram_server_key", NULL, NULL, NULL, "SCRAM-Server-Key", "D", SCRAM_MAX_KEY_LEN * 2,
 	offsetof(struct pg_conn, scram_server_key)},
 
+	/* OAuth v2 */
+	{"oauth_issuer", NULL, NULL, NULL,
+		"OAuth-Issuer", "", 40,
+	offsetof(struct pg_conn, oauth_issuer)},
+
+	{"oauth_client_id", NULL, NULL, NULL,
+		"OAuth-Client-ID", "", 40,
+	offsetof(struct pg_conn, oauth_client_id)},
+
+	{"oauth_client_secret", NULL, NULL, NULL,
+		"OAuth-Client-Secret", "", 40,
+	offsetof(struct pg_conn, oauth_client_secret)},
+
+	{"oauth_scope", NULL, NULL, NULL,
+		"OAuth-Scope", "", 15,
+	offsetof(struct pg_conn, oauth_scope)},
+
 	/* Terminating entry --- MUST BE LAST */
 	{NULL, NULL, NULL, NULL,
 	NULL, NULL, 0}
@@ -399,6 +417,7 @@ static const PQEnvironmentOption EnvironmentOptions[] =
 static const pg_fe_sasl_mech *supported_sasl_mechs[] =
 {
 	&pg_scram_mech,
+	&pg_oauth_mech,
 };
 #define SASL_MECHANISM_COUNT lengthof(supported_sasl_mechs)
 
@@ -655,6 +674,7 @@ pqDropServerData(PGconn *conn)
 	conn->write_failed = false;
 	free(conn->write_err_msg);
 	conn->write_err_msg = NULL;
+	conn->oauth_want_retry = false;
 
 	/*
 	 * Cancel connections need to retain their be_pid and be_key across
@@ -1144,7 +1164,7 @@ static inline void
 fill_allowed_sasl_mechs(PGconn *conn)
 {
 	/*---
-	 * We only support one mechanism at the moment, so rather than deal with a
+	 * We only support two mechanisms at the moment, so rather than deal with a
 	 * linked list, conn->allowed_sasl_mechs is an array of static length. We
 	 * rely on the compile-time assertion here to keep us honest.
 	 *
@@ -1519,6 +1539,10 @@ pqConnectOptions2(PGconn *conn)
 			{
 				mech = &pg_scram_mech;
 			}
+			else if (strcmp(method, "oauth") == 0)
+			{
+				mech = &pg_oauth_mech;
+			}
 
 			/*
 			 * Final group: meta-options.
@@ -4111,7 +4135,19 @@ keep_going:						/* We will come back to here until there is
 				conn->inStart = conn->inCursor;
 
 				if (res != STATUS_OK)
+				{
+					/*
+					 * OAuth connections may perform two-step discovery, where
+					 * the first connection is a dummy.
+					 */
+					if (conn->sasl == &pg_oauth_mech && conn->oauth_want_retry)
+					{
+						need_new_connection = true;
+						goto keep_going;
+					}
+
 					goto error_return;
+				}
 
 				/*
 				 * Just make sure that any data sent by pg_fe_sendauth is
@@ -4390,6 +4426,9 @@ keep_going:						/* We will come back to here until there is
 					}
 				}
 
+				/* Don't hold onto any OAuth tokens longer than necessary. */
+				pqClearOAuthToken(conn);
+
 				/*
 				 * For non cancel requests we can release the address list
 				 * now. For cancel requests we never actually resolve
@@ -5002,6 +5041,12 @@ freePGconn(PGconn *conn)
 	free(conn->load_balance_hosts);
 	free(conn->scram_client_key);
 	free(conn->scram_server_key);
+	free(conn->oauth_issuer);
+	free(conn->oauth_issuer_id);
+	free(conn->oauth_discovery_uri);
+	free(conn->oauth_client_id);
+	free(conn->oauth_client_secret);
+	free(conn->oauth_scope);
 	termPQExpBuffer(&conn->errorMessage);
 	termPQExpBuffer(&conn->workBuffer);
 
@@ -5155,6 +5200,7 @@ pqClosePGconn(PGconn *conn)
 	conn->asyncStatus = PGASYNC_IDLE;
 	conn->xactStatus = PQTRANS_IDLE;
 	conn->pipelineStatus = PQ_PIPELINE_OFF;
+	pqClearOAuthToken(conn);
 	pqClearAsyncResult(conn);	/* deallocate result */
 	pqClearConnErrorState(conn);
 
diff --git a/src/interfaces/libpq/libpq-fe.h b/src/interfaces/libpq/libpq-fe.h
index a3491faf0c3..5f8d608261e 100644
--- a/src/interfaces/libpq/libpq-fe.h
+++ b/src/interfaces/libpq/libpq-fe.h
@@ -59,6 +59,8 @@ extern "C"
 /* Features added in PostgreSQL v18: */
 /* Indicates presence of PQfullProtocolVersion */
 #define LIBPQ_HAS_FULL_PROTOCOL_VERSION 1
+/* Indicates presence of the PQAUTHDATA_PROMPT_OAUTH_DEVICE authdata hook */
+#define LIBPQ_HAS_PROMPT_OAUTH_DEVICE 1
 
 /*
  * Option flags for PQcopyResult
@@ -186,6 +188,13 @@ typedef enum
 	PQ_PIPELINE_ABORTED
 } PGpipelineStatus;
 
+typedef enum
+{
+	PQAUTHDATA_PROMPT_OAUTH_DEVICE, /* user must visit a device-authorization
+									 * URL */
+	PQAUTHDATA_OAUTH_BEARER_TOKEN,	/* server requests an OAuth Bearer token */
+} PGauthData;
+
 /* PGconn encapsulates a connection to the backend.
  * The contents of this struct are not supposed to be known to applications.
  */
@@ -720,10 +729,83 @@ extern int	PQenv2encoding(void);
 
 /* === in fe-auth.c === */
 
+typedef struct _PGpromptOAuthDevice
+{
+	const char *verification_uri;	/* verification URI to visit */
+	const char *user_code;		/* user code to enter */
+} PGpromptOAuthDevice;
+
+/* for PGoauthBearerRequest.async() */
+#ifdef _WIN32
+#define SOCKTYPE uintptr_t		/* avoids depending on winsock2.h for SOCKET */
+#else
+#define SOCKTYPE int
+#endif
+
+typedef struct _PGoauthBearerRequest
+{
+	/* Hook inputs (constant across all calls) */
+	const char *const openid_configuration; /* OIDC discovery URI */
+	const char *const scope;	/* required scope(s), or NULL */
+
+	/* Hook outputs */
+
+	/*---------
+	 * Callback implementing a custom asynchronous OAuth flow.
+	 *
+	 * The callback may return
+	 * - PGRES_POLLING_READING/WRITING, to indicate that a socket descriptor
+	 *   has been stored in *altsock and libpq should wait until it is
+	 *   readable or writable before calling back;
+	 * - PGRES_POLLING_OK, to indicate that the flow is complete and
+	 *   request->token has been set; or
+	 * - PGRES_POLLING_FAILED, to indicate that token retrieval has failed.
+	 *
+	 * This callback is optional. If the token can be obtained without
+	 * 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.
+	 */
+	PostgresPollingStatusType (*async) (PGconn *conn,
+										struct _PGoauthBearerRequest *request,
+										SOCKTYPE * altsock);
+
+	/*
+	 * Callback to clean up custom allocations. A hook implementation may use
+	 * this to free request->token and any resources in request->user.
+	 *
+	 * This is technically optional, but highly recommended, because there is
+	 * no other indication as to when it is safe to free the token.
+	 */
+	void		(*cleanup) (PGconn *conn, struct _PGoauthBearerRequest *request);
+
+	/*
+	 * The hook should set this to the Bearer token contents for the
+	 * connection, once the flow is completed.  The token contents must remain
+	 * available to libpq until the hook's cleanup callback is called.
+	 */
+	char	   *token;
+
+	/*
+	 * Hook-defined data. libpq will not modify this pointer across calls to
+	 * the async callback, so it can be used to keep track of
+	 * application-specific state. Resources allocated here should be freed by
+	 * the cleanup callback.
+	 */
+	void	   *user;
+} PGoauthBearerRequest;
+
+#undef SOCKTYPE
+
 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);
 
+typedef int (*PQauthDataHook_type) (PGauthData type, PGconn *conn, void *data);
+extern void PQsetAuthDataHook(PQauthDataHook_type hook);
+extern PQauthDataHook_type PQgetAuthDataHook(void);
+extern int	PQdefaultAuthDataHook(PGauthData type, PGconn *conn, void *data);
+
 /* === in encnames.c === */
 
 extern int	pg_char_to_encoding(const char *name);
diff --git a/src/interfaces/libpq/libpq-int.h b/src/interfaces/libpq/libpq-int.h
index 2546f9f8a50..f36f7f19d58 100644
--- a/src/interfaces/libpq/libpq-int.h
+++ b/src/interfaces/libpq/libpq-int.h
@@ -437,6 +437,17 @@ struct pg_conn
 								 * cancel request, instead of being a normal
 								 * connection that's used for queries */
 
+	/* OAuth v2 */
+	char	   *oauth_issuer;	/* token issuer/URL */
+	char	   *oauth_issuer_id;	/* token issuer identifier */
+	char	   *oauth_discovery_uri;	/* URI of the issuer's discovery
+										 * document */
+	char	   *oauth_client_id;	/* client identifier */
+	char	   *oauth_client_secret;	/* client secret */
+	char	   *oauth_scope;	/* access token scope */
+	char	   *oauth_token;	/* access token */
+	bool		oauth_want_retry;	/* should we retry on failure? */
+
 	/* Optional file to write trace info to */
 	FILE	   *Pfdebug;
 	int			traceFlags;
@@ -505,7 +516,7 @@ struct pg_conn
 								 * the server? */
 	uint32		allowed_auth_methods;	/* bitmask of acceptable AuthRequest
 										 * codes */
-	const pg_fe_sasl_mech *allowed_sasl_mechs[1];	/* and acceptable SASL
+	const pg_fe_sasl_mech *allowed_sasl_mechs[2];	/* and acceptable SASL
 													 * mechanisms */
 	bool		client_finished_auth;	/* have we finished our half of the
 										 * authentication exchange? */
diff --git a/src/interfaces/libpq/meson.build b/src/interfaces/libpq/meson.build
index 1a5a223e1af..4180e35f8cf 100644
--- a/src/interfaces/libpq/meson.build
+++ b/src/interfaces/libpq/meson.build
@@ -4,6 +4,7 @@
 # args for executables (which depend on libpq).
 
 libpq_sources = files(
+  'fe-auth-oauth.c',
   'fe-auth-scram.c',
   'fe-auth.c',
   'fe-cancel.c',
@@ -40,6 +41,10 @@ if gssapi.found()
   )
 endif
 
+if libcurl.found()
+  libpq_sources += files('fe-auth-oauth-curl.c')
+endif
+
 export_file = custom_target('libpq.exports',
   kwargs: gen_export_kwargs,
 )
diff --git a/src/makefiles/meson.build b/src/makefiles/meson.build
index d49b2079a44..60e13d50235 100644
--- a/src/makefiles/meson.build
+++ b/src/makefiles/meson.build
@@ -229,6 +229,7 @@ pgxs_deps = {
   'gssapi': gssapi,
   'icu': icu,
   'ldap': ldap,
+  'libcurl': libcurl,
   'libxml': libxml,
   'libxslt': libxslt,
   'llvm': llvm,
diff --git a/src/test/authentication/t/001_password.pl b/src/test/authentication/t/001_password.pl
index 1357f806b6f..4ce22ccbdf2 100644
--- a/src/test/authentication/t/001_password.pl
+++ b/src/test/authentication/t/001_password.pl
@@ -404,11 +404,11 @@ $node->connect_fails(
 $node->connect_fails(
 	"user=scram_role require_auth=!scram-sha-256",
 	"SCRAM authentication forbidden, fails with SCRAM auth",
-	expected_stderr => qr/server requested SASL authentication/);
+	expected_stderr => qr/server requested SCRAM-SHA-256 authentication/);
 $node->connect_fails(
 	"user=scram_role require_auth=!password,!md5,!scram-sha-256",
 	"multiple authentication types forbidden, fails with SCRAM auth",
-	expected_stderr => qr/server requested SASL authentication/);
+	expected_stderr => qr/server requested SCRAM-SHA-256 authentication/);
 
 # Test that bad passwords are rejected.
 $ENV{"PGPASSWORD"} = 'badpass';
@@ -465,13 +465,13 @@ $node->connect_fails(
 	"user=scram_role require_auth=!scram-sha-256",
 	"password authentication forbidden, fails with SCRAM auth",
 	expected_stderr =>
-	  qr/authentication method requirement "!scram-sha-256" failed: server requested SASL authentication/
+	  qr/authentication method requirement "!scram-sha-256" failed: server requested SCRAM-SHA-256 authentication/
 );
 $node->connect_fails(
 	"user=scram_role require_auth=!password,!md5,!scram-sha-256",
 	"multiple authentication types forbidden, fails with SCRAM auth",
 	expected_stderr =>
-	  qr/authentication method requirement "!password,!md5,!scram-sha-256" failed: server requested SASL authentication/
+	  qr/authentication method requirement "!password,!md5,!scram-sha-256" failed: server requested SCRAM-SHA-256 authentication/
 );
 
 # Test SYSTEM_USER <> NULL with parallel workers.
diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile
index c0d3cf0e14b..bdfd5f1f8de 100644
--- a/src/test/modules/Makefile
+++ b/src/test/modules/Makefile
@@ -11,6 +11,7 @@ SUBDIRS = \
 		  dummy_index_am \
 		  dummy_seclabel \
 		  libpq_pipeline \
+		  oauth_validator \
 		  plsample \
 		  spgist_name_ops \
 		  test_bloomfilter \
diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build
index 4f544a042d4..0c2ccc75a63 100644
--- a/src/test/modules/meson.build
+++ b/src/test/modules/meson.build
@@ -9,6 +9,7 @@ subdir('gin')
 subdir('injection_points')
 subdir('ldap_password_func')
 subdir('libpq_pipeline')
+subdir('oauth_validator')
 subdir('plsample')
 subdir('spgist_name_ops')
 subdir('ssl_passphrase_callback')
diff --git a/src/test/modules/oauth_validator/.gitignore b/src/test/modules/oauth_validator/.gitignore
new file mode 100644
index 00000000000..5dcb3ff9723
--- /dev/null
+++ b/src/test/modules/oauth_validator/.gitignore
@@ -0,0 +1,4 @@
+# Generated subdirectories
+/log/
+/results/
+/tmp_check/
diff --git a/src/test/modules/oauth_validator/Makefile b/src/test/modules/oauth_validator/Makefile
new file mode 100644
index 00000000000..f297ed5c968
--- /dev/null
+++ b/src/test/modules/oauth_validator/Makefile
@@ -0,0 +1,40 @@
+#-------------------------------------------------------------------------
+#
+# Makefile for src/test/modules/oauth_validator
+#
+# Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/test/modules/oauth_validator/Makefile
+#
+#-------------------------------------------------------------------------
+
+MODULES = validator fail_validator
+PGFILEDESC = "validator - test OAuth validator module"
+
+PROGRAM = oauth_hook_client
+PGAPPICON = win32
+OBJS = $(WIN32RES) oauth_hook_client.o
+
+PG_CPPFLAGS = -I$(libpq_srcdir)
+PG_LIBS_INTERNAL += $(libpq_pgport)
+
+NO_INSTALLCHECK = 1
+
+TAP_TESTS = 1
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = src/test/modules/oauth_validator
+top_builddir = ../../../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+
+export PYTHON
+export with_libcurl
+export with_python
+
+endif
diff --git a/src/test/modules/oauth_validator/README b/src/test/modules/oauth_validator/README
new file mode 100644
index 00000000000..138a8104622
--- /dev/null
+++ b/src/test/modules/oauth_validator/README
@@ -0,0 +1,13 @@
+Test programs and libraries for OAuth
+-------------------------------------
+
+This folder contains tests for the client- and server-side OAuth
+implementations. Most tests are run end-to-end to test both simultaneously. The
+tests in t/001_server use a mock OAuth authorization server, implemented jointly
+by t/OAuth/Server.pm and t/oauth_server.py, to run the libpq Device
+Authorization flow. The tests in t/002_client exercise custom OAuth flows and
+don't need an authorization server.
+
+Tests in this folder generally require 'oauth' to be present in PG_TEST_EXTRA,
+since localhost HTTP servers will be started. A Python installation is required
+to run the mock authorization server.
diff --git a/src/test/modules/oauth_validator/fail_validator.c b/src/test/modules/oauth_validator/fail_validator.c
new file mode 100644
index 00000000000..f77a3e115c6
--- /dev/null
+++ b/src/test/modules/oauth_validator/fail_validator.c
@@ -0,0 +1,42 @@
+/*-------------------------------------------------------------------------
+ *
+ * fail_validator.c
+ *	  Test module for serverside OAuth token validation callbacks, which always
+ *	  fails
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/test/modules/oauth_validator/fail_validator.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "fmgr.h"
+#include "libpq/oauth.h"
+
+PG_MODULE_MAGIC;
+
+static ValidatorModuleResult *fail_token(ValidatorModuleState *state,
+										 const char *token,
+										 const char *role);
+
+/* Callback implementations (we only need the main one) */
+static const OAuthValidatorCallbacks validator_callbacks = {
+	.validate_cb = fail_token,
+};
+
+const OAuthValidatorCallbacks *
+_PG_oauth_validator_module_init(void)
+{
+	return &validator_callbacks;
+}
+
+static ValidatorModuleResult *
+fail_token(ValidatorModuleState *state, const char *token, const char *role)
+{
+	elog(FATAL, "fail_validator: sentinel error");
+	pg_unreachable();
+}
diff --git a/src/test/modules/oauth_validator/meson.build b/src/test/modules/oauth_validator/meson.build
new file mode 100644
index 00000000000..4b78c90557c
--- /dev/null
+++ b/src/test/modules/oauth_validator/meson.build
@@ -0,0 +1,69 @@
+# Copyright (c) 2024, PostgreSQL Global Development Group
+
+validator_sources = files(
+  'validator.c',
+)
+
+if host_system == 'windows'
+  validator_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'validator',
+    '--FILEDESC', 'validator - test OAuth validator module',])
+endif
+
+validator = shared_module('validator',
+  validator_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += validator
+
+fail_validator_sources = files(
+  'fail_validator.c',
+)
+
+if host_system == 'windows'
+  fail_validator_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'fail_validator',
+    '--FILEDESC', 'fail_validator - failing OAuth validator module',])
+endif
+
+fail_validator = shared_module('fail_validator',
+  fail_validator_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += fail_validator
+
+oauth_hook_client_sources = files(
+  'oauth_hook_client.c',
+)
+
+if host_system == 'windows'
+  oauth_hook_client_sources += rc_bin_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'oauth_hook_client',
+    '--FILEDESC', 'oauth_hook_client - test program for libpq OAuth hooks',])
+endif
+
+oauth_hook_client = executable('oauth_hook_client',
+  oauth_hook_client_sources,
+  dependencies: [frontend_code, libpq],
+  kwargs: default_bin_args + {
+    'install': false,
+  },
+)
+testprep_targets += oauth_hook_client
+
+tests += {
+  'name': 'oauth_validator',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'tap': {
+    'tests': [
+      't/001_server.pl',
+      't/002_client.pl',
+    ],
+    'env': {
+      'PYTHON': python.path(),
+      'with_libcurl': libcurl.found() ? 'yes' : 'no',
+      'with_python': 'yes',
+    },
+  },
+}
diff --git a/src/test/modules/oauth_validator/oauth_hook_client.c b/src/test/modules/oauth_validator/oauth_hook_client.c
new file mode 100644
index 00000000000..12fe70c990b
--- /dev/null
+++ b/src/test/modules/oauth_validator/oauth_hook_client.c
@@ -0,0 +1,264 @@
+/*-------------------------------------------------------------------------
+ *
+ * oauth_hook_client.c
+ *		Test driver for t/002_client.pl, which verifies OAuth hook
+ *		functionality in libpq.
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ *
+ * IDENTIFICATION
+ *		src/test/modules/oauth_validator/oauth_hook_client.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres_fe.h"
+
+#include <sys/socket.h>
+
+#include "getopt_long.h"
+#include "libpq-fe.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(" --expected-scope SCOPE	fail if received scopes do not match SCOPE\n");
+	printf(" --expected-uri URI		fail if received configuration link does not match URI\n");
+	printf(" --misbehave=MODE		have the hook fail required postconditions\n"
+		   "						(MODEs: no-hook, fail-async, no-token, no-socket)\n");
+	printf(" --no-hook				don't install OAuth hooks (connection will fail)\n");
+	printf(" --hang-forever			don't ever return a token (combine with connect_timeout)\n");
+	printf(" --token TOKEN			use the provided TOKEN value\n");
+}
+
+/* --options */
+static bool no_hook = false;
+static bool hang_forever = false;
+static const char *expected_uri = NULL;
+static const char *expected_scope = NULL;
+static const char *misbehave_mode = NULL;
+static char *token = NULL;
+
+int
+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},
+		{0}
+	};
+
+	const char *conninfo;
+	PGconn	   *conn;
+	int			c;
+
+	while ((c = getopt_long(argc, argv, "h", long_options, NULL)) != -1)
+	{
+		switch (c)
+		{
+			case 'h':
+				usage(argv);
+				return 0;
+
+			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;
+
+			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);
+
+	/* Connect. (All the actual work is in the hook.) */
+	conn = PQconnectdb(conninfo);
+	if (PQstatus(conn) != CONNECTION_OK)
+	{
+		fprintf(stderr, "Connection to database failed: %s\n",
+				PQerrorMessage(conn));
+		PQfinish(conn);
+		return 1;
+	}
+
+	printf("connection succeeded\n");
+	PQfinish(conn);
+	return 0;
+}
+
+/*
+ * PQauthDataHook implementation. Replaces the default client flow by handling
+ * PQAUTHDATA_OAUTH_BEARER_TOKEN.
+ */
+static int
+handle_auth_data(PGauthData type, PGconn *conn, void *data)
+{
+	PGoauthBearerRequest *req = data;
+
+	if (no_hook || (type != PQAUTHDATA_OAUTH_BEARER_TOKEN))
+		return 0;
+
+	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;
+		}
+	}
+
+	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". */
+		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/001_server.pl b/src/test/modules/oauth_validator/t/001_server.pl
new file mode 100644
index 00000000000..80f52585896
--- /dev/null
+++ b/src/test/modules/oauth_validator/t/001_server.pl
@@ -0,0 +1,551 @@
+
+#
+# Tests the libpq builtin OAuth flow, as well as server-side HBA and validator
+# setup.
+#
+# Copyright (c) 2021-2024, PostgreSQL Global Development Group
+#
+
+use strict;
+use warnings FATAL => 'all';
+
+use JSON::PP     qw(encode_json);
+use MIME::Base64 qw(encode_base64);
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+use FindBin;
+use lib $FindBin::RealBin;
+
+use OAuth::Server;
+
+if (!$ENV{PG_TEST_EXTRA} || $ENV{PG_TEST_EXTRA} !~ /\boauth\b/)
+{
+	plan skip_all =>
+	  'Potentially unsafe test oauth not enabled in PG_TEST_EXTRA';
+}
+
+if ($ENV{with_libcurl} ne 'yes')
+{
+	plan skip_all => 'client-side OAuth not supported by this build';
+}
+
+if ($ENV{with_python} ne 'yes')
+{
+	plan skip_all => 'OAuth tests require --with-python to run';
+}
+
+my $node = PostgreSQL::Test::Cluster->new('primary');
+$node->init;
+$node->append_conf('postgresql.conf', "log_connections = on\n");
+$node->append_conf('postgresql.conf',
+	"oauth_validator_libraries = 'validator'\n");
+$node->start;
+
+$node->safe_psql('postgres', 'CREATE USER test;');
+$node->safe_psql('postgres', 'CREATE USER testalt;');
+$node->safe_psql('postgres', 'CREATE USER testparam;');
+
+# Save a background connection for later configuration changes.
+my $bgconn = $node->background_psql('postgres');
+
+my $webserver = OAuth::Server->new();
+$webserver->run();
+
+END
+{
+	my $exit_code = $?;
+
+	$webserver->stop() if defined $webserver;    # might have been SKIP'd
+
+	$? = $exit_code;
+}
+
+my $port = $webserver->port();
+my $issuer = "http://localhost:$port";
+
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test      oauth issuer="$issuer"       scope="openid postgres"
+local all testalt   oauth issuer="$issuer/.well-known/oauth-authorization-server/alternate" scope="openid postgres alt"
+local all testparam oauth issuer="$issuer/param" scope="openid postgres"
+});
+$node->reload;
+
+my $log_start = $node->wait_for_log(qr/reloading configuration files/);
+
+# Check pg_hba_file_rules() support.
+my $contents = $bgconn->query_safe(
+	qq(SELECT rule_number, auth_method, options
+		 FROM pg_hba_file_rules
+		 ORDER BY rule_number;));
+is( $contents,
+	qq{1|oauth|\{issuer=$issuer,"scope=openid postgres",validator=validator\}
+2|oauth|\{issuer=$issuer/.well-known/oauth-authorization-server/alternate,"scope=openid postgres alt",validator=validator\}
+3|oauth|\{issuer=$issuer/param,"scope=openid postgres",validator=validator\}},
+	"pg_hba_file_rules recreates OAuth HBA settings");
+
+# To test against HTTP rather than HTTPS, we need to enable PGOAUTHDEBUG. But
+# first, check to make sure the client refuses such connections by default.
+$node->connect_fails(
+	"user=test dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635",
+	"HTTPS is required without debug mode",
+	expected_stderr =>
+	  qr@OAuth discovery URI "\Q$issuer\E/.well-known/openid-configuration" must use HTTPS@
+);
+
+$ENV{PGOAUTHDEBUG} = "UNSAFE";
+
+my $user = "test";
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635",
+	"connect as test",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@,
+	log_like => [
+		qr/oauth_validator: token="9243959234", role="$user"/,
+		qr/oauth_validator: issuer="\Q$issuer\E", scope="openid postgres"/,
+		qr/connection authenticated: identity="test" method=oauth/,
+		qr/connection authorized/,
+	]);
+
+# The /alternate issuer uses slightly different parameters, along with an
+# OAuth-style discovery document.
+$user = "testalt";
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer/alternate oauth_client_id=f02c6361-0636",
+	"connect as testalt",
+	expected_stderr =>
+	  qr@Visit https://example\.org/ and enter the code: postgresuser@,
+	log_like => [
+		qr/oauth_validator: token="9243959234-alt", role="$user"/,
+		qr|oauth_validator: issuer="\Q$issuer/.well-known/oauth-authorization-server/alternate\E", scope="openid postgres alt"|,
+		qr/connection authenticated: identity="testalt" method=oauth/,
+		qr/connection authorized/,
+	]);
+
+# The issuer linked by the server must match the client's oauth_issuer setting.
+$node->connect_fails(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0636",
+	"oauth_issuer must match discovery",
+	expected_stderr =>
+	  qr@server's discovery document at \Q$issuer/.well-known/oauth-authorization-server/alternate\E \(issuer "\Q$issuer/alternate\E"\) is incompatible with oauth_issuer \(\Q$issuer\E\)@
+);
+
+# Test require_auth settings against OAUTHBEARER.
+my @cases = (
+	{ require_auth => "oauth" },
+	{ require_auth => "oauth,scram-sha-256" },
+	{ require_auth => "password,oauth" },
+	{ require_auth => "none,oauth" },
+	{ require_auth => "!scram-sha-256" },
+	{ require_auth => "!none" },
+
+	{
+		require_auth => "!oauth",
+		failure => qr/server requested OAUTHBEARER authentication/
+	},
+	{
+		require_auth => "scram-sha-256",
+		failure => qr/server requested OAUTHBEARER authentication/
+	},
+	{
+		require_auth => "!password,!oauth",
+		failure => qr/server requested OAUTHBEARER authentication/
+	},
+	{
+		require_auth => "none",
+		failure => qr/server requested SASL authentication/
+	},
+	{
+		require_auth => "!oauth,!scram-sha-256",
+		failure => qr/server requested SASL authentication/
+	});
+
+$user = "test";
+foreach my $c (@cases)
+{
+	my $connstr =
+	  "user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635 require_auth=$c->{'require_auth'}";
+
+	if (defined $c->{'failure'})
+	{
+		$node->connect_fails(
+			$connstr,
+			"require_auth=$c->{'require_auth'} fails",
+			expected_stderr => $c->{'failure'});
+	}
+	else
+	{
+		$node->connect_ok(
+			$connstr,
+			"require_auth=$c->{'require_auth'} succeeds",
+			expected_stderr =>
+			  qr@Visit https://example\.com/ and enter the code: postgresuser@
+		);
+	}
+}
+
+# Make sure the client_id and secret are correctly encoded. $vschars contains
+# every allowed character for a client_id/_secret (the "VSCHAR" class).
+# $vschars_esc is additionally backslash-escaped for inclusion in a
+# single-quoted connection string.
+my $vschars =
+  " !\"#\$%&'()*+,-./0123456789:;<=>?\@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~";
+my $vschars_esc =
+  " !\"#\$%&\\'()*+,-./0123456789:;<=>?\@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\\\]^_`abcdefghijklmnopqrstuvwxyz{|}~";
+
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id='$vschars_esc'",
+	"escapable characters: client_id",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id='$vschars_esc' oauth_client_secret='$vschars_esc'",
+	"escapable characters: client_id and secret",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+#
+# Further tests rely on support for specific behaviors in oauth_server.py. To
+# trigger these behaviors, we ask for the special issuer .../param (which is set
+# up in HBA for the testparam user) and encode magic instructions into the
+# oauth_client_id.
+#
+
+my $common_connstr =
+  "user=testparam dbname=postgres oauth_issuer=$issuer/param ";
+my $base_connstr = $common_connstr;
+
+sub connstr
+{
+	my (%params) = @_;
+
+	my $json = encode_json(\%params);
+	my $encoded = encode_base64($json, "");
+
+	return "$base_connstr oauth_client_id=$encoded";
+}
+
+# Make sure the param system works end-to-end first.
+$node->connect_ok(
+	connstr(),
+	"connect to /param",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$node->connect_ok(
+	connstr(stage => 'token', retries => 1),
+	"token retry",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(stage => 'token', retries => 2),
+	"token retry (twice)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(stage => 'all', retries => 1, interval => 2),
+	"token retry (two second interval)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(stage => 'all', retries => 1, interval => JSON::PP::null),
+	"token retry (default interval)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$node->connect_ok(
+	connstr(stage => 'all', content_type => 'application/json;charset=utf-8'),
+	"content type with charset",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(
+		stage => 'all',
+		content_type => "application/json \t;\t charset=utf-8"),
+	"content type with charset (whitespace)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(stage => 'device', uri_spelling => "verification_url"),
+	"alternative spelling of verification_uri",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$node->connect_fails(
+	connstr(stage => 'device', huge_response => JSON::PP::true),
+	"bad device authz response: overlarge JSON",
+	expected_stderr =>
+	  qr/failed to obtain device authorization: response is too large/);
+$node->connect_fails(
+	connstr(stage => 'token', huge_response => JSON::PP::true),
+	"bad token response: overlarge JSON",
+	expected_stderr =>
+	  qr/failed to obtain access token: response is too large/);
+
+$node->connect_fails(
+	connstr(stage => 'device', content_type => 'text/plain'),
+	"bad device authz response: wrong content type",
+	expected_stderr =>
+	  qr/failed to parse device authorization: unexpected content type/);
+$node->connect_fails(
+	connstr(stage => 'token', content_type => 'text/plain'),
+	"bad token response: wrong content type",
+	expected_stderr =>
+	  qr/failed to parse access token response: unexpected content type/);
+$node->connect_fails(
+	connstr(stage => 'token', content_type => 'application/jsonx'),
+	"bad token response: wrong content type (correct prefix)",
+	expected_stderr =>
+	  qr/failed to parse access token response: unexpected content type/);
+
+$node->connect_fails(
+	connstr(
+		stage => 'all',
+		interval => ~0,
+		retries => 1,
+		retry_code => "slow_down"),
+	"bad token response: server overflows the device authz interval",
+	expected_stderr =>
+	  qr/failed to obtain access token: slow_down interval overflow/);
+
+$node->connect_fails(
+	connstr(stage => 'token', error_code => "invalid_grant"),
+	"bad token response: invalid_grant, no description",
+	expected_stderr => qr/failed to obtain access token: \(invalid_grant\)/);
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_grant",
+		error_desc => "grant expired"),
+	"bad token response: expired grant",
+	expected_stderr =>
+	  qr/failed to obtain access token: grant expired \(invalid_grant\)/);
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_client",
+		error_status => 401),
+	"bad token response: client authentication failure, default description",
+	expected_stderr =>
+	  qr/failed to obtain access token: provider requires client authentication, and no oauth_client_secret is set \(invalid_client\)/
+);
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_client",
+		error_status => 401,
+		error_desc => "authn failure"),
+	"bad token response: client authentication failure, provided description",
+	expected_stderr =>
+	  qr/failed to obtain access token: authn failure \(invalid_client\)/);
+
+$node->connect_fails(
+	connstr(stage => 'token', token => ""),
+	"server rejects access: empty token",
+	expected_stderr => qr/bearer authentication failed/);
+$node->connect_fails(
+	connstr(stage => 'token', token => "****"),
+	"server rejects access: invalid token contents",
+	expected_stderr => qr/bearer authentication failed/);
+
+# Test behavior of the oauth_client_secret.
+$base_connstr = "$common_connstr oauth_client_secret=''";
+
+$node->connect_ok(
+	connstr(stage => 'all', expected_secret => ''),
+	"empty oauth_client_secret",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$base_connstr = "$common_connstr oauth_client_secret='$vschars_esc'";
+
+$node->connect_ok(
+	connstr(stage => 'all', expected_secret => $vschars),
+	"nonempty oauth_client_secret",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_client",
+		error_status => 401),
+	"bad token response: client authentication failure, default description with oauth_client_secret",
+	expected_stderr =>
+	  qr/failed to obtain access token: provider rejected the oauth_client_secret \(invalid_client\)/
+);
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_client",
+		error_status => 401,
+		error_desc => "mutual TLS required for client"),
+	"bad token response: client authentication failure, provided description with oauth_client_secret",
+	expected_stderr =>
+	  qr/failed to obtain access token: mutual TLS required for client \(invalid_client\)/
+);
+
+#
+# This section of tests reconfigures the validator module itself, rather than
+# the OAuth server.
+#
+
+# Searching the logs is easier if OAuth parameter discovery isn't cluttering
+# things up; hardcode the discovery URI. (Scope is hardcoded to empty to cover
+# that case as well.)
+$common_connstr =
+  "dbname=postgres oauth_issuer=$issuer/.well-known/openid-configuration oauth_scope='' oauth_client_id=f02c6361-0635";
+
+# Misbehaving validators must fail shut.
+$bgconn->query_safe("ALTER SYSTEM SET oauth_validator.authn_id TO ''");
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+$node->connect_fails(
+	"$common_connstr user=test",
+	"validator must set authn_id",
+	expected_stderr => qr/OAuth bearer authentication failed/,
+	log_like => [
+		qr/connection authenticated: identity=""/,
+		qr/DETAIL:\s+Validator provided no identity/,
+		qr/FATAL:\s+OAuth bearer authentication failed/,
+	]);
+
+# Even if a validator authenticates the user, if the token isn't considered
+# valid, the connection fails.
+$bgconn->query_safe(
+	"ALTER SYSTEM SET oauth_validator.authn_id TO 'test\@example.org'");
+$bgconn->query_safe(
+	"ALTER SYSTEM SET oauth_validator.authorize_tokens TO false");
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+$node->connect_fails(
+	"$common_connstr user=test",
+	"validator must authorize token explicitly",
+	expected_stderr => qr/OAuth bearer authentication failed/,
+	log_like => [
+		qr/connection authenticated: identity="test\@example\.org"/,
+		qr/DETAIL:\s+Validator failed to authorize the provided token/,
+		qr/FATAL:\s+OAuth bearer authentication failed/,
+	]);
+
+#
+# Test user mapping.
+#
+
+# Allow "[email protected]" to log in under the test role.
+unlink($node->data_dir . '/pg_ident.conf');
+$node->append_conf(
+	'pg_ident.conf', qq{
+oauthmap	user\@example.com	test
+});
+
+# test and testalt use the map; testparam uses ident delegation.
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test      oauth issuer="$issuer" scope="" map=oauthmap
+local all testalt   oauth issuer="$issuer" scope="" map=oauthmap
+local all testparam oauth issuer="$issuer" scope="" delegate_ident_mapping=1
+});
+
+# To start, have the validator use the role names as authn IDs.
+$bgconn->query_safe("ALTER SYSTEM RESET oauth_validator.authn_id");
+$bgconn->query_safe("ALTER SYSTEM RESET oauth_validator.authorize_tokens");
+
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+# The test and testalt roles should no longer map correctly.
+$node->connect_fails(
+	"$common_connstr user=test",
+	"mismatched username map (test)",
+	expected_stderr => qr/OAuth bearer authentication failed/);
+$node->connect_fails(
+	"$common_connstr user=testalt",
+	"mismatched username map (testalt)",
+	expected_stderr => qr/OAuth bearer authentication failed/);
+
+# Have the validator identify the end user as [email protected].
+$bgconn->query_safe(
+	"ALTER SYSTEM SET oauth_validator.authn_id TO 'user\@example.com'");
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+# Now the test role can be logged into. (testalt still can't be mapped.)
+$node->connect_ok(
+	"$common_connstr user=test",
+	"matched username map (test)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_fails(
+	"$common_connstr user=testalt",
+	"mismatched username map (testalt)",
+	expected_stderr => qr/OAuth bearer authentication failed/);
+
+# testparam ignores the map entirely.
+$node->connect_ok(
+	"$common_connstr user=testparam",
+	"delegated ident (testparam)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$bgconn->query_safe("ALTER SYSTEM RESET oauth_validator.authn_id");
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+#
+# Test multiple validators.
+#
+
+$node->append_conf('postgresql.conf',
+	"oauth_validator_libraries = 'validator, fail_validator'\n");
+
+# With multiple validators, every HBA line must explicitly declare one.
+my $result = $node->restart(fail_ok => 1);
+is($result, 0,
+	'restart fails without explicit validators in oauth HBA entries');
+
+$log_start = $node->wait_for_log(
+	qr/authentication method "oauth" requires argument "validator" to be set/,
+	$log_start);
+
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test    oauth validator=validator      issuer="$issuer"           scope="openid postgres"
+local all testalt oauth validator=fail_validator issuer="$issuer/.well-known/oauth-authorization-server/alternate" scope="openid postgres alt"
+});
+$node->restart;
+
+$log_start = $node->wait_for_log(qr/ready to accept connections/, $log_start);
+
+# The test user should work as before.
+$user = "test";
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635",
+	"validator is used for $user",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@,
+	log_like => [qr/connection authorized/]);
+
+# testalt should be routed through the fail_validator.
+$user = "testalt";
+$node->connect_fails(
+	"user=$user dbname=postgres oauth_issuer=$issuer/.well-known/oauth-authorization-server/alternate oauth_client_id=f02c6361-0636",
+	"fail_validator is used for $user",
+	expected_stderr => qr/FATAL:\s+fail_validator: sentinel error/);
+
+$node->stop;
+
+done_testing();
diff --git a/src/test/modules/oauth_validator/t/002_client.pl b/src/test/modules/oauth_validator/t/002_client.pl
new file mode 100644
index 00000000000..95cccf90dd8
--- /dev/null
+++ b/src/test/modules/oauth_validator/t/002_client.pl
@@ -0,0 +1,154 @@
+#
+# Exercises the API for custom OAuth client flows, using the oauth_hook_client
+# test driver.
+#
+# Copyright (c) 2021-2024, PostgreSQL Global Development Group
+#
+
+use strict;
+use warnings FATAL => 'all';
+
+use JSON::PP     qw(encode_json);
+use MIME::Base64 qw(encode_base64);
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+if (!$ENV{PG_TEST_EXTRA} || $ENV{PG_TEST_EXTRA} !~ /\boauth\b/)
+{
+	plan skip_all =>
+	  'Potentially unsafe test oauth not enabled in PG_TEST_EXTRA';
+}
+
+#
+# Cluster Setup
+#
+
+my $node = PostgreSQL::Test::Cluster->new('primary');
+$node->init;
+$node->append_conf('postgresql.conf', "log_connections = on\n");
+$node->append_conf('postgresql.conf',
+	"oauth_validator_libraries = 'validator'\n");
+$node->start;
+
+$node->safe_psql('postgres', 'CREATE USER test;');
+
+# These tests don't use the builtin flow, and we don't have an authorization
+# server running, so the address used here shouldn't matter. Use an invalid IP
+# address, so if there's some cascade of errors that causes the client to
+# attempt a connection, we'll fail noisily.
+my $issuer = "https://256.256.256.256";
+my $scope = "openid postgres";
+
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test oauth issuer="$issuer" scope="$scope"
+});
+$node->reload;
+
+my ($log_start, $log_end);
+$log_start = $node->wait_for_log(qr/reloading configuration files/);
+
+$ENV{PGOAUTHDEBUG} = "UNSAFE";
+
+#
+# Tests
+#
+
+my $user = "test";
+my $base_connstr = $node->connstr() . " user=$user";
+my $common_connstr =
+  "$base_connstr oauth_issuer=$issuer oauth_client_id=myID";
+
+sub test
+{
+	my ($test_name, %params) = @_;
+
+	my $flags = [];
+	if (defined($params{flags}))
+	{
+		$flags = $params{flags};
+	}
+
+	my @cmd = ("oauth_hook_client", @{$flags}, $common_connstr);
+	note "running '" . join("' '", @cmd) . "'";
+
+	my ($stdout, $stderr) = run_command(\@cmd);
+
+	if (defined($params{expected_stdout}))
+	{
+		like($stdout, $params{expected_stdout}, "$test_name: stdout matches");
+	}
+
+	if (defined($params{expected_stderr}))
+	{
+		like($stderr, $params{expected_stderr}, "$test_name: stderr matches");
+	}
+	else
+	{
+		is($stderr, "", "$test_name: no stderr");
+	}
+}
+
+test(
+	"basic synchronous hook can provide a token",
+	flags => [
+		"--token", "my-token",
+		"--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"/, ]);
+
+if ($ENV{with_libcurl} ne 'yes')
+{
+	# libpq should help users out if no OAuth support is built in.
+	test(
+		"fails without custom hook installed",
+		flags => ["--no-hook"],
+		expected_stderr =>
+		  qr/no custom OAuth flows are available, and libpq was not built with libcurl support/
+	);
+}
+
+# connect_timeout should work if the flow doesn't respond.
+$common_connstr = "$common_connstr connect_timeout=1";
+test(
+	"connect_timeout interrupts hung client flow",
+	flags => ["--hang-forever"],
+	expected_stderr => qr/failed: timeout expired/);
+
+# Test various misbehaviors of the client hook.
+my @cases = (
+	{
+		flag => "--misbehave=no-hook",
+		expected_error =>
+		  qr/user-defined OAuth flow provided neither a token nor an async callback/,
+	},
+	{
+		flag => "--misbehave=fail-async",
+		expected_error => qr/user-defined OAuth flow failed/,
+	},
+	{
+		flag => "--misbehave=no-token",
+		expected_error => qr/user-defined OAuth flow did not provide a token/,
+	},
+	{
+		flag => "--misbehave=no-socket",
+		expected_error =>
+		  qr/user-defined OAuth flow did not provide a socket for polling/,
+	});
+
+foreach my $c (@cases)
+{
+	test(
+		"hook misbehavior: $c->{'flag'}",
+		flags => [ $c->{'flag'} ],
+		expected_stderr => $c->{'expected_error'});
+}
+
+done_testing();
diff --git a/src/test/modules/oauth_validator/t/OAuth/Server.pm b/src/test/modules/oauth_validator/t/OAuth/Server.pm
new file mode 100644
index 00000000000..f0f23d1d1a8
--- /dev/null
+++ b/src/test/modules/oauth_validator/t/OAuth/Server.pm
@@ -0,0 +1,140 @@
+
+# Copyright (c) 2024, PostgreSQL Global Development Group
+
+=pod
+
+=head1 NAME
+
+OAuth::Server - runs a mock OAuth authorization server for testing
+
+=head1 SYNOPSIS
+
+  use OAuth::Server;
+
+  my $server = OAuth::Server->new();
+  $server->run;
+
+  my $port = $server->port;
+  my $issuer = "http://localhost:$port";
+
+  # test against $issuer...
+
+  $server->stop;
+
+=head1 DESCRIPTION
+
+This is glue API between the Perl tests and the Python authorization server
+daemon implemented in t/oauth_server.py. (Python has a fairly usable HTTP server
+in its standard library, so the implementation was ported from Perl.)
+
+This authorization server does not use TLS (it implements a nonstandard, unsafe
+issuer at "http://localhost:<port>"), so libpq in particular will need to set
+PGOAUTHDEBUG=UNSAFE to be able to talk to it.
+
+=cut
+
+package OAuth::Server;
+
+use warnings;
+use strict;
+use Scalar::Util;
+use Test::More;
+
+=pod
+
+=head1 METHODS
+
+=over
+
+=item SSL::Server->new()
+
+Create a new OAuth Server object.
+
+=cut
+
+sub new
+{
+	my $class = shift;
+
+	my $self = {};
+	bless($self, $class);
+
+	return $self;
+}
+
+=pod
+
+=item $server->port()
+
+Returns the port in use by the server.
+
+=cut
+
+sub port
+{
+	my $self = shift;
+
+	return $self->{'port'};
+}
+
+=pod
+
+=item $server->run()
+
+Runs the authorization server daemon in t/oauth_server.py.
+
+=cut
+
+sub run
+{
+	my $self = shift;
+	my $port;
+
+	my $pid = open(my $read_fh, "-|", $ENV{PYTHON}, "t/oauth_server.py")
+	  or die "failed to start OAuth server: $!";
+
+	# Get the port number from the daemon. It closes stdout afterwards; that way
+	# we can slurp in the entire contents here rather than worrying about the
+	# number of bytes to read.
+	$port = do { local $/ = undef; <$read_fh> }
+	  // die "failed to read port number: $!";
+	chomp $port;
+	die "server did not advertise a valid port"
+	  unless Scalar::Util::looks_like_number($port);
+
+	$self->{'pid'} = $pid;
+	$self->{'port'} = $port;
+	$self->{'child'} = $read_fh;
+
+	note("OAuth provider (PID $pid) is listening on port $port\n");
+}
+
+=pod
+
+=item $server->stop()
+
+Sends SIGTERM to the authorization server and waits for it to exit.
+
+=cut
+
+sub stop
+{
+	my $self = shift;
+
+	note("Sending SIGTERM to OAuth provider PID: $self->{'pid'}\n");
+
+	kill(15, $self->{'pid'});
+	$self->{'pid'} = undef;
+
+	# Closing the popen() handle waits for the process to exit.
+	close($self->{'child'});
+	$self->{'child'} = undef;
+}
+
+=pod
+
+=back
+
+=cut
+
+1;
diff --git a/src/test/modules/oauth_validator/t/oauth_server.py b/src/test/modules/oauth_validator/t/oauth_server.py
new file mode 100755
index 00000000000..8ec09102027
--- /dev/null
+++ b/src/test/modules/oauth_validator/t/oauth_server.py
@@ -0,0 +1,391 @@
+#! /usr/bin/env python3
+#
+# A mock OAuth authorization server, designed to be invoked from
+# OAuth/Server.pm. This listens on an ephemeral port number (printed to stdout
+# so that the Perl tests can contact it) and runs as a daemon until it is
+# signaled.
+#
+
+import base64
+import http.server
+import json
+import os
+import sys
+import time
+import urllib.parse
+from collections import defaultdict
+
+
+class OAuthHandler(http.server.BaseHTTPRequestHandler):
+    """
+    Core implementation of the authorization server. The API is
+    inheritance-based, with entry points at do_GET() and do_POST(). See the
+    documentation for BaseHTTPRequestHandler.
+    """
+
+    JsonObject = dict[str, object]  # TypeAlias is not available until 3.10
+
+    def _check_issuer(self):
+        """
+        Switches the behavior of the provider depending on the issuer URI.
+        """
+        self._alt_issuer = (
+            self.path.startswith("/alternate/")
+            or self.path == "/.well-known/oauth-authorization-server/alternate"
+        )
+        self._parameterized = self.path.startswith("/param/")
+
+        if self._alt_issuer:
+            # The /alternate issuer uses IETF-style .well-known URIs.
+            if self.path.startswith("/.well-known/"):
+                self.path = self.path.removesuffix("/alternate")
+            else:
+                self.path = self.path.removeprefix("/alternate")
+        elif self._parameterized:
+            self.path = self.path.removeprefix("/param")
+
+    def _check_authn(self):
+        """
+        Checks the expected value of the Authorization header, if any.
+        """
+        secret = self._get_param("expected_secret", None)
+        if secret is None:
+            return
+
+        assert "Authorization" in self.headers
+        method, creds = self.headers["Authorization"].split()
+
+        if method != "Basic":
+            raise RuntimeError(f"client used {method} auth; expected Basic")
+
+        username = urllib.parse.quote_plus(self.client_id)
+        password = urllib.parse.quote_plus(secret)
+        expected_creds = f"{username}:{password}"
+
+        if creds.encode() != base64.b64encode(expected_creds.encode()):
+            raise RuntimeError(
+                f"client sent '{creds}'; expected b64encode('{expected_creds}')"
+            )
+
+    def do_GET(self):
+        self._response_code = 200
+        self._check_issuer()
+
+        config_path = "/.well-known/openid-configuration"
+        if self._alt_issuer:
+            config_path = "/.well-known/oauth-authorization-server"
+
+        if self.path == config_path:
+            resp = self.config()
+        else:
+            self.send_error(404, "Not Found")
+            return
+
+        self._send_json(resp)
+
+    def _parse_params(self) -> dict[str, str]:
+        """
+        Parses apart the form-urlencoded request body and returns the resulting
+        dict. For use by do_POST().
+        """
+        size = int(self.headers["Content-Length"])
+        form = self.rfile.read(size)
+
+        assert self.headers["Content-Type"] == "application/x-www-form-urlencoded"
+        return urllib.parse.parse_qs(
+            form.decode("utf-8"),
+            strict_parsing=True,
+            keep_blank_values=True,
+            encoding="utf-8",
+            errors="strict",
+        )
+
+    @property
+    def client_id(self) -> str:
+        """
+        Returns the client_id sent in the POST body or the Authorization header.
+        self._parse_params() must have been called first.
+        """
+        if "client_id" in self._params:
+            return self._params["client_id"][0]
+
+        if "Authorization" not in self.headers:
+            raise RuntimeError("client did not send any client_id")
+
+        _, creds = self.headers["Authorization"].split()
+
+        decoded = base64.b64decode(creds).decode("utf-8")
+        username, _ = decoded.split(":", 1)
+
+        return urllib.parse.unquote_plus(username)
+
+    def do_POST(self):
+        self._response_code = 200
+        self._check_issuer()
+
+        self._params = self._parse_params()
+        if self._parameterized:
+            # Pull encoded test parameters out of the peer's client_id field.
+            # This is expected to be Base64-encoded JSON.
+            js = base64.b64decode(self.client_id)
+            self._test_params = json.loads(js)
+
+        self._check_authn()
+
+        if self.path == "/authorize":
+            resp = self.authorization()
+        elif self.path == "/token":
+            resp = self.token()
+        else:
+            self.send_error(404)
+            return
+
+        self._send_json(resp)
+
+    def _should_modify(self) -> bool:
+        """
+        Returns True if the client has requested a modification to this stage of
+        the exchange.
+        """
+        if not hasattr(self, "_test_params"):
+            return False
+
+        stage = self._test_params.get("stage")
+
+        return (
+            stage == "all"
+            or (
+                stage == "discovery"
+                and self.path == "/.well-known/openid-configuration"
+            )
+            or (stage == "device" and self.path == "/authorize")
+            or (stage == "token" and self.path == "/token")
+        )
+
+    def _get_param(self, name, default):
+        """
+        If the client has requested a modification to this stage (see
+        _should_modify()), this method searches the provided test parameters for
+        a key of the given name, and returns it if found. Otherwise the provided
+        default is returned.
+        """
+        if self._should_modify() and name in self._test_params:
+            return self._test_params[name]
+
+        return default
+
+    @property
+    def _content_type(self) -> str:
+        """
+        Returns "application/json" unless the test has requested something
+        different.
+        """
+        return self._get_param("content_type", "application/json")
+
+    @property
+    def _interval(self) -> int:
+        """
+        Returns 0 unless the test has requested something different.
+        """
+        return self._get_param("interval", 0)
+
+    @property
+    def _retry_code(self) -> str:
+        """
+        Returns "authorization_pending" unless the test has requested something
+        different.
+        """
+        return self._get_param("retry_code", "authorization_pending")
+
+    @property
+    def _uri_spelling(self) -> str:
+        """
+        Returns "verification_uri" unless the test has requested something
+        different.
+        """
+        return self._get_param("uri_spelling", "verification_uri")
+
+    @property
+    def _response_padding(self):
+        """
+        If the huge_response test parameter is set to True, returns a dict
+        containing a gigantic string value, which can then be folded into a JSON
+        response.
+        """
+        if not self._get_param("huge_response", False):
+            return dict()
+
+        return {"_pad_": "x" * 1024 * 1024}
+
+    @property
+    def _access_token(self):
+        """
+        The actual Bearer token sent back to the client on success. Tests may
+        override this with the "token" test parameter.
+        """
+        token = self._get_param("token", None)
+        if token is not None:
+            return token
+
+        token = "9243959234"
+        if self._alt_issuer:
+            token += "-alt"
+
+        return token
+
+    def _send_json(self, js: JsonObject) -> None:
+        """
+        Sends the provided JSON dict as an application/json response.
+        self._response_code can be modified to send JSON error responses.
+        """
+        resp = json.dumps(js).encode("ascii")
+        self.log_message("sending JSON response: %s", resp)
+
+        self.send_response(self._response_code)
+        self.send_header("Content-Type", self._content_type)
+        self.send_header("Content-Length", str(len(resp)))
+        self.end_headers()
+
+        self.wfile.write(resp)
+
+    def config(self) -> JsonObject:
+        port = self.server.socket.getsockname()[1]
+
+        issuer = f"http://localhost:{port}"
+        if self._alt_issuer:
+            issuer += "/alternate"
+        elif self._parameterized:
+            issuer += "/param"
+
+        return {
+            "issuer": issuer,
+            "token_endpoint": issuer + "/token",
+            "device_authorization_endpoint": issuer + "/authorize",
+            "response_types_supported": ["token"],
+            "subject_types_supported": ["public"],
+            "id_token_signing_alg_values_supported": ["RS256"],
+            "grant_types_supported": [
+                "authorization_code",
+                "urn:ietf:params:oauth:grant-type:device_code",
+            ],
+        }
+
+    @property
+    def _token_state(self):
+        """
+        A cached _TokenState object for the connected client (as determined by
+        the request's client_id), or a new one if it doesn't already exist.
+
+        This relies on the existence of a defaultdict attached to the server;
+        see main() below.
+        """
+        return self.server.token_state[self.client_id]
+
+    def _remove_token_state(self):
+        """
+        Removes any cached _TokenState for the current client_id. Call this
+        after the token exchange ends to get rid of unnecessary state.
+        """
+        if self.client_id in self.server.token_state:
+            del self.server.token_state[self.client_id]
+
+    def authorization(self) -> JsonObject:
+        uri = "https://example.com/"
+        if self._alt_issuer:
+            uri = "https://example.org/"
+
+        resp = {
+            "device_code": "postgres",
+            "user_code": "postgresuser",
+            self._uri_spelling: uri,
+            "expires-in": 5,
+            **self._response_padding,
+        }
+
+        interval = self._interval
+        if interval is not None:
+            resp["interval"] = interval
+            self._token_state.min_delay = interval
+        else:
+            self._token_state.min_delay = 5  # default
+
+        # Check the scope.
+        if "scope" in self._params:
+            assert self._params["scope"][0], "empty scopes should be omitted"
+
+        return resp
+
+    def token(self) -> JsonObject:
+        if err := self._get_param("error_code", None):
+            self._response_code = self._get_param("error_status", 400)
+
+            resp = {"error": err}
+            if desc := self._get_param("error_desc", ""):
+                resp["error_description"] = desc
+
+            return resp
+
+        if self._should_modify() and "retries" in self._test_params:
+            retries = self._test_params["retries"]
+
+            # Check to make sure the token interval is being respected.
+            now = time.monotonic()
+            if self._token_state.last_try is not None:
+                delay = now - self._token_state.last_try
+                assert (
+                    delay > self._token_state.min_delay
+                ), f"client waited only {delay} seconds between token requests (expected {self._token_state.min_delay})"
+
+            self._token_state.last_try = now
+
+            # If we haven't reached the required number of retries yet, return a
+            # "pending" response.
+            if self._token_state.retries < retries:
+                self._token_state.retries += 1
+
+                self._response_code = 400
+                return {"error": self._retry_code}
+
+        # Clean up any retry tracking state now that the exchange is ending.
+        self._remove_token_state()
+
+        return {
+            "access_token": self._access_token,
+            "token_type": "bearer",
+            **self._response_padding,
+        }
+
+
+def main():
+    """
+    Starts the authorization server on localhost. The ephemeral port in use will
+    be printed to stdout.
+    """
+
+    s = http.server.HTTPServer(("127.0.0.1", 0), OAuthHandler)
+
+    # Attach a "cache" dictionary to the server to allow the OAuthHandlers to
+    # track state across token requests. The use of defaultdict ensures that new
+    # entries will be created automatically.
+    class _TokenState:
+        retries = 0
+        min_delay = None
+        last_try = None
+
+    s.token_state = defaultdict(_TokenState)
+
+    # Give the parent the port number to contact (this is also the signal that
+    # we're ready to receive requests).
+    port = s.socket.getsockname()[1]
+    print(port)
+
+    # stdout is closed to allow the parent to just "read to the end".
+    stdout = sys.stdout.fileno()
+    sys.stdout.close()
+    os.close(stdout)
+
+    s.serve_forever()  # we expect our parent to send a termination signal
+
+
+if __name__ == "__main__":
+    main()
diff --git a/src/test/modules/oauth_validator/validator.c b/src/test/modules/oauth_validator/validator.c
new file mode 100644
index 00000000000..bf94f091def
--- /dev/null
+++ b/src/test/modules/oauth_validator/validator.c
@@ -0,0 +1,135 @@
+/*-------------------------------------------------------------------------
+ *
+ * validator.c
+ *	  Test module for serverside OAuth token validation callbacks
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/test/modules/oauth_validator/validator.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "fmgr.h"
+#include "libpq/oauth.h"
+#include "miscadmin.h"
+#include "utils/guc.h"
+#include "utils/memutils.h"
+
+PG_MODULE_MAGIC;
+
+static void validator_startup(ValidatorModuleState *state);
+static void validator_shutdown(ValidatorModuleState *state);
+static ValidatorModuleResult *validate_token(ValidatorModuleState *state,
+											 const char *token,
+											 const char *role);
+
+/* Callback implementations (exercise all three) */
+static const OAuthValidatorCallbacks validator_callbacks = {
+	.startup_cb = validator_startup,
+	.shutdown_cb = validator_shutdown,
+	.validate_cb = validate_token
+};
+
+/* GUCs */
+static char *authn_id = NULL;
+static bool authorize_tokens = true;
+
+/*---
+ * Extension entry point. Sets up GUCs for use by tests:
+ *
+ * - oauth_validator.authn_id	Sets the user identifier to return during token
+ *								validation. Defaults to the username in the
+ *								startup packet.
+ *
+ * - oauth_validator.authorize_tokens
+ *								Sets whether to successfully validate incoming
+ *								tokens. Defaults to true.
+ */
+void
+_PG_init(void)
+{
+	DefineCustomStringVariable("oauth_validator.authn_id",
+							   "Authenticated identity to use for future connections",
+							   NULL,
+							   &authn_id,
+							   NULL,
+							   PGC_SIGHUP,
+							   0,
+							   NULL, NULL, NULL);
+	DefineCustomBoolVariable("oauth_validator.authorize_tokens",
+							 "Should tokens be marked valid?",
+							 NULL,
+							 &authorize_tokens,
+							 true,
+							 PGC_SIGHUP,
+							 0,
+							 NULL, NULL, NULL);
+
+	MarkGUCPrefixReserved("oauth_validator");
+}
+
+/*
+ * Validator module entry point.
+ */
+const OAuthValidatorCallbacks *
+_PG_oauth_validator_module_init(void)
+{
+	return &validator_callbacks;
+}
+
+#define PRIVATE_COOKIE ((void *) 13579)
+
+/*
+ * Startup callback, to set up private data for the validator.
+ */
+static void
+validator_startup(ValidatorModuleState *state)
+{
+	state->private_data = PRIVATE_COOKIE;
+}
+
+/*
+ * Shutdown callback, to tear down the validator.
+ */
+static void
+validator_shutdown(ValidatorModuleState *state)
+{
+	/* Check to make sure our private state still exists. */
+	if (state->private_data != PRIVATE_COOKIE)
+		elog(ERROR, "oauth_validator: private state cookie changed to %p in shutdown",
+			 state->private_data);
+}
+
+/*
+ * Validator implementation. Logs the incoming data and authorizes the token by
+ * default; the behavior can be modified via the module's GUC settings.
+ */
+static ValidatorModuleResult *
+validate_token(ValidatorModuleState *state, const char *token, const char *role)
+{
+	ValidatorModuleResult *res;
+
+	/* Check to make sure our private state still exists. */
+	if (state->private_data != PRIVATE_COOKIE)
+		elog(ERROR, "oauth_validator: private state cookie changed to %p in validate",
+			 state->private_data);
+
+	res = palloc(sizeof(ValidatorModuleResult));
+
+	elog(LOG, "oauth_validator: token=\"%s\", role=\"%s\"", token, role);
+	elog(LOG, "oauth_validator: issuer=\"%s\", scope=\"%s\"",
+		 MyProcPort->hba->oauth_issuer,
+		 MyProcPort->hba->oauth_scope);
+
+	res->authorized = authorize_tokens;
+	if (authn_id)
+		res->authn_id = pstrdup(authn_id);
+	else
+		res->authn_id = pstrdup(role);
+
+	return res;
+}
diff --git a/src/test/perl/PostgreSQL/Test/Cluster.pm b/src/test/perl/PostgreSQL/Test/Cluster.pm
index f521ad0b12f..ab7d7452ede 100644
--- a/src/test/perl/PostgreSQL/Test/Cluster.pm
+++ b/src/test/perl/PostgreSQL/Test/Cluster.pm
@@ -2515,6 +2515,11 @@ instead of the default.
 
 If this regular expression is set, matches it with the output generated.
 
+=item expected_stderr => B<value>
+
+If this regular expression is set, matches it against the standard error
+stream; otherwise the stderr must be empty.
+
 =item log_like => [ qr/required message/ ]
 
 =item log_unlike => [ qr/prohibited message/ ]
@@ -2558,7 +2563,20 @@ sub connect_ok
 		like($stdout, $params{expected_stdout}, "$test_name: stdout matches");
 	}
 
-	is($stderr, "", "$test_name: no stderr");
+	if (defined($params{expected_stderr}))
+	{
+		if (like($stderr, $params{expected_stderr}, "$test_name: stderr matches")
+			&& ($ret != 0))
+		{
+			# In this case (failing test but matching stderr) we'll have
+			# swallowed the output needed to debug. Put it back into the logs.
+			diag("$test_name: full stderr:\n" . $stderr);
+		}
+	}
+	else
+	{
+		is($stderr, "", "$test_name: no stderr");
+	}
 
 	$self->log_check($test_name, $log_location, %params);
 }
diff --git a/src/tools/pgindent/pgindent b/src/tools/pgindent/pgindent
index d8acce7e929..7dccf4614aa 100755
--- a/src/tools/pgindent/pgindent
+++ b/src/tools/pgindent/pgindent
@@ -242,6 +242,14 @@ sub pre_indent
 	# Protect wrapping in CATALOG()
 	$source =~ s!^(CATALOG\(.*)$!/*$1*/!gm;
 
+	# Treat a CURL_IGNORE_DEPRECATION() as braces for the purposes of
+	# indentation. (The recursive regex comes from the perlre documentation; it
+	# matches balanced parentheses as group $1 and the contents as group $2.)
+	my $curlopen = '{ /* CURL_IGNORE_DEPRECATION */';
+	my $curlclose = '} /* CURL_IGNORE_DEPRECATION */';
+	$source =~
+	  s!^[ \t]+CURL_IGNORE_DEPRECATION(\(((?:(?>[^()]+)|(?1))*)\))!$curlopen$2$curlclose!gms;
+
 	return $source;
 }
 
@@ -256,6 +264,12 @@ sub post_indent
 	$source =~ s!^/\* Open extern "C" \*/$!{!gm;
 	$source =~ s!^/\* Close extern "C" \*/$!}!gm;
 
+	# Restore the CURL_IGNORE_DEPRECATION() macro, keeping in mind that our
+	# markers may have been re-indented.
+	$source =~
+	  s!{[ \t]+/\* CURL_IGNORE_DEPRECATION \*/!CURL_IGNORE_DEPRECATION(!gm;
+	$source =~ s!}[ \t]+/\* CURL_IGNORE_DEPRECATION \*/!)!gm;
+
 	## Comments
 
 	# Undo change of dash-protected block comments
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index a2644a2e653..f5e29b2cc90 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -371,6 +371,9 @@ CState
 CTECycleClause
 CTEMaterialize
 CTESearchClause
+CURL
+CURLM
+CURLoption
 CV
 CachedExpression
 CachedPlan
@@ -1724,6 +1727,7 @@ NumericDigit
 NumericSortSupport
 NumericSumAccum
 NumericVar
+OAuthValidatorCallbacks
 OM_uint32
 OP
 OSAPerGroupState
@@ -1832,6 +1836,7 @@ PGVerbosity
 PG_Locale_Strategy
 PG_Lock_Status
 PG_init_t
+PGauthData
 PGcancel
 PGcancelConn
 PGcmdQueueEntry
@@ -1839,7 +1844,9 @@ PGconn
 PGdataValue
 PGlobjfuncs
 PGnotify
+PGoauthBearerRequest
 PGpipelineStatus
+PGpromptOAuthDevice
 PGresAttDesc
 PGresAttValue
 PGresParamDesc
@@ -1951,6 +1958,7 @@ PQArgBlock
 PQEnvironmentOption
 PQExpBuffer
 PQExpBufferData
+PQauthDataHook_type
 PQcommMethods
 PQconninfoOption
 PQnoticeProcessor
@@ -3089,6 +3097,8 @@ VacuumRelation
 VacuumStmt
 ValidIOData
 ValidateIndexState
+ValidatorModuleState
+ValidatorModuleResult
 ValuesScan
 ValuesScanState
 Var
@@ -3483,6 +3493,7 @@ explain_get_index_name_hook_type
 f_smgr
 fasthash_state
 fd_set
+fe_oauth_state
 fe_scram_state
 fe_scram_state_enum
 fetch_range_request
-- 
2.34.1



  [application/octet-stream] v44-0005-XXX-fix-libcurl-link-error.patch (1.1K, ../../CAOYmi+kJqzo6XsR9TEhvVfeVNQ-TyFM5LATypm9yoQVYk=4Wrw@mail.gmail.com/7-v44-0005-XXX-fix-libcurl-link-error.patch)
  download | inline diff:
From 035a3832b4035811c80533a0b6d3011b56aa48a4 Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Mon, 13 Jan 2025 12:31:59 -0800
Subject: [PATCH v44 5/6] XXX fix libcurl link error

The ftp/curl port appears to be missing a minimum version dependency on
libssh2, so the following starts showing up after upgrading to curl
8.11.1_1:

    libcurl.so.4: Undefined symbol "libssh2_session_callback_set2"

But 13.3 is EOL, so it's not clear if anyone would be interested in a
bug report, and a FreeBSD 14 Cirrus image is in progress. Hack past it
for now.
---
 .cirrus.tasks.yml | 1 +
 1 file changed, 1 insertion(+)

diff --git a/.cirrus.tasks.yml b/.cirrus.tasks.yml
index 8c518c317e7..97bb38c72c6 100644
--- a/.cirrus.tasks.yml
+++ b/.cirrus.tasks.yml
@@ -165,6 +165,7 @@ task:
     sysctl kern.corefile='/tmp/cores/%N.%P.core'
   setup_additional_packages_script: |
     pkg install -y curl
+    pkg upgrade -y libssh2 # XXX shouldn't be necessary. revisit w/ FreeBSD 14
 
   # NB: Intentionally build without -Dllvm. The freebsd image size is already
   # large enough to make VM startup slow, and even without llvm freebsd
-- 
2.34.1



  [application/octet-stream] v44-0006-DO-NOT-MERGE-Add-pytest-suite-for-OAuth.patch (212.2K, ../../CAOYmi+kJqzo6XsR9TEhvVfeVNQ-TyFM5LATypm9yoQVYk=4Wrw@mail.gmail.com/8-v44-0006-DO-NOT-MERGE-Add-pytest-suite-for-OAuth.patch)
  download | inline diff:
From 5e360725bf97f53d70ab83d4d9abf07d3f0d74da Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Fri, 4 Jun 2021 09:06:38 -0700
Subject: [PATCH v44 6/6] DO NOT MERGE: Add pytest suite for OAuth

Requires Python 3. On the first run of `make installcheck` or `meson
test` the dependencies will be installed into a local virtualenv for
you. See the README for more details.

Cirrus has been updated to build OAuth support on Debian and FreeBSD.

The suite contains a --temp-instance option, analogous to pg_regress's
option of the same name, which allows an ephemeral server to be spun up
during a test run.

TODOs:
- The --tap-stream option to pytest-tap is slightly broken during test
  failures (it suppresses error information), which impedes debugging.
- pyca/cryptography is pinned at an old version. Since we use it for
  testing and not security, this isn't a critical problem yet, but it's
  not ideal. Newer versions require a Rust compiler to build, and while
  many platforms have precompiled wheels, some (FreeBSD) do not. Even
  with the Rust pieces bypassed, compilation on FreeBSD takes a while.
- The with_oauth test skip logic should probably be integrated into the
  Makefile side as well...
- See if 32-bit tests can be enabled with a 32-bit Python.
---
 .cirrus.tasks.yml                     |    6 +-
 meson.build                           |  103 +
 src/test/meson.build                  |    1 +
 src/test/python/.gitignore            |    2 +
 src/test/python/Makefile              |   38 +
 src/test/python/README                |   66 +
 src/test/python/client/__init__.py    |    0
 src/test/python/client/conftest.py    |  196 ++
 src/test/python/client/test_client.py |  186 ++
 src/test/python/client/test_oauth.py  | 2659 +++++++++++++++++++++++++
 src/test/python/conftest.py           |   34 +
 src/test/python/meson.build           |   47 +
 src/test/python/pq3.py                |  740 +++++++
 src/test/python/pytest.ini            |    4 +
 src/test/python/requirements.txt      |   11 +
 src/test/python/server/__init__.py    |    0
 src/test/python/server/conftest.py    |  141 ++
 src/test/python/server/meson.build    |   18 +
 src/test/python/server/oauthtest.c    |  118 ++
 src/test/python/server/test_oauth.py  | 1080 ++++++++++
 src/test/python/server/test_server.py |   21 +
 src/test/python/test_internals.py     |  138 ++
 src/test/python/test_pq3.py           |  574 ++++++
 src/test/python/tls.py                |  195 ++
 src/tools/make_venv                   |   56 +
 src/tools/testwrap                    |    7 +
 26 files changed, 6440 insertions(+), 1 deletion(-)
 create mode 100644 src/test/python/.gitignore
 create mode 100644 src/test/python/Makefile
 create mode 100644 src/test/python/README
 create mode 100644 src/test/python/client/__init__.py
 create mode 100644 src/test/python/client/conftest.py
 create mode 100644 src/test/python/client/test_client.py
 create mode 100644 src/test/python/client/test_oauth.py
 create mode 100644 src/test/python/conftest.py
 create mode 100644 src/test/python/meson.build
 create mode 100644 src/test/python/pq3.py
 create mode 100644 src/test/python/pytest.ini
 create mode 100644 src/test/python/requirements.txt
 create mode 100644 src/test/python/server/__init__.py
 create mode 100644 src/test/python/server/conftest.py
 create mode 100644 src/test/python/server/meson.build
 create mode 100644 src/test/python/server/oauthtest.c
 create mode 100644 src/test/python/server/test_oauth.py
 create mode 100644 src/test/python/server/test_server.py
 create mode 100644 src/test/python/test_internals.py
 create mode 100644 src/test/python/test_pq3.py
 create mode 100644 src/test/python/tls.py
 create mode 100755 src/tools/make_venv

diff --git a/.cirrus.tasks.yml b/.cirrus.tasks.yml
index 97bb38c72c6..a6fab60bfd8 100644
--- a/.cirrus.tasks.yml
+++ b/.cirrus.tasks.yml
@@ -20,7 +20,7 @@ env:
   MTEST_ARGS: --print-errorlogs --no-rebuild -C build
   PGCTLTIMEOUT: 120 # avoids spurious failures during parallel tests
   TEMP_CONFIG: ${CIRRUS_WORKING_DIR}/src/tools/ci/pg_ci_base.conf
-  PG_TEST_EXTRA: kerberos ldap ssl libpq_encryption load_balance oauth
+  PG_TEST_EXTRA: kerberos ldap ssl libpq_encryption load_balance oauth python
 
 
 # What files to preserve in case tests fail
@@ -318,6 +318,7 @@ task:
     DEBIAN_FRONTEND=noninteractive apt-get -y install \
       libcurl4-openssl-dev \
       libcurl4-openssl-dev:i386 \
+      python3-venv \
 
   matrix:
     - name: Linux - Debian Bookworm - Autoconf
@@ -402,8 +403,11 @@ task:
       # can easily provide some here by running one of the sets of tests that
       # way. Newer versions of python insist on changing the LC_CTYPE away
       # from C, prevent that with PYTHONCOERCECLOCALE.
+      # XXX 32-bit Python tests are currently disabled, as the system's 64-bit
+      # Python modules can't link against libpq.
       test_world_32_script: |
         su postgres <<-EOF
+          export PG_TEST_EXTRA="${PG_TEST_EXTRA//python}"
           ulimit -c unlimited
           PYTHONCOERCECLOCALE=0 LANG=C meson test $MTEST_ARGS -C build-32 --num-processes ${TEST_JOBS}
         EOF
diff --git a/meson.build b/meson.build
index 3b35f1f0c9e..3ee040a90ff 100644
--- a/meson.build
+++ b/meson.build
@@ -3408,6 +3408,9 @@ else
 endif
 
 testwrap = files('src/tools/testwrap')
+make_venv = files('src/tools/make_venv')
+
+checked_working_venv = false
 
 foreach test_dir : tests
   testwrap_base = [
@@ -3576,6 +3579,106 @@ foreach test_dir : tests
         )
       endforeach
       install_suites += test_group
+    elif kind == 'pytest'
+      venv_name = test_dir['name'] + '_venv'
+      venv_path = meson.build_root() / venv_name
+
+      # The Python tests require a working venv module. This is part of the
+      # standard library, but some platforms disable it until a separate package
+      # is installed. Those same platforms don't provide an easy way to check
+      # whether the venv command will work until the first time you try it, so
+      # we decide whether or not to enable these tests on the fly.
+      if not checked_working_venv
+        cmd = run_command(python, '-m', 'venv', venv_path, check: false)
+
+        have_working_venv = (cmd.returncode() == 0)
+        if not have_working_venv
+          warning('A working Python venv module is required to run Python tests.')
+        endif
+
+        checked_working_venv = true
+      endif
+
+      if not have_working_venv
+        continue
+      endif
+
+      # Make sure the temporary installation is in PATH (necessary both for
+      # --temp-instance and for any pip modules compiling against libpq, like
+      # psycopg2).
+      env = test_env
+      env.prepend('PATH', temp_install_bindir, test_dir['bd'])
+
+      foreach name, value : t.get('env', {})
+        env.set(name, value)
+      endforeach
+
+      reqs = files(t['requirements'])
+      test('install_' + venv_name,
+        python,
+        args: [ make_venv, '--requirements', reqs, venv_path ],
+        env: env,
+        priority: setup_tests_priority - 1,  # must run after tmp_install
+        is_parallel: false,
+        suite: ['setup'],
+        timeout: 60,  # 30s is too short for the cryptography package compile
+      )
+
+      test_group = test_dir['name']
+      test_output = test_result_dir / test_group / kind
+      test_kwargs = {
+        #'protocol': 'tap',
+        'suite': test_group,
+        'timeout': 1000,
+        'depends': test_deps,
+        'env': env,
+      } + t.get('test_kwargs', {})
+
+      if fs.is_dir(venv_path / 'Scripts')
+        # Windows virtualenv layout
+        pytest = venv_path / 'Scripts' / 'py.test'
+      else
+        pytest = venv_path / 'bin' / 'py.test'
+      endif
+
+      test_command = [
+        pytest,
+        # Avoid running these tests against an existing database.
+        '--temp-instance', test_output / 'data',
+
+        # FIXME pytest-tap's stream feature accidentally suppresses errors that
+        # are critical for debugging:
+        #     https://github.com/python-tap/pytest-tap/issues/30
+        # Don't use the meson TAP protocol for now...
+        #'--tap-stream',
+      ]
+
+      foreach pyt : t['tests']
+        # Similarly to TAP, strip ./ and .py to make the names prettier
+        pyt_p = pyt
+        if pyt_p.startswith('./')
+          pyt_p = pyt_p.split('./')[1]
+        endif
+        if pyt_p.endswith('.py')
+          pyt_p = fs.stem(pyt_p)
+        endif
+
+        testwrap_pytest = testwrap_base + [
+          '--testgroup', test_group,
+          '--testname', pyt_p,
+          '--skip-without-extra', 'python',
+        ]
+
+        test(test_group / pyt_p,
+          python,
+          kwargs: test_kwargs,
+          args: testwrap_pytest + [
+            '--', test_command,
+            test_dir['sd'] / pyt,
+          ],
+        )
+      endforeach
+      install_suites += test_group
     else
       error('unknown kind @0@ of test in @1@'.format(kind, test_dir['sd']))
     endif
diff --git a/src/test/meson.build b/src/test/meson.build
index ccc31d6a86a..236057cd99e 100644
--- a/src/test/meson.build
+++ b/src/test/meson.build
@@ -8,6 +8,7 @@ subdir('postmaster')
 subdir('recovery')
 subdir('subscription')
 subdir('modules')
+subdir('python')
 
 if ssl.found()
   subdir('ssl')
diff --git a/src/test/python/.gitignore b/src/test/python/.gitignore
new file mode 100644
index 00000000000..0e8f027b2ec
--- /dev/null
+++ b/src/test/python/.gitignore
@@ -0,0 +1,2 @@
+__pycache__/
+/venv/
diff --git a/src/test/python/Makefile b/src/test/python/Makefile
new file mode 100644
index 00000000000..b0695b6287e
--- /dev/null
+++ b/src/test/python/Makefile
@@ -0,0 +1,38 @@
+#
+# Copyright 2021 VMware, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+# Only Python 3 is supported, but if it's named something different on your
+# system you can override it with the PYTHON3 variable.
+PYTHON3 := python3
+
+# All dependencies are placed into this directory. The default is .gitignored
+# for you, but you can override it if you'd like.
+VENV := ./venv
+
+override VBIN   := $(VENV)/bin
+override PIP    := $(VBIN)/pip
+override PYTEST := $(VBIN)/py.test
+override ISORT  := $(VBIN)/isort
+override BLACK  := $(VBIN)/black
+
+.PHONY: installcheck indent
+
+installcheck: $(PYTEST)
+	$(PYTEST) -v -rs
+
+indent: $(ISORT) $(BLACK)
+	$(ISORT) --profile black *.py client/*.py server/*.py
+	$(BLACK) *.py client/*.py server/*.py
+
+$(PYTEST) $(ISORT) $(BLACK) &: requirements.txt | $(PIP)
+	$(PIP) install --force-reinstall -r $<
+
+$(PIP):
+	$(PYTHON3) -m venv $(VENV)
+
+# A convenience recipe to rebuild psycopg2 against the local libpq.
+.PHONY: rebuild-psycopg2
+rebuild-psycopg2: | $(PIP)
+	$(PIP) install --force-reinstall --no-binary :all: $(shell grep psycopg2 requirements.txt)
diff --git a/src/test/python/README b/src/test/python/README
new file mode 100644
index 00000000000..acf339a5899
--- /dev/null
+++ b/src/test/python/README
@@ -0,0 +1,66 @@
+A test suite for exercising both the libpq client and the server backend at the
+protocol level, based on pytest and Construct.
+
+WARNING! This suite takes superuser-level control of the cluster under test,
+writing to the server config, creating and destroying databases, etc. It also
+spins up various ephemeral TCP services. This is not safe for production servers
+and therefore must be explicitly opted into by setting PG_TEST_EXTRA=python in
+the environment.
+
+The test suite currently assumes that the standard PG* environment variables
+point to the database under test and are sufficient to log in a superuser on
+that system. In other words, a bare `psql` needs to Just Work before the test
+suite can do its thing. For a newly built dev cluster, typically all that I need
+to do is a
+
+    export PGDATABASE=postgres
+
+but you can adjust as needed for your setup. See also 'Advanced Usage' below.
+
+## Requirements
+
+A supported version (3.6+) of Python.
+
+The first run of
+
+    make installcheck PG_TEST_EXTRA=python
+
+will install a local virtual environment and all needed dependencies. During
+development, if libpq changes incompatibly, you can issue
+
+    $ make rebuild-psycopg2
+
+to force a rebuild of the client library.
+
+## Hacking
+
+The code style is enforced by a _very_ opinionated autoformatter. Running the
+
+    make indent
+
+recipe will invoke it for you automatically. Don't fight the tool; part of the
+zen is in knowing that if the formatter makes your code ugly, there's probably a
+cleaner way to write your code.
+
+## Advanced Usage
+
+The Makefile is there for convenience, but you don't have to use it. Activate
+the virtualenv to be able to use pytest directly:
+
+    $ export PG_TEST_EXTRA=python
+    $ source venv/bin/activate
+    $ py.test -k oauth
+    ...
+    $ py.test ./server/test_server.py
+    ...
+    $ deactivate  # puts the PATH et al back the way it was before
+
+To make quick smoke tests possible, slow tests have been marked explicitly. You
+can skip them by saying e.g.
+
+    $ py.test -m 'not slow'
+
+If you'd rather not test against an existing server, you can have the suite spin
+up a temporary one using whatever pg_ctl it finds in PATH:
+
+    $ py.test --temp-instance=./tmp_check
diff --git a/src/test/python/client/__init__.py b/src/test/python/client/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/src/test/python/client/conftest.py b/src/test/python/client/conftest.py
new file mode 100644
index 00000000000..20e72a404aa
--- /dev/null
+++ b/src/test/python/client/conftest.py
@@ -0,0 +1,196 @@
+#
+# Copyright 2021 VMware, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import contextlib
+import datetime
+import functools
+import ipaddress
+import os
+import socket
+import sys
+import threading
+
+import psycopg2
+import psycopg2.extras
+import pytest
+from cryptography import x509
+from cryptography.hazmat.primitives import hashes, serialization
+from cryptography.hazmat.primitives.asymmetric import rsa
+from cryptography.x509.oid import NameOID
+
+import pq3
+
+BLOCKING_TIMEOUT = 2  # the number of seconds to wait for blocking calls
+
+
[email protected]
+def server_socket(unused_tcp_port_factory):
+    """
+    Returns a listening socket bound to an ephemeral port.
+    """
+    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+        s.bind(("127.0.0.1", unused_tcp_port_factory()))
+        s.listen(1)
+        s.settimeout(BLOCKING_TIMEOUT)
+        yield s
+
+
+class ClientHandshake(threading.Thread):
+    """
+    A thread that connects to a local Postgres server using psycopg2. Once the
+    opening handshake completes, the connection will be immediately closed.
+    """
+
+    def __init__(self, *, port, **kwargs):
+        super().__init__()
+
+        kwargs["port"] = port
+        self._kwargs = kwargs
+
+        self.exception = None
+
+    def run(self):
+        try:
+            conn = psycopg2.connect(host="127.0.0.1", **self._kwargs)
+            with contextlib.closing(conn):
+                self._pump_async(conn)
+        except Exception as e:
+            self.exception = e
+
+    def check_completed(self, timeout=BLOCKING_TIMEOUT):
+        """
+        Joins the client thread. Raises an exception if the thread could not be
+        joined, or if it threw an exception itself. (The exception will be
+        cleared, so future calls to check_completed will succeed.)
+        """
+        self.join(timeout)
+
+        if self.is_alive():
+            raise TimeoutError("client thread did not handshake within the timeout")
+        elif self.exception:
+            e = self.exception
+            self.exception = None
+            raise e
+
+    def _pump_async(self, conn):
+        """
+        Polls a psycopg2 connection until it's completed. (Synchronous
+        connections will work here too; they'll just immediately return OK.)
+        """
+        psycopg2.extras.wait_select(conn)
+
+
[email protected]
+def accept(server_socket):
+    """
+    Returns a factory function that, when called, returns a pair (sock, client)
+    where sock is a server socket that has accepted a connection from client,
+    and client is an instance of ClientHandshake. Clients will complete their
+    handshakes and cleanly disconnect.
+
+    The default connstring options may be extended or overridden by passing
+    arbitrary keyword arguments. Keep in mind that you generally should not
+    override the host or port, since they point to the local test server.
+
+    For situations where a client needs to connect more than once to complete a
+    handshake, the accept function may be called more than once. (The client
+    returned for subsequent calls will always be the same client that was
+    returned for the first call.)
+
+    Tests must either complete the handshake so that the client thread can be
+    automatically joined during teardown, or else call client.check_completed()
+    and manually handle any expected errors.
+    """
+    _, port = server_socket.getsockname()
+
+    client = None
+    default_opts = dict(
+        port=port,
+        user=pq3.pguser(),
+        sslmode="disable",
+    )
+
+    def factory(**kwargs):
+        nonlocal client
+
+        if client is None:
+            opts = dict(default_opts)
+            opts.update(kwargs)
+
+            # The server_socket is already listening, so the client thread can
+            # be safely started; it'll block on the connection until we accept.
+            client = ClientHandshake(**opts)
+            client.start()
+
+        sock, _ = server_socket.accept()
+        sock.settimeout(BLOCKING_TIMEOUT)
+        return sock, client
+
+    yield factory
+
+    if client is not None:
+        client.check_completed()
+
+
[email protected]
+def conn(accept):
+    """
+    Returns an accepted, wrapped pq3 connection to a psycopg2 client. The socket
+    will be closed when the test finishes, and the client will be checked for a
+    cleanly completed handshake.
+    """
+    sock, client = accept()
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            yield conn
+
+
[email protected](scope="session")
+def certpair(tmp_path_factory):
+    """
+    Yields a (cert, key) pair of file paths that can be used by a TLS server.
+    The certificate is issued for "localhost" and its standard IPv4/6 addresses.
+    """
+
+    tmpdir = tmp_path_factory.mktemp("certs")
+    now = datetime.datetime.now(datetime.timezone.utc)
+
+    # https://cryptography.io/en/latest/x509/tutorial/#creating-a-self-signed-certificate
+    key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
+
+    subject = issuer = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "localhost")])
+    altNames = [
+        x509.DNSName("localhost"),
+        x509.IPAddress(ipaddress.IPv4Address("127.0.0.1")),
+        x509.IPAddress(ipaddress.IPv6Address("::1")),
+    ]
+    cert = (
+        x509.CertificateBuilder()
+        .subject_name(subject)
+        .issuer_name(issuer)
+        .public_key(key.public_key())
+        .serial_number(x509.random_serial_number())
+        .not_valid_before(now)
+        .not_valid_after(now + datetime.timedelta(minutes=10))
+        .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
+        .add_extension(x509.SubjectAlternativeName(altNames), critical=False)
+    ).sign(key, hashes.SHA256())
+
+    # Writing the key with mode 0600 lets us use this from the server side, too.
+    keypath = str(tmpdir / "key.pem")
+    with open(keypath, "wb", opener=functools.partial(os.open, mode=0o600)) as f:
+        f.write(
+            key.private_bytes(
+                encoding=serialization.Encoding.PEM,
+                format=serialization.PrivateFormat.PKCS8,
+                encryption_algorithm=serialization.NoEncryption(),
+            )
+        )
+
+    certpath = str(tmpdir / "cert.pem")
+    with open(certpath, "wb") as f:
+        f.write(cert.public_bytes(serialization.Encoding.PEM))
+
+    return certpath, keypath
diff --git a/src/test/python/client/test_client.py b/src/test/python/client/test_client.py
new file mode 100644
index 00000000000..8372376ede4
--- /dev/null
+++ b/src/test/python/client/test_client.py
@@ -0,0 +1,186 @@
+#
+# Copyright 2021 VMware, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import base64
+import sys
+
+import psycopg2
+import pytest
+from cryptography.hazmat.primitives import hashes, hmac
+
+import pq3
+
+from .test_oauth import alt_patterns
+
+
+def finish_handshake(conn):
+    """
+    Sends the AuthenticationOK message and the standard opening salvo of server
+    messages, then asserts that the client immediately sends a Terminate message
+    to close the connection cleanly.
+    """
+    pq3.send(conn, pq3.types.AuthnRequest, type=pq3.authn.OK)
+    pq3.send(conn, pq3.types.ParameterStatus, name=b"client_encoding", value=b"UTF-8")
+    pq3.send(conn, pq3.types.ParameterStatus, name=b"DateStyle", value=b"ISO, MDY")
+    pq3.send(conn, pq3.types.ReadyForQuery, status=b"I")
+
+    pkt = pq3.recv1(conn)
+    assert pkt.type == pq3.types.Terminate
+
+
+def test_handshake(conn):
+    startup = pq3.recv1(conn, cls=pq3.Startup)
+    assert startup.proto == pq3.protocol(3, 0)
+
+    finish_handshake(conn)
+
+
+def test_aborted_connection(accept):
+    """
+    Make sure the client correctly reports an early close during handshakes.
+    """
+    sock, client = accept()
+    sock.close()
+
+    expected = alt_patterns(
+        "server closed the connection unexpectedly",
+        # On some platforms, ECONNABORTED gets set instead.
+        "Software caused connection abort",
+    )
+    with pytest.raises(psycopg2.OperationalError, match=expected):
+        client.check_completed()
+
+
+#
+# SCRAM-SHA-256 (see RFC 5802: https://tools.ietf.org/html/rfc5802)
+#
+
+
[email protected]
+def password():
+    """
+    Returns a password for use by both client and server.
+    """
+    # TODO: parameterize this with passwords that require SASLprep.
+    return "secret"
+
+
[email protected]
+def pwconn(accept, password):
+    """
+    Like the conn fixture, but uses a password in the connection.
+    """
+    sock, client = accept(password=password)
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            yield conn
+
+
+def sha256(data):
+    """The H(str) function from Section 2.2."""
+    digest = hashes.Hash(hashes.SHA256())
+    digest.update(data)
+    return digest.finalize()
+
+
+def hmac_256(key, data):
+    """The HMAC(key, str) function from Section 2.2."""
+    h = hmac.HMAC(key, hashes.SHA256())
+    h.update(data)
+    return h.finalize()
+
+
+def xor(a, b):
+    """The XOR operation from Section 2.2."""
+    res = bytearray(a)
+    for i, byte in enumerate(b):
+        res[i] ^= byte
+    return bytes(res)
+
+
+def h_i(data, salt, i):
+    """The Hi(str, salt, i) function from Section 2.2."""
+    assert i > 0
+
+    acc = hmac_256(data, salt + b"\x00\x00\x00\x01")
+    last = acc
+    i -= 1
+
+    while i:
+        u = hmac_256(data, last)
+        acc = xor(acc, u)
+
+        last = u
+        i -= 1
+
+    return acc
+
+
+def test_scram(pwconn, password):
+    startup = pq3.recv1(pwconn, cls=pq3.Startup)
+    assert startup.proto == pq3.protocol(3, 0)
+
+    pq3.send(
+        pwconn,
+        pq3.types.AuthnRequest,
+        type=pq3.authn.SASL,
+        body=[b"SCRAM-SHA-256", b""],
+    )
+
+    # Get the client-first-message.
+    pkt = pq3.recv1(pwconn)
+    assert pkt.type == pq3.types.PasswordMessage
+
+    initial = pq3.SASLInitialResponse.parse(pkt.payload)
+    assert initial.name == b"SCRAM-SHA-256"
+
+    c_bind, authzid, c_name, c_nonce = initial.data.split(b",")
+    assert c_bind == b"n"  # no channel bindings on a plaintext connection
+    assert authzid == b""  # we don't support authzid currently
+    assert c_name == b"n="  # libpq doesn't honor the GS2 username
+    assert c_nonce.startswith(b"r=")
+
+    # Send the server-first-message.
+    salt = b"12345"
+    iterations = 2
+
+    s_nonce = c_nonce + b"somenonce"
+    s_salt = b"s=" + base64.b64encode(salt)
+    s_iterations = b"i=%d" % iterations
+
+    msg = b",".join([s_nonce, s_salt, s_iterations])
+    pq3.send(pwconn, pq3.types.AuthnRequest, type=pq3.authn.SASLContinue, body=msg)
+
+    # Get the client-final-message.
+    pkt = pq3.recv1(pwconn)
+    assert pkt.type == pq3.types.PasswordMessage
+
+    c_bind_final, c_nonce_final, c_proof = pkt.payload.split(b",")
+    assert c_bind_final == b"c=" + base64.b64encode(c_bind + b"," + authzid + b",")
+    assert c_nonce_final == s_nonce
+
+    # Calculate what the client proof should be.
+    salted_password = h_i(password.encode("ascii"), salt, iterations)
+    client_key = hmac_256(salted_password, b"Client Key")
+    stored_key = sha256(client_key)
+
+    auth_message = b",".join(
+        [c_name, c_nonce, s_nonce, s_salt, s_iterations, c_bind_final, c_nonce_final]
+    )
+    client_signature = hmac_256(stored_key, auth_message)
+    client_proof = xor(client_key, client_signature)
+
+    expected = b"p=" + base64.b64encode(client_proof)
+    assert c_proof == expected
+
+    # Send the correct server signature.
+    server_key = hmac_256(salted_password, b"Server Key")
+    server_signature = hmac_256(server_key, auth_message)
+
+    s_verify = b"v=" + base64.b64encode(server_signature)
+    pq3.send(pwconn, pq3.types.AuthnRequest, type=pq3.authn.SASLFinal, body=s_verify)
+
+    # Done!
+    finish_handshake(pwconn)
diff --git a/src/test/python/client/test_oauth.py b/src/test/python/client/test_oauth.py
new file mode 100644
index 00000000000..ea1aeaed487
--- /dev/null
+++ b/src/test/python/client/test_oauth.py
@@ -0,0 +1,2659 @@
+#
+# Copyright 2021 VMware, Inc.
+# Portions Copyright 2023 Timescale, Inc.
+# Portions Copyright 2024 PostgreSQL Global Development Group
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import base64
+import collections
+import contextlib
+import ctypes
+import http.server
+import json
+import logging
+import os
+import platform
+import secrets
+import socket
+import ssl
+import sys
+import threading
+import time
+import traceback
+import types
+import urllib.parse
+from numbers import Number
+
+import psycopg2
+import pytest
+
+import pq3
+
+from .conftest import BLOCKING_TIMEOUT
+
+# The client tests need libpq to have been compiled with OAuth support; skip
+# them otherwise.
+pytestmark = pytest.mark.skipif(
+    os.getenv("with_libcurl") != "yes",
+    reason="OAuth client tests require --with-libcurl support",
+)
+
+if platform.system() == "Darwin":
+    libpq = ctypes.cdll.LoadLibrary("libpq.5.dylib")
+elif platform.system() == "Windows":
+    pass  # TODO
+else:
+    libpq = ctypes.cdll.LoadLibrary("libpq.so.5")
+
+
+def finish_handshake(conn):
+    """
+    Sends the AuthenticationOK message and the standard opening salvo of server
+    messages, then asserts that the client immediately sends a Terminate message
+    to close the connection cleanly.
+    """
+    pq3.send(conn, pq3.types.AuthnRequest, type=pq3.authn.OK)
+    pq3.send(conn, pq3.types.ParameterStatus, name=b"client_encoding", value=b"UTF-8")
+    pq3.send(conn, pq3.types.ParameterStatus, name=b"DateStyle", value=b"ISO, MDY")
+    pq3.send(conn, pq3.types.ReadyForQuery, status=b"I")
+
+    pkt = pq3.recv1(conn)
+    assert pkt.type == pq3.types.Terminate
+
+
+#
+# OAUTHBEARER (see RFC 7628: https://tools.ietf.org/html/rfc7628)
+#
+
+
+def start_oauth_handshake(conn):
+    """
+    Negotiates an OAUTHBEARER SASL challenge. Returns the client's initial
+    response data.
+    """
+    startup = pq3.recv1(conn, cls=pq3.Startup)
+    assert startup.proto == pq3.protocol(3, 0)
+
+    pq3.send(
+        conn, pq3.types.AuthnRequest, type=pq3.authn.SASL, body=[b"OAUTHBEARER", b""]
+    )
+
+    pkt = pq3.recv1(conn)
+    assert pkt.type == pq3.types.PasswordMessage
+
+    initial = pq3.SASLInitialResponse.parse(pkt.payload)
+    assert initial.name == b"OAUTHBEARER"
+
+    return initial.data
+
+
+def get_auth_value(initial):
+    """
+    Finds the auth value (e.g. "Bearer somedata..." in the client's initial SASL
+    response.
+    """
+    kvpairs = initial.split(b"\x01")
+    assert kvpairs[0] == b"n,,"  # no channel binding or authzid
+    assert kvpairs[2] == b""  # ends with an empty kvpair
+    assert kvpairs[3] == b""  # ...and there's nothing after it
+    assert len(kvpairs) == 4
+
+    key, value = kvpairs[1].split(b"=", 2)
+    assert key == b"auth"
+
+    return value
+
+
+def fail_oauth_handshake(conn, sasl_resp, *, errmsg="doesn't matter"):
+    """
+    Sends a failure response via the OAUTHBEARER mechanism, consumes the
+    client's dummy response, and issues a FATAL error to end the exchange.
+
+    sasl_resp is a dictionary which will be serialized as the OAUTHBEARER JSON
+    response. If provided, errmsg is used in the FATAL ErrorResponse.
+    """
+    resp = json.dumps(sasl_resp)
+    pq3.send(
+        conn,
+        pq3.types.AuthnRequest,
+        type=pq3.authn.SASLContinue,
+        body=resp.encode("utf-8"),
+    )
+
+    # Per RFC, the client is required to send a dummy ^A response.
+    pkt = pq3.recv1(conn)
+    assert pkt.type == pq3.types.PasswordMessage
+    assert pkt.payload == b"\x01"
+
+    # Now fail the SASL exchange.
+    pq3.send(
+        conn,
+        pq3.types.ErrorResponse,
+        fields=[
+            b"SFATAL",
+            b"C28000",
+            b"M" + errmsg.encode("utf-8"),
+            b"",
+        ],
+    )
+
+
+def handle_discovery_connection(sock, discovery=None, *, response=None):
+    """
+    Helper for all tests that expect an initial discovery connection from the
+    client. The provided discovery URI will be used in a standard error response
+    from the server (or response may be set, to provide a custom dictionary),
+    and the SASL exchange will be failed.
+
+    By default, the client is expected to complete the entire handshake. Set
+    finish to False if the client should immediately disconnect when it receives
+    the error response.
+    """
+    if response is None:
+        response = {"status": "invalid_token"}
+        if discovery is not None:
+            response["openid-configuration"] = discovery
+
+    with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+        # Initiate a handshake.
+        initial = start_oauth_handshake(conn)
+
+        # For discovery, the client should send an empty auth header. See RFC
+        # 7628, Sec. 4.3.
+        auth = get_auth_value(initial)
+        assert auth == b""
+
+        # The discovery handshake is doomed to fail.
+        fail_oauth_handshake(conn, response)
+
+
+class RawResponse(str):
+    """
+    Returned by registered endpoint callbacks to take full control of the
+    response. Usually, return values are converted to JSON; a RawResponse body
+    will be passed to the client as-is, allowing endpoint implementations to
+    issue invalid JSON.
+    """
+
+    pass
+
+
+class RawBytes(bytes):
+    """
+    Like RawResponse, but bypasses the UTF-8 encoding step as well, allowing
+    implementations to issue invalid encodings.
+    """
+
+    pass
+
+
+class OpenIDProvider(threading.Thread):
+    """
+    A thread that runs a mock OpenID provider server on an SSL-enabled socket.
+    """
+
+    def __init__(self, ssl_socket):
+        super().__init__()
+
+        self.exception = None
+
+        _, port = ssl_socket.getsockname()
+
+        oauth = self._OAuthState()
+        oauth.host = f"localhost:{port}"
+        oauth.issuer = f"https://localhost:{port}"
+
+        # The following endpoints are required to be advertised by providers,
+        # even though our chosen client implementation does not actually make
+        # use of them.
+        oauth.register_endpoint(
+            "authorization_endpoint", "POST", "/authorize", self._authorization_handler
+        )
+        oauth.register_endpoint("jwks_uri", "GET", "/keys", self._jwks_handler)
+
+        self.server = self._HTTPSServer(ssl_socket, self._Handler)
+        self.server.oauth = oauth
+
+    def run(self):
+        try:
+            # XXX socketserver.serve_forever() has a serious architectural
+            # issue: its select loop wakes up every `poll_interval` seconds to
+            # see if the server is shutting down. The default, 500 ms, only lets
+            # us run two tests every second. But the faster we go, the more CPU
+            # we burn unnecessarily...
+            self.server.serve_forever(poll_interval=0.01)
+        except Exception as e:
+            self.exception = e
+
+    def stop(self, timeout=BLOCKING_TIMEOUT):
+        """
+        Shuts down the server and joins its thread. Raises an exception if the
+        thread could not be joined, or if it threw an exception itself. Must
+        only be called once, after start().
+        """
+        self.server.shutdown()
+        self.join(timeout)
+
+        if self.is_alive():
+            raise TimeoutError("client thread did not handshake within the timeout")
+        elif self.exception:
+            e = self.exception
+            raise e
+
+    class _OAuthState(object):
+        def __init__(self):
+            self.endpoint_paths = {}
+            self._endpoints = {}
+
+            # Provide a standard discovery document by default; tests can
+            # override it.
+            self.register_endpoint(
+                None,
+                "GET",
+                "/.well-known/openid-configuration",
+                self._default_discovery_handler,
+            )
+
+            # Default content type unless overridden.
+            self.content_type = "application/json"
+
+        @property
+        def discovery_uri(self):
+            return f"{self.issuer}/.well-known/openid-configuration"
+
+        def register_endpoint(self, name, method, path, func):
+            if method not in self._endpoints:
+                self._endpoints[method] = {}
+
+            self._endpoints[method][path] = func
+
+            if name is not None:
+                self.endpoint_paths[name] = path
+
+        def endpoint(self, method, path):
+            if method not in self._endpoints:
+                return None
+
+            return self._endpoints[method].get(path)
+
+        def _default_discovery_handler(self, headers, params):
+            doc = {
+                "issuer": self.issuer,
+                "response_types_supported": ["token"],
+                "subject_types_supported": ["public"],
+                "id_token_signing_alg_values_supported": ["RS256"],
+                "grant_types_supported": [
+                    "authorization_code",
+                    "urn:ietf:params:oauth:grant-type:device_code",
+                ],
+            }
+
+            for name, path in self.endpoint_paths.items():
+                doc[name] = self.issuer + path
+
+            return 200, doc
+
+    class _HTTPSServer(http.server.HTTPServer):
+        def __init__(self, ssl_socket, handler_cls):
+            # Attach the SSL socket to the server. We don't bind/activate since
+            # the socket is already listening.
+            super().__init__(None, handler_cls, bind_and_activate=False)
+            self.socket = ssl_socket
+            self.server_address = self.socket.getsockname()
+
+        def shutdown_request(self, request):
+            # Cleanly unwrap the SSL socket before shutting down the connection;
+            # otherwise careful clients will complain about truncation.
+            try:
+                request = request.unwrap()
+            except (ssl.SSLEOFError, ConnectionResetError, BrokenPipeError):
+                # The client already closed (or aborted) the connection without
+                # a clean shutdown. This is seen on some platforms during tests
+                # that break the HTTP protocol. Just return and have the server
+                # close the socket.
+                return
+            except ssl.SSLError as err:
+                # FIXME OpenSSL 3.4 introduced an incompatibility with Python's
+                # TLS error handling, resulting in a bogus "[SYS] unknown error"
+                # on some platforms. Hopefully this is fixed in 2025's set of
+                # maintenance releases and this case can be removed.
+                #
+                #     https://github.com/python/cpython/issues/127257
+                #
+                if "[SYS] unknown error" in str(err):
+                    return
+                raise
+
+            super().shutdown_request(request)
+
+        def handle_error(self, request, addr):
+            self.shutdown_request(request)
+            raise
+
+    @staticmethod
+    def _jwks_handler(headers, params):
+        return 200, {"keys": []}
+
+    @staticmethod
+    def _authorization_handler(headers, params):
+        # We don't actually want this to be called during these tests -- we
+        # should be using the device authorization endpoint instead.
+        assert (
+            False
+        ), "authorization handler called instead of device authorization handler"
+
+    class _Handler(http.server.BaseHTTPRequestHandler):
+        timeout = BLOCKING_TIMEOUT
+
+        def _handle(self, *, params=None, handler=None):
+            oauth = self.server.oauth
+            assert self.headers["Host"] == oauth.host
+
+            # XXX: BaseHTTPRequestHandler collapses leading slashes in the path
+            # to work around an open redirection vuln (gh-87389) in
+            # SimpleHTTPServer. But we're not using SimpleHTTPServer, and we
+            # want to test repeating leading slashes, so that's not very
+            # helpful. Put them back.
+            orig_path = self.raw_requestline.split()[1]
+            orig_path = str(orig_path, "iso-8859-1")
+            assert orig_path.endswith(self.path)  # sanity check
+            self.path = orig_path
+
+            if handler is None:
+                handler = oauth.endpoint(self.command, self.path)
+                assert (
+                    handler is not None
+                ), f"no registered endpoint for {self.command} {self.path}"
+
+            result = handler(self.headers, params)
+
+            if len(result) == 2:
+                headers = {"Content-Type": oauth.content_type}
+                code, resp = result
+            else:
+                code, headers, resp = result
+
+            self.send_response(code)
+            for h, v in headers.items():
+                self.send_header(h, v)
+            self.end_headers()
+
+            if resp is not None:
+                if not isinstance(resp, RawBytes):
+                    if not isinstance(resp, RawResponse):
+                        resp = json.dumps(resp)
+                    resp = resp.encode("utf-8")
+                self.wfile.write(resp)
+
+            self.close_connection = True
+
+        def do_GET(self):
+            self._handle()
+
+        def _request_body(self):
+            length = self.headers["Content-Length"]
+
+            # Handle only an explicit content-length.
+            assert length is not None
+            length = int(length)
+
+            return self.rfile.read(length).decode("utf-8")
+
+        def do_POST(self):
+            assert self.headers["Content-Type"] == "application/x-www-form-urlencoded"
+
+            body = self._request_body()
+            if body:
+                # parse_qs() is understandably fairly lax when it comes to
+                # acceptable characters, but we're stricter. Spaces must be
+                # encoded, and they must use the '+' encoding rather than "%20".
+                assert " " not in body
+                assert "%20" not in body
+
+                params = urllib.parse.parse_qs(
+                    body,
+                    keep_blank_values=True,
+                    strict_parsing=True,
+                    encoding="utf-8",
+                    errors="strict",
+                )
+            else:
+                params = {}
+
+            self._handle(params=params)
+
+
[email protected](autouse=True)
+def enable_client_oauth_debugging(monkeypatch):
+    """
+    HTTP providers aren't allowed by default; enable them via envvar.
+    """
+    monkeypatch.setenv("PGOAUTHDEBUG", "UNSAFE")
+
+
[email protected](autouse=True)
+def trust_certpair_in_client(monkeypatch, certpair):
+    """
+    Set a trusted CA file for OAuth client connections.
+    """
+    monkeypatch.setenv("PGOAUTHCAFILE", certpair[0])
+
+
[email protected](scope="session")
+def ssl_socket(certpair):
+    """
+    A listening server-side socket for SSL connections, using the certpair
+    fixture.
+    """
+    sock = socket.create_server(("", 0))
+
+    # The TLS connections we're making are incredibly sensitive to delayed ACKs
+    # from the client. (Without TCP_NODELAY, test performance degrades 4-5x.)
+    sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
+
+    with contextlib.closing(sock):
+        # Wrap the server socket for TLS.
+        ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
+        ctx.load_cert_chain(*certpair)
+
+        yield ctx.wrap_socket(sock, server_side=True)
+
+
[email protected]
+def openid_provider(ssl_socket):
+    """
+    A fixture that returns the OAuth state of a running OpenID provider server. The
+    server will be stopped when the fixture is torn down.
+    """
+    thread = OpenIDProvider(ssl_socket)
+    thread.start()
+
+    try:
+        yield thread.server.oauth
+    finally:
+        thread.stop()
+
+
+#
+# PQAuthDataHook implementation, matching libpq.h
+#
+
+
+PQAUTHDATA_PROMPT_OAUTH_DEVICE = 0
+PQAUTHDATA_OAUTH_BEARER_TOKEN = 1
+
+PGRES_POLLING_FAILED = 0
+PGRES_POLLING_READING = 1
+PGRES_POLLING_WRITING = 2
+PGRES_POLLING_OK = 3
+
+
+class PGPromptOAuthDevice(ctypes.Structure):
+    _fields_ = [
+        ("verification_uri", ctypes.c_char_p),
+        ("user_code", ctypes.c_char_p),
+    ]
+
+
+class PGOAuthBearerRequest(ctypes.Structure):
+    pass
+
+
+PGOAuthBearerRequest._fields_ = [
+    ("openid_configuration", ctypes.c_char_p),
+    ("scope", ctypes.c_char_p),
+    (
+        "async_",
+        ctypes.CFUNCTYPE(
+            ctypes.c_int,
+            ctypes.c_void_p,
+            ctypes.POINTER(PGOAuthBearerRequest),
+            ctypes.POINTER(ctypes.c_int),
+        ),
+    ),
+    (
+        "cleanup",
+        ctypes.CFUNCTYPE(None, ctypes.c_void_p, ctypes.POINTER(PGOAuthBearerRequest)),
+    ),
+    ("token", ctypes.c_char_p),
+    ("user", ctypes.c_void_p),
+]
+
+
[email protected]
+def auth_data_cb():
+    """
+    Tracks calls to the libpq authdata hook. The yielded object contains a calls
+    member that records the data sent to the hook. If a test needs to perform
+    custom actions during a call, it can set the yielded object's impl callback;
+    beware that the callback takes place on a different thread.
+
+    This is done differently from the other callback implementations on purpose.
+    For the others, we can declare test-specific callbacks and have them perform
+    direct assertions on the data they receive. But that won't work for a C
+    callback, because there's no way for us to bubble up the assertion through
+    libpq. Instead, this mock-style approach is taken, where we just record the
+    calls and let the test examine them later.
+    """
+
+    class _Call:
+        pass
+
+    class _cb(object):
+        def __init__(self):
+            self.calls = []
+
+    cb = _cb()
+    cb.impl = None
+
+    # The callback will occur on a different thread, so protect the cb object.
+    cb_lock = threading.Lock()
+
+    @ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_byte, ctypes.c_void_p, ctypes.c_void_p)
+    def auth_data_cb(typ, pgconn, data):
+        handle_by_default = 0  # does an implementation have to be provided?
+
+        if typ == PQAUTHDATA_PROMPT_OAUTH_DEVICE:
+            cls = PGPromptOAuthDevice
+            handle_by_default = 1
+        elif typ == PQAUTHDATA_OAUTH_BEARER_TOKEN:
+            cls = PGOAuthBearerRequest
+        else:
+            return 0
+
+        call = _Call()
+        call.type = typ
+
+        # The lifetime of the underlying data being pointed to doesn't
+        # necessarily match the lifetime of the Python object, so we can't
+        # reference a Structure's fields after returning. Explicitly copy the
+        # contents over, field by field.
+        data = ctypes.cast(data, ctypes.POINTER(cls))
+        for name, _ in cls._fields_:
+            setattr(call, name, getattr(data.contents, name))
+
+        with cb_lock:
+            cb.calls.append(call)
+
+        if cb.impl:
+            # Pass control back to the test.
+            try:
+                return cb.impl(typ, pgconn, data.contents)
+            except Exception:
+                # This can't escape into the C stack, but we can fail the flow
+                # and hope the traceback gives us enough detail.
+                logging.error(
+                    "Exception during authdata hook callback:\n"
+                    + traceback.format_exc()
+                )
+                return -1
+
+        return handle_by_default
+
+    libpq.PQsetAuthDataHook(auth_data_cb)
+    try:
+        yield cb
+    finally:
+        # The callback is about to go out of scope, so make sure libpq is
+        # disconnected from it. (We wouldn't want to accidentally influence
+        # later tests anyway.)
+        libpq.PQsetAuthDataHook(None)
+
+
[email protected](
+    "success, abnormal_failure",
+    [
+        pytest.param(True, False, id="success"),
+        pytest.param(False, False, id="normal failure"),
+        pytest.param(False, True, id="abnormal failure"),
+    ],
+)
[email protected]("secret", [None, "", "hunter2"])
[email protected]("scope", [None, "", "openid email"])
[email protected]("retries", [0, 1])
[email protected](
+    "content_type",
+    [
+        pytest.param("application/json", id="standard"),
+        pytest.param("application/json;charset=utf-8", id="charset"),
+        pytest.param("application/json \t;\t charset=utf-8", id="charset (whitespace)"),
+    ],
+)
[email protected]("uri_spelling", ["verification_url", "verification_uri"])
[email protected](
+    "asynchronous",
+    [
+        pytest.param(False, id="synchronous"),
+        pytest.param(True, id="asynchronous"),
+    ],
+)
+def test_oauth_with_explicit_discovery_uri(
+    accept,
+    openid_provider,
+    asynchronous,
+    uri_spelling,
+    content_type,
+    retries,
+    scope,
+    secret,
+    auth_data_cb,
+    success,
+    abnormal_failure,
+):
+    client_id = secrets.token_hex()
+    openid_provider.content_type = content_type
+
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id=client_id,
+        oauth_client_secret=secret,
+        oauth_scope=scope,
+        async_=asynchronous,
+    )
+
+    device_code = secrets.token_hex()
+    user_code = f"{secrets.token_hex(2)}-{secrets.token_hex(2)}"
+    verification_url = "https://example.com/device"
+
+    access_token = secrets.token_urlsafe()
+
+    def check_client_authn(headers, params):
+        if secret is None:
+            assert "Authorization" not in headers
+            assert params["client_id"] == [client_id]
+            return
+
+        # Require the client to use Basic authn; request-body credentials are
+        # NOT RECOMMENDED (RFC 6749, Sec. 2.3.1).
+        assert "Authorization" in headers
+        assert "client_id" not in params
+
+        method, creds = headers["Authorization"].split()
+        assert method == "Basic"
+
+        expected = f"{client_id}:{secret}"
+        assert base64.b64decode(creds) == expected.encode("ascii")
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        check_client_authn(headers, params)
+
+        if scope:
+            assert params["scope"] == [scope]
+        else:
+            assert "scope" not in params
+
+        resp = {
+            "device_code": device_code,
+            "user_code": user_code,
+            "interval": 0,
+            uri_spelling: verification_url,
+            "expires_in": 5,
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    attempts = 0
+    retry_lock = threading.Lock()
+
+    def token_endpoint(headers, params):
+        check_client_authn(headers, params)
+
+        assert params["grant_type"] == ["urn:ietf:params:oauth:grant-type:device_code"]
+        assert params["device_code"] == [device_code]
+
+        now = time.monotonic()
+
+        with retry_lock:
+            nonlocal attempts
+
+            # If the test wants to force the client to retry, return an
+            # authorization_pending response and decrement the retry count.
+            if attempts < retries:
+                attempts += 1
+                return 400, {"error": "authorization_pending"}
+
+        # Successfully finish the request by sending the access bearer token.
+        resp = {
+            "access_token": access_token,
+            "token_type": "bearer",
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    # First connection is a discovery request, which should result in the above
+    # endpoints being called.
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    # Client should reconnect.
+    sock, _ = accept()
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            initial = start_oauth_handshake(conn)
+
+            # Validate and accept the token.
+            auth = get_auth_value(initial)
+            assert auth == f"Bearer {access_token}".encode("ascii")
+
+            if success:
+                finish_handshake(conn)
+
+            elif abnormal_failure:
+                # Send an empty error response, which should result in a
+                # mechanism-level failure in the client. This test ensures that
+                # the client doesn't try a third connection for this case.
+                expected_error = "server sent error response without a status"
+                fail_oauth_handshake(conn, {})
+
+            else:
+                # Simulate token validation failure.
+                resp = {
+                    "status": "invalid_token",
+                    "openid-configuration": openid_provider.discovery_uri,
+                }
+                expected_error = "test token validation failure"
+                fail_oauth_handshake(conn, resp, errmsg=expected_error)
+
+    if retries:
+        # Finally, make sure that the client prompted the user once with the
+        # expected authorization URL and user code.
+        assert len(auth_data_cb.calls) == 2
+
+        # First call should have been for a custom flow, which we ignored.
+        assert auth_data_cb.calls[0].type == PQAUTHDATA_OAUTH_BEARER_TOKEN
+
+        # Second call is for our user prompt.
+        call = auth_data_cb.calls[1]
+        assert call.type == PQAUTHDATA_PROMPT_OAUTH_DEVICE
+        assert call.verification_uri.decode() == verification_url
+        assert call.user_code.decode() == user_code
+
+    if not success:
+        # The client should not try to connect again.
+        with pytest.raises(psycopg2.OperationalError, match=expected_error):
+            client.check_completed()
+
+
[email protected](
+    "server_discovery",
+    [
+        pytest.param(True, id="server discovery"),
+        pytest.param(False, id="direct discovery"),
+    ],
+)
[email protected](
+    "issuer, path",
+    [
+        pytest.param(
+            "{issuer}",
+            "/.well-known/oauth-authorization-server",
+            id="oauth",
+        ),
+        pytest.param(
+            "{issuer}/alt",
+            "/.well-known/oauth-authorization-server/alt",
+            id="oauth with path, IETF style",
+        ),
+        pytest.param(
+            "{issuer}/alt",
+            "/alt/.well-known/oauth-authorization-server",
+            id="oauth with path, broken OIDC style",
+        ),
+        pytest.param(
+            "{issuer}/alt",
+            "/alt/.well-known/openid-configuration",
+            id="openid with path, OIDC style",
+        ),
+        pytest.param(
+            "{issuer}/alt",
+            "/.well-known/openid-configuration/alt",
+            id="openid with path, IETF style",
+        ),
+        pytest.param(
+            "{issuer}/",
+            "//.well-known/openid-configuration",
+            id="empty path segment, OIDC style",
+        ),
+        pytest.param(
+            "{issuer}/",
+            "/.well-known/openid-configuration/",
+            id="empty path segment, IETF style",
+        ),
+    ],
+)
+def test_alternate_well_known_paths(
+    accept, openid_provider, issuer, path, server_discovery
+):
+    issuer = issuer.format(issuer=openid_provider.issuer)
+    discovery_uri = openid_provider.issuer + path
+
+    client_id = secrets.token_hex()
+    access_token = secrets.token_urlsafe()
+
+    def discovery_handler(*args):
+        """
+        Pass-through implementation of the discovery handler. Modifies the
+        default document to contain this test's issuer identifier.
+        """
+        code, doc = openid_provider._default_discovery_handler(*args)
+        doc["issuer"] = issuer
+        return code, doc
+
+    openid_provider.register_endpoint(None, "GET", path, discovery_handler)
+
+    def authorization_endpoint(headers, params):
+        resp = {
+            "device_code": "12345",
+            "user_code": "ABCDE",
+            "interval": 0,
+            "verification_url": "https://example.com/device",
+            "expires_in": 5,
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    def token_endpoint(headers, params):
+        # Successfully finish the request by sending the access bearer token.
+        resp = {
+            "access_token": access_token,
+            "token_type": "bearer",
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    kwargs = dict(oauth_client_id=client_id)
+    if server_discovery:
+        kwargs.update(oauth_issuer=issuer)
+    else:
+        kwargs.update(oauth_issuer=discovery_uri)
+
+    sock, client = accept(**kwargs)
+
+    with sock:
+        handle_discovery_connection(sock, discovery_uri)
+
+    # Expect the client to connect again.
+    sock, _ = accept()
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            initial = start_oauth_handshake(conn)
+
+            # Validate the token.
+            auth = get_auth_value(initial)
+            assert auth == f"Bearer {access_token}".encode("ascii")
+
+            finish_handshake(conn)
+
+
[email protected](
+    "server_discovery",
+    [
+        pytest.param(True, id="server discovery"),
+        pytest.param(False, id="direct discovery"),
+    ],
+)
[email protected](
+    "issuer, path, expected_error",
+    [
+        pytest.param(
+            "{issuer}",
+            "/.well-known/oauth-authorization-server/",
+            None,
+            id="extra empty segment (no path)",
+        ),
+        pytest.param(
+            "{issuer}/path",
+            "/.well-known/oauth-authorization-server/path/",
+            None,
+            id="extra empty segment (with path)",
+        ),
+        pytest.param(
+            "{issuer}",
+            "?/.well-known/oauth-authorization-server",
+            r'OAuth discovery URI ".*" must not contain query or fragment components',
+            id="query",
+        ),
+        pytest.param(
+            "{issuer}",
+            "#/.well-known/oauth-authorization-server",
+            r'OAuth discovery URI ".*" must not contain query or fragment components',
+            id="fragment",
+        ),
+        pytest.param(
+            "{issuer}/sub/path",
+            "/sub/.well-known/oauth-authorization-server/path",
+            r'OAuth discovery URI ".*" uses an invalid format',
+            id="sandwiched prefix",
+        ),
+        pytest.param(
+            "{issuer}/path",
+            "/path/openid-configuration",
+            r'OAuth discovery URI ".*" is not a .well-known URI',
+            id="not .well-known",
+        ),
+        pytest.param(
+            "{issuer}",
+            "https://.well-known/oauth-authorization-server",
+            r'OAuth discovery URI ".*" is not a .well-known URI',
+            id=".well-known prefix buried in the authority",
+        ),
+        pytest.param(
+            "{issuer}",
+            "/.well-known/oauth-protected-resource",
+            r'OAuth discovery URI ".*" uses an unsupported .well-known suffix',
+            id="unknown well-known suffix",
+        ),
+        pytest.param(
+            "{issuer}/path",
+            "/path/.well-known/openid-configuration-2",
+            r'OAuth discovery URI ".*" uses an unsupported .well-known suffix',
+            id="unknown well-known suffix, OIDC style",
+        ),
+        pytest.param(
+            "{issuer}/path",
+            "/.well-known/oauth-authorization-server-2/path",
+            r'OAuth discovery URI ".*" uses an unsupported .well-known suffix',
+            id="unknown well-known suffix, IETF style",
+        ),
+        pytest.param(
+            "{issuer}",
+            "file:///.well-known/oauth-authorization-server",
+            r'OAuth discovery URI ".*" must use HTTPS',
+            id="unsupported scheme",
+        ),
+    ],
+)
+def test_bad_well_known_paths(
+    accept, openid_provider, issuer, path, expected_error, server_discovery
+):
+    if not server_discovery and "/.well-known/" not in path:
+        # An oauth_issuer without a /.well-known/ path segment is just a normal
+        # issuer identifier, so this isn't an interesting test.
+        pytest.skip("not interesting: direct discovery requires .well-known")
+
+    issuer = issuer.format(issuer=openid_provider.issuer)
+    discovery_uri = urllib.parse.urljoin(openid_provider.issuer, path)
+
+    client_id = secrets.token_hex()
+
+    def discovery_handler(*args):
+        """
+        Pass-through implementation of the discovery handler. Modifies the
+        default document to contain this test's issuer identifier.
+        """
+        code, doc = openid_provider._default_discovery_handler(*args)
+        doc["issuer"] = issuer
+        return code, doc
+
+    openid_provider.register_endpoint(None, "GET", path, discovery_handler)
+
+    def fail(*args):
+        """
+        No other endpoints should be contacted; fail if the client tries.
+        """
+        assert False, "endpoint unexpectedly called"
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", fail
+    )
+    openid_provider.register_endpoint("token_endpoint", "POST", "/token", fail)
+
+    kwargs = dict(oauth_client_id=client_id)
+    if server_discovery:
+        kwargs.update(oauth_issuer=issuer)
+    else:
+        kwargs.update(oauth_issuer=discovery_uri)
+
+    sock, client = accept(**kwargs)
+    with sock:
+        if expected_error and not server_discovery:
+            # If the client already knows the URL, it should disconnect as soon
+            # as it realizes it's not valid.
+            expect_disconnected_handshake(sock)
+        else:
+            # Otherwise, it should complete the connection.
+            handle_discovery_connection(sock, discovery_uri)
+
+    # The client should not reconnect.
+
+    if expected_error is None:
+        if server_discovery:
+            expected_error = rf"server's discovery document at {discovery_uri} \(issuer \".*\"\) is incompatible with oauth_issuer \({issuer}\)"
+        else:
+            expected_error = rf"the issuer identifier \({issuer}\) does not match oauth_issuer \(.*\)"
+
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
+def expect_disconnected_handshake(sock):
+    """
+    Helper for any tests that expect the client to disconnect immediately after
+    being sent the OAUTHBEARER SASL method. Generally speaking, this requires
+    the client to have an oauth_issuer set so that it doesn't try to go through
+    discovery.
+    """
+    with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+        # Initiate a handshake.
+        startup = pq3.recv1(conn, cls=pq3.Startup)
+        assert startup.proto == pq3.protocol(3, 0)
+
+        pq3.send(
+            conn,
+            pq3.types.AuthnRequest,
+            type=pq3.authn.SASL,
+            body=[b"OAUTHBEARER", b""],
+        )
+
+        # The client should disconnect at this point.
+        assert not conn.read(1), "client sent unexpected data"
+
+
[email protected](
+    "missing",
+    [
+        pytest.param(["oauth_issuer"], id="missing oauth_issuer"),
+        pytest.param(["oauth_client_id"], id="missing oauth_client_id"),
+        pytest.param(["oauth_client_id", "oauth_issuer"], id="missing both"),
+    ],
+)
+def test_oauth_requires_issuer_and_client_id(accept, openid_provider, missing):
+    params = dict(
+        oauth_issuer=openid_provider.issuer,
+        oauth_client_id="some-id",
+    )
+
+    # Remove required parameters. This should cause a client error after the
+    # server asks for OAUTHBEARER and the client tries to contact the issuer.
+    for k in missing:
+        del params[k]
+
+    sock, client = accept(**params)
+    with sock:
+        expect_disconnected_handshake(sock)
+
+    expected_error = "oauth_issuer and oauth_client_id are not both set"
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
+# See https://datatracker.ietf.org/doc/html/rfc6749#appendix-A for character
+# class definitions.
+all_vschars = "".join([chr(c) for c in range(0x20, 0x7F)])
+all_nqchars = "".join([chr(c) for c in range(0x21, 0x7F) if c not in (0x22, 0x5C)])
+
+
[email protected]("client_id", ["", ":", " + ", r'+=&"\/~', all_vschars])
[email protected]("secret", [None, "", ":", " + ", r'+=&"\/~', all_vschars])
[email protected]("device_code", ["", " + ", r'+=&"\/~', all_vschars])
[email protected]("scope", ["&", r"+=&/", all_nqchars])
+def test_url_encoding(accept, openid_provider, client_id, secret, device_code, scope):
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id=client_id,
+        oauth_client_secret=secret,
+        oauth_scope=scope,
+    )
+
+    user_code = f"{secrets.token_hex(2)}-{secrets.token_hex(2)}"
+    verification_url = "https://example.com/device"
+
+    access_token = secrets.token_urlsafe()
+
+    def check_client_authn(headers, params):
+        if secret is None:
+            assert "Authorization" not in headers
+            assert params["client_id"] == [client_id]
+            return
+
+        # Require the client to use Basic authn; request-body credentials are
+        # NOT RECOMMENDED (RFC 6749, Sec. 2.3.1).
+        assert "Authorization" in headers
+        assert "client_id" not in params
+
+        method, creds = headers["Authorization"].split()
+        assert method == "Basic"
+
+        decoded = base64.b64decode(creds).decode("utf-8")
+        username, password = decoded.split(":", 1)
+
+        expected_username = urllib.parse.quote_plus(client_id)
+        expected_password = urllib.parse.quote_plus(secret)
+
+        assert [username, password] == [expected_username, expected_password]
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        check_client_authn(headers, params)
+
+        if scope:
+            assert params["scope"] == [scope]
+        else:
+            assert "scope" not in params
+
+        resp = {
+            "device_code": device_code,
+            "user_code": user_code,
+            "interval": 0,
+            "verification_url": verification_url,
+            "expires_in": 5,
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    def token_endpoint(headers, params):
+        check_client_authn(headers, params)
+
+        assert params["grant_type"] == ["urn:ietf:params:oauth:grant-type:device_code"]
+        assert params["device_code"] == [device_code]
+
+        # Successfully finish the request by sending the access bearer token.
+        resp = {
+            "access_token": access_token,
+            "token_type": "bearer",
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    # First connection is a discovery request, which should result in the above
+    # endpoints being called.
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    # Second connection sends the token.
+    sock, _ = accept()
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            initial = start_oauth_handshake(conn)
+
+            # Validate and accept the token.
+            auth = get_auth_value(initial)
+            assert auth == f"Bearer {access_token}".encode("ascii")
+
+            finish_handshake(conn)
+
+
[email protected]
[email protected]("error_code", ["authorization_pending", "slow_down"])
[email protected]("retries", [1, 2])
[email protected]("omit_interval", [True, False])
+def test_oauth_retry_interval(
+    accept, openid_provider, omit_interval, retries, error_code
+):
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id="some-id",
+    )
+
+    expected_retry_interval = 5 if omit_interval else 1
+    access_token = secrets.token_urlsafe()
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        resp = {
+            "device_code": "my-device-code",
+            "user_code": "my-user-code",
+            "verification_uri": "https://example.com",
+            "expires_in": 5,
+        }
+
+        if not omit_interval:
+            resp["interval"] = expected_retry_interval
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    attempts = 0
+    last_retry = None
+    retry_lock = threading.Lock()
+    token_sent = threading.Event()
+
+    def token_endpoint(headers, params):
+        now = time.monotonic()
+
+        with retry_lock:
+            nonlocal attempts, last_retry, expected_retry_interval
+
+            # Make sure the retry interval is being respected by the client.
+            if last_retry is not None:
+                interval = now - last_retry
+                assert interval >= expected_retry_interval
+
+            last_retry = now
+
+            # If the test wants to force the client to retry, return the desired
+            # error response and decrement the retry count.
+            if attempts < retries:
+                attempts += 1
+
+                # A slow_down code requires the client to additionally increase
+                # its interval by five seconds.
+                if error_code == "slow_down":
+                    expected_retry_interval += 5
+
+                return 400, {"error": error_code}
+
+        # Successfully finish the request by sending the access bearer token,
+        # and signal the main thread to continue.
+        resp = {
+            "access_token": access_token,
+            "token_type": "bearer",
+        }
+        token_sent.set()
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    # First connection is a discovery request, which should result in the above
+    # endpoints being called.
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    # At this point the client is talking to the authorization server. Wait for
+    # that to succeed so we don't run into the accept() timeout.
+    token_sent.wait()
+
+    # Client should reconnect and send the token.
+    sock, _ = accept()
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            initial = start_oauth_handshake(conn)
+
+            # Validate and accept the token.
+            auth = get_auth_value(initial)
+            assert auth == f"Bearer {access_token}".encode("ascii")
+
+            finish_handshake(conn)
+
+
[email protected]
+def self_pipe():
+    """
+    Yields a pipe fd pair.
+    """
+
+    class _Pipe:
+        pass
+
+    p = _Pipe()
+    p.readfd, p.writefd = os.pipe()
+
+    try:
+        yield p
+    finally:
+        os.close(p.readfd)
+        os.close(p.writefd)
+
+
[email protected]("scope", [None, "", "openid email"])
[email protected](
+    "retries",
+    [
+        -1,  # no async callback
+        0,  # async callback immediately returns token
+        1,  # async callback waits on altsock once
+        2,  # async callback waits on altsock twice
+    ],
+)
[email protected](
+    "asynchronous",
+    [
+        pytest.param(False, id="synchronous"),
+        pytest.param(True, id="asynchronous"),
+    ],
+)
+def test_user_defined_flow(
+    accept, auth_data_cb, self_pipe, scope, retries, asynchronous
+):
+    issuer = "http://localhost"
+    discovery_uri = issuer + "/.well-known/openid-configuration"
+    access_token = secrets.token_urlsafe()
+
+    sock, client = accept(
+        oauth_issuer=discovery_uri,
+        oauth_client_id="some-id",
+        oauth_scope=scope,
+        async_=asynchronous,
+    )
+
+    # Track callbacks.
+    attempts = 0
+    wakeup_called = False
+    cleanup_calls = 0
+    lock = threading.Lock()
+
+    def wakeup():
+        """Writes a byte to the wakeup pipe."""
+        nonlocal wakeup_called
+        with lock:
+            wakeup_called = True
+            os.write(self_pipe.writefd, b"\0")
+
+    def get_token(pgconn, request, p_altsock):
+        """
+        Async token callback. While attempts < retries, libpq will be instructed
+        to wait on the self_pipe. When attempts == retries, the token will be
+        set.
+
+        Note that assertions and exceptions raised here are allowed but not very
+        helpful, since they can't bubble through the libpq stack to be collected
+        by the test suite. Try not to rely too heavily on them.
+        """
+        # Make sure libpq passed our user data through.
+        assert request.user == 42
+
+        with lock:
+            nonlocal attempts, wakeup_called
+
+            if attempts:
+                # If we've already started the timer, we shouldn't get a
+                # call back before it trips.
+                assert wakeup_called, "authdata hook was called before the timer"
+
+                # Drain the wakeup byte.
+                os.read(self_pipe.readfd, 1)
+
+            if attempts < retries:
+                attempts += 1
+
+                # Wake up the client in a little bit of time.
+                wakeup_called = False
+                threading.Timer(0.1, wakeup).start()
+
+                # Tell libpq to wait on the other end of the wakeup pipe.
+                p_altsock[0] = self_pipe.readfd
+                return PGRES_POLLING_READING
+
+        # Done!
+        request.token = access_token.encode()
+        return PGRES_POLLING_OK
+
+    @ctypes.CFUNCTYPE(
+        ctypes.c_int,
+        ctypes.c_void_p,
+        ctypes.POINTER(PGOAuthBearerRequest),
+        ctypes.POINTER(ctypes.c_int),
+    )
+    def get_token_wrapper(pgconn, p_request, p_altsock):
+        """
+        Translation layer between C and Python for the async callback.
+        Assertions and exceptions will be swallowed at the boundary, so make
+        sure they don't escape here.
+        """
+        try:
+            return get_token(pgconn, p_request.contents, p_altsock)
+        except Exception:
+            logging.error("Exception during async callback:\n" + traceback.format_exc())
+            return PGRES_POLLING_FAILED
+
+    @ctypes.CFUNCTYPE(None, ctypes.c_void_p, ctypes.POINTER(PGOAuthBearerRequest))
+    def cleanup(pgconn, p_request):
+        """
+        Should be called exactly once per connection.
+        """
+        nonlocal cleanup_calls
+        with lock:
+            cleanup_calls += 1
+
+    def bearer_hook(typ, pgconn, request):
+        """
+        Implementation of the PQAuthDataHook, which either sets up an async
+        callback or returns the token directly, depending on the value of
+        retries.
+
+        As above, try not to rely too much on assertions/exceptions here.
+        """
+        assert typ == PQAUTHDATA_OAUTH_BEARER_TOKEN
+        request.cleanup = cleanup
+
+        if retries < 0:
+            # Special case: return a token immediately without a callback.
+            request.token = access_token.encode()
+            return 1
+
+        # Tell libpq to call us back.
+        request.async_ = get_token_wrapper
+        request.user = ctypes.c_void_p(42)  # will be checked in the callback
+        return 1
+
+    auth_data_cb.impl = bearer_hook
+
+    # Now drive the server side.
+    if retries >= 0:
+        # First connection is a discovery request, which should result in the
+        # hook being invoked.
+        with sock:
+            handle_discovery_connection(sock, discovery_uri)
+
+        # Client should reconnect to send the token.
+        sock, _ = accept()
+
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            # Initiate a handshake, which should result in our custom callback
+            # being invoked to fetch the token.
+            initial = start_oauth_handshake(conn)
+
+            # Validate and accept the token.
+            auth = get_auth_value(initial)
+            assert auth == f"Bearer {access_token}".encode("ascii")
+
+            finish_handshake(conn)
+
+    # Check the data provided to the hook.
+    assert len(auth_data_cb.calls) == 1
+
+    call = auth_data_cb.calls[0]
+    assert call.type == PQAUTHDATA_OAUTH_BEARER_TOKEN
+    assert call.openid_configuration.decode() == discovery_uri
+    assert call.scope == (None if scope is None else scope.encode())
+
+    # Make sure we clean up after ourselves when the connection is finished.
+    client.check_completed()
+    assert cleanup_calls == 1
+
+
+def alt_patterns(*patterns):
+    """
+    Just combines multiple alternative regexes into one. It's not very efficient
+    but IMO it's easier to read and maintain.
+    """
+    pat = ""
+
+    for p in patterns:
+        if pat:
+            pat += "|"
+        pat += f"({p})"
+
+    return pat
+
+
[email protected](
+    "failure_mode, error_pattern",
+    [
+        pytest.param(
+            (
+                401,
+                {
+                    "error": "invalid_client",
+                    "error_description": "client authentication failed",
+                },
+            ),
+            r"failed to obtain device authorization: client authentication failed \(invalid_client\)",
+            id="authentication failure with description",
+        ),
+        pytest.param(
+            (400, {"error": "invalid_request"}),
+            r"failed to obtain device authorization: \(invalid_request\)",
+            id="invalid request without description",
+        ),
+        pytest.param(
+            (400, {"error": "invalid_request", "padding": "x" * 1024 * 1024}),
+            r"failed to obtain device authorization: response is too large",
+            id="gigantic authz response",
+        ),
+        pytest.param(
+            (400, {}),
+            r'failed to parse token error response: field "error" is missing',
+            id="broken error response",
+        ),
+        pytest.param(
+            (401, {"error": "invalid_client"}),
+            r"failed to obtain device authorization: provider requires client authentication, and no oauth_client_secret is set \(invalid_client\)",
+            id="failed authentication without description",
+        ),
+        pytest.param(
+            (200, RawResponse(r'{ "interval": 3.5.8 }')),
+            r"failed to parse device authorization: Token .* is invalid",
+            id="non-numeric interval",
+        ),
+        pytest.param(
+            (200, RawResponse(r'{ "interval": 08 }')),
+            r"failed to parse device authorization: Token .* is invalid",
+            id="invalid numeric interval",
+        ),
+    ],
+)
+def test_oauth_device_authorization_failures(
+    accept, openid_provider, failure_mode, error_pattern
+):
+    client_id = secrets.token_hex()
+
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id=client_id,
+    )
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        return failure_mode
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    def token_endpoint(headers, params):
+        assert False, "token endpoint was invoked unexpectedly"
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    # Now make sure the client correctly failed.
+    with pytest.raises(psycopg2.OperationalError, match=error_pattern):
+        client.check_completed()
+
+
+Missing = object()  # sentinel for test_oauth_device_authorization_bad_json()
+
+
[email protected](
+    "bad_value",
+    [
+        pytest.param({"device_code": 3}, id="object"),
+        pytest.param([1, 2, 3], id="array"),
+        pytest.param("some string", id="string"),
+        pytest.param(4, id="numeric"),
+        pytest.param(False, id="boolean"),
+        pytest.param(None, id="null"),
+        pytest.param(Missing, id="missing"),
+    ],
+)
[email protected](
+    "field_name,ok_type,required",
+    [
+        ("device_code", str, True),
+        ("user_code", str, True),
+        ("verification_uri", str, True),
+        ("interval", int, False),
+    ],
+)
+def test_oauth_device_authorization_bad_json_schema(
+    accept, openid_provider, field_name, ok_type, required, bad_value
+):
+    # To make the test matrix easy, just skip the tests that aren't actually
+    # interesting (field of the correct type, missing optional field).
+    if bad_value is Missing and not required:
+        pytest.skip("not interesting: optional field")
+    elif type(bad_value) == ok_type:  # not isinstance(), because bool is an int
+        pytest.skip("not interesting: correct type")
+
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id=secrets.token_hex(),
+    )
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        # Begin with an acceptable base response...
+        resp = {
+            "device_code": "my-device-code",
+            "user_code": "my-user-code",
+            "interval": 0,
+            "verification_uri": "https://example.com",
+            "expires_in": 5,
+        }
+
+        # ...then tweak it so the client fails.
+        if bad_value is Missing:
+            del resp[field_name]
+        else:
+            resp[field_name] = bad_value
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    def token_endpoint(headers, params):
+        assert False, "token endpoint was invoked unexpectedly"
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    # Now make sure the client correctly failed.
+    if bad_value is Missing:
+        error_pattern = f'field "{field_name}" is missing'
+    elif ok_type == str:
+        error_pattern = f'field "{field_name}" must be a string'
+    elif ok_type == int:
+        error_pattern = f'field "{field_name}" must be a number'
+    else:
+        assert False, "update error_pattern for new failure mode"
+
+    with pytest.raises(psycopg2.OperationalError, match=error_pattern):
+        client.check_completed()
+
+
[email protected](
+    "failure_mode, error_pattern",
+    [
+        pytest.param(
+            (
+                400,
+                {
+                    "error": "expired_token",
+                    "error_description": "the device code has expired",
+                },
+            ),
+            r"failed to obtain access token: the device code has expired \(expired_token\)",
+            id="expired token with description",
+        ),
+        pytest.param(
+            (400, {"error": "access_denied"}),
+            r"failed to obtain access token: \(access_denied\)",
+            id="access denied without description",
+        ),
+        pytest.param(
+            (400, {"error": "access_denied", "padding": "x" * 1024 * 1024}),
+            r"failed to obtain access token: response is too large",
+            id="gigantic token response",
+        ),
+        pytest.param(
+            (400, {}),
+            r'failed to parse token error response: field "error" is missing',
+            id="empty error response",
+        ),
+        pytest.param(
+            (401, {"error": "invalid_client"}),
+            r"failed to obtain access token: provider requires client authentication, and no oauth_client_secret is set \(invalid_client\)",
+            id="authentication failure without description",
+        ),
+        pytest.param(
+            (200, {}, {}),
+            r"failed to parse access token response: no content type was provided",
+            id="missing content type",
+        ),
+        pytest.param(
+            (200, {"Content-Type": "text/plain"}, {}),
+            r"failed to parse access token response: unexpected content type",
+            id="wrong content type",
+        ),
+        pytest.param(
+            (200, {"Content-Type": "application/jsonx"}, {}),
+            r"failed to parse access token response: unexpected content type",
+            id="wrong content type (correct prefix)",
+        ),
+    ],
+)
[email protected]("retries", [0, 1])
+def test_oauth_token_failures(
+    accept, openid_provider, retries, failure_mode, error_pattern
+):
+    client_id = secrets.token_hex()
+
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id=client_id,
+    )
+
+    device_code = secrets.token_hex()
+    user_code = f"{secrets.token_hex(2)}-{secrets.token_hex(2)}"
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        assert params["client_id"] == [client_id]
+
+        resp = {
+            "device_code": device_code,
+            "user_code": user_code,
+            "interval": 0,
+            "verification_uri": "https://example.com/device",
+            "expires_in": 5,
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    retry_lock = threading.Lock()
+    final_sent = False
+
+    def token_endpoint(headers, params):
+        with retry_lock:
+            nonlocal retries, final_sent
+
+            # If the test wants to force the client to retry, return an
+            # authorization_pending response and decrement the retry count.
+            if retries > 0:
+                retries -= 1
+                return 400, {"error": "authorization_pending"}
+
+            # We should only return our failure_mode response once; any further
+            # requests indicate that the client isn't correctly bailing out.
+            assert not final_sent, "client continued after token error"
+
+            final_sent = True
+
+        return failure_mode
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    # Now make sure the client correctly failed.
+    with pytest.raises(psycopg2.OperationalError, match=error_pattern):
+        client.check_completed()
+
+
[email protected](
+    "bad_value",
+    [
+        pytest.param({"device_code": 3}, id="object"),
+        pytest.param([1, 2, 3], id="array"),
+        pytest.param("some string", id="string"),
+        pytest.param(4, id="numeric"),
+        pytest.param(False, id="boolean"),
+        pytest.param(None, id="null"),
+        pytest.param(Missing, id="missing"),
+    ],
+)
[email protected](
+    "field_name,ok_type,required",
+    [
+        ("access_token", str, True),
+        ("token_type", str, True),
+    ],
+)
+def test_oauth_token_bad_json_schema(
+    accept, openid_provider, field_name, ok_type, required, bad_value
+):
+    # To make the test matrix easy, just skip the tests that aren't actually
+    # interesting (field of the correct type, missing optional field).
+    if bad_value is Missing and not required:
+        pytest.skip("not interesting: optional field")
+    elif type(bad_value) == ok_type:  # not isinstance(), because bool is an int
+        pytest.skip("not interesting: correct type")
+
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id=secrets.token_hex(),
+    )
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        resp = {
+            "device_code": "my-device-code",
+            "user_code": "my-user-code",
+            "interval": 0,
+            "verification_uri": "https://example.com",
+            "expires_in": 5,
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    def token_endpoint(headers, params):
+        # Begin with an acceptable base response...
+        resp = {
+            "access_token": secrets.token_urlsafe(),
+            "token_type": "bearer",
+        }
+
+        # ...then tweak it so the client fails.
+        if bad_value is Missing:
+            del resp[field_name]
+        else:
+            resp[field_name] = bad_value
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    # Now make sure the client correctly failed.
+    error_pattern = "failed to parse access token response: "
+    if bad_value is Missing:
+        error_pattern += f'field "{field_name}" is missing'
+    elif ok_type == str:
+        error_pattern += f'field "{field_name}" must be a string'
+    elif ok_type == int:
+        error_pattern += f'field "{field_name}" must be a number'
+    else:
+        assert False, "update error_pattern for new failure mode"
+
+    with pytest.raises(psycopg2.OperationalError, match=error_pattern):
+        client.check_completed()
+
+
[email protected]("success", [True, False])
[email protected]("scope", [None, "openid email"])
[email protected](
+    "base_response",
+    [
+        {"status": "invalid_token"},
+        {"extra_object": {"key": "value"}, "status": "invalid_token"},
+        {"extra_object": {"status": 1}, "status": "invalid_token"},
+    ],
+)
+def test_oauth_discovery(accept, openid_provider, base_response, scope, success):
+    sock, client = accept(
+        oauth_issuer=openid_provider.issuer,
+        oauth_client_id=secrets.token_hex(),
+    )
+
+    device_code = secrets.token_hex()
+    user_code = f"{secrets.token_hex(2)}-{secrets.token_hex(2)}"
+    verification_url = "https://example.com/device"
+
+    access_token = secrets.token_urlsafe()
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        if scope:
+            assert params["scope"] == [scope]
+        else:
+            assert "scope" not in params
+
+        resp = {
+            "device_code": device_code,
+            "user_code": user_code,
+            "interval": 0,
+            "verification_uri": verification_url,
+            "expires_in": 5,
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    def token_endpoint(headers, params):
+        assert params["grant_type"] == ["urn:ietf:params:oauth:grant-type:device_code"]
+        assert params["device_code"] == [device_code]
+
+        # Successfully finish the request by sending the access bearer token.
+        resp = {
+            "access_token": access_token,
+            "token_type": "bearer",
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    # Construct the response to use when failing the SASL exchange. Return a
+    # link to the discovery document, pointing to the test provider server.
+    fail_resp = {
+        **base_response,
+        "openid-configuration": openid_provider.discovery_uri,
+    }
+
+    if scope:
+        fail_resp["scope"] = scope
+
+    with sock:
+        handle_discovery_connection(sock, response=fail_resp)
+
+    # The client will connect to us a second time, using the parameters we sent
+    # it.
+    sock, _ = accept()
+
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            initial = start_oauth_handshake(conn)
+
+            # Validate the token.
+            auth = get_auth_value(initial)
+            assert auth == f"Bearer {access_token}".encode("ascii")
+
+            if success:
+                finish_handshake(conn)
+
+            else:
+                # Simulate token validation failure.
+                expected_error = "test token validation failure"
+                fail_oauth_handshake(conn, fail_resp, errmsg=expected_error)
+
+    if not success:
+        # The client should not try to connect again.
+        with pytest.raises(psycopg2.OperationalError, match=expected_error):
+            client.check_completed()
+
+
[email protected](
+    "response,expected_error",
+    [
+        pytest.param(
+            "abcde",
+            'Token "abcde" is invalid',
+            id="bad JSON: invalid syntax",
+        ),
+        pytest.param(
+            b"\xFF\xFF\xFF\xFF",
+            "server's error response is not valid UTF-8",
+            id="bad JSON: invalid encoding",
+        ),
+        pytest.param(
+            '"abcde"',
+            "top-level element must be an object",
+            id="bad JSON: top-level element is a string",
+        ),
+        pytest.param(
+            "[]",
+            "top-level element must be an object",
+            id="bad JSON: top-level element is an array",
+        ),
+        pytest.param(
+            "{}",
+            "server sent error response without a status",
+            id="bad JSON: no status member",
+        ),
+        pytest.param(
+            '{ "status": null }',
+            'field "status" must be a string',
+            id="bad JSON: null status member",
+        ),
+        pytest.param(
+            '{ "status": 0 }',
+            'field "status" must be a string',
+            id="bad JSON: int status member",
+        ),
+        pytest.param(
+            '{ "status": [ "bad" ] }',
+            'field "status" must be a string',
+            id="bad JSON: array status member",
+        ),
+        pytest.param(
+            '{ "status": { "bad": "bad" } }',
+            'field "status" must be a string',
+            id="bad JSON: object status member",
+        ),
+        pytest.param(
+            '{ "nested": { "status": "bad" } }',
+            "server sent error response without a status",
+            id="bad JSON: nested status",
+        ),
+        pytest.param(
+            '{ "status": "invalid_token" ',
+            "The input string ended unexpectedly",
+            id="bad JSON: unterminated object",
+        ),
+        pytest.param(
+            '{ "status": "invalid_token" } { }',
+            'Expected end of input, but found "{"',
+            id="bad JSON: trailing data",
+        ),
+        pytest.param(
+            '{ "status": "invalid_token", "openid-configuration": 1 }',
+            'field "openid-configuration" must be a string',
+            id="bad JSON: int openid-configuration member",
+        ),
+        pytest.param(
+            '{ "status": "invalid_token", "openid-configuration": 1 }',
+            'field "openid-configuration" must be a string',
+            id="bad JSON: int openid-configuration member",
+        ),
+        pytest.param(
+            '{ "status": "invalid_token", "openid-configuration": "", "openid-configuration": "" }',
+            'field "openid-configuration" is duplicated',
+            id="bad JSON: duplicated field",
+        ),
+        pytest.param(
+            '{ "status": "invalid_token", "scope": 1 }',
+            'field "scope" must be a string',
+            id="bad JSON: int scope member",
+        ),
+    ],
+)
+def test_oauth_discovery_server_error(accept, response, expected_error):
+    sock, client = accept(
+        oauth_issuer="https://example.com",
+        oauth_client_id=secrets.token_hex(),
+    )
+
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            initial = start_oauth_handshake(conn)
+
+            if isinstance(response, str):
+                response = response.encode("utf-8")
+
+            # Fail the SASL exchange with an invalid JSON response.
+            pq3.send(
+                conn,
+                pq3.types.AuthnRequest,
+                type=pq3.authn.SASLContinue,
+                body=response,
+            )
+
+            # The client should disconnect, so the socket is closed here. (If
+            # the client doesn't disconnect, it will report a different error
+            # below and the test will fail.)
+
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
+# All of these tests are expected to fail before libpq tries to actually attempt
+# a connection to any endpoint. To avoid hitting the network in the event that a
+# test fails, an invalid IPv4 address (256.256.256.256) is used as a hostname.
[email protected](
+    "bad_response,expected_error",
+    [
+        pytest.param(
+            (200, {"Content-Type": "text/plain"}, {}),
+            r'failed to parse OpenID discovery document: unexpected content type: "text/plain"',
+            id="not JSON",
+        ),
+        pytest.param(
+            (200, {}, {}),
+            r"failed to parse OpenID discovery document: no content type was provided",
+            id="no Content-Type",
+        ),
+        pytest.param(
+            (204, {}, None),
+            r"failed to fetch OpenID discovery document: unexpected response code 204",
+            id="no content",
+        ),
+        pytest.param(
+            (301, {"Location": "https://localhost/"}, None),
+            r"failed to fetch OpenID discovery document: unexpected response code 301",
+            id="redirection",
+        ),
+        pytest.param(
+            (404, {}),
+            r"failed to fetch OpenID discovery document: unexpected response code 404",
+            id="not found",
+        ),
+        pytest.param(
+            (200, RawResponse("blah\x00blah")),
+            r"failed to parse OpenID discovery document: response contains embedded NULLs",
+            id="NULL bytes in document",
+        ),
+        pytest.param(
+            (200, RawBytes(b"blah\xFFblah")),
+            r"failed to parse OpenID discovery document: response is not valid UTF-8",
+            id="document is not UTF-8",
+        ),
+        pytest.param(
+            (200, 123),
+            r"failed to parse OpenID discovery document: top-level element must be an object",
+            id="scalar at top level",
+        ),
+        pytest.param(
+            (200, []),
+            r"failed to parse OpenID discovery document: top-level element must be an object",
+            id="array at top level",
+        ),
+        pytest.param(
+            (200, RawResponse("{")),
+            r"failed to parse OpenID discovery document.* input string ended unexpectedly",
+            id="unclosed object",
+        ),
+        pytest.param(
+            (200, RawResponse(r'{ "hello": ] }')),
+            r"failed to parse OpenID discovery document.* Expected JSON value",
+            id="bad array",
+        ),
+        pytest.param(
+            (200, {"issuer": 123}),
+            r'failed to parse OpenID discovery document: field "issuer" must be a string',
+            id="non-string issuer",
+        ),
+        pytest.param(
+            (200, {"issuer": ["something"]}),
+            r'failed to parse OpenID discovery document: field "issuer" must be a string',
+            id="issuer array",
+        ),
+        pytest.param(
+            (200, {"issuer": {}}),
+            r'failed to parse OpenID discovery document: field "issuer" must be a string',
+            id="issuer object",
+        ),
+        pytest.param(
+            (200, {"grant_types_supported": 123}),
+            r'failed to parse OpenID discovery document: field "grant_types_supported" must be an array of strings',
+            id="numeric grant types field",
+        ),
+        pytest.param(
+            (
+                200,
+                {
+                    "grant_types_supported": "urn:ietf:params:oauth:grant-type:device_code"
+                },
+            ),
+            r'failed to parse OpenID discovery document: field "grant_types_supported" must be an array of strings',
+            id="string grant types field",
+        ),
+        pytest.param(
+            (200, {"grant_types_supported": {}}),
+            r'failed to parse OpenID discovery document: field "grant_types_supported" must be an array of strings',
+            id="object grant types field",
+        ),
+        pytest.param(
+            (200, {"grant_types_supported": [123]}),
+            r'failed to parse OpenID discovery document: field "grant_types_supported" must be an array of strings',
+            id="non-string grant types",
+        ),
+        pytest.param(
+            (200, {"grant_types_supported": ["something", 123]}),
+            r'failed to parse OpenID discovery document: field "grant_types_supported" must be an array of strings',
+            id="non-string grant types later in the list",
+        ),
+        pytest.param(
+            (200, {"grant_types_supported": ["something", {}]}),
+            r'failed to parse OpenID discovery document: field "grant_types_supported" must be an array of strings',
+            id="object grant types later in the list",
+        ),
+        pytest.param(
+            (200, {"grant_types_supported": ["something", ["something"]]}),
+            r'failed to parse OpenID discovery document: field "grant_types_supported" must be an array of strings',
+            id="embedded array grant types later in the list",
+        ),
+        pytest.param(
+            (
+                200,
+                {
+                    "grant_types_supported": ["something"],
+                    "token_endpoint": "https://256.256.256.256/",
+                    "issuer": 123,
+                },
+            ),
+            r'failed to parse OpenID discovery document: field "issuer" must be a string',
+            id="non-string issuer after other valid fields",
+        ),
+        pytest.param(
+            (
+                200,
+                {
+                    "ignored": {"grant_types_supported": 123, "token_endpoint": 123},
+                    "issuer": 123,
+                },
+            ),
+            r'failed to parse OpenID discovery document: field "issuer" must be a string',
+            id="non-string issuer after other ignored fields",
+        ),
+        pytest.param(
+            (200, {"token_endpoint": "https://256.256.256.256/"}),
+            r'failed to parse OpenID discovery document: field "issuer" is missing',
+            id="missing issuer",
+        ),
+        pytest.param(
+            (200, {"issuer": "{issuer}"}),
+            r'failed to parse OpenID discovery document: field "token_endpoint" is missing',
+            id="missing token endpoint",
+        ),
+        pytest.param(
+            (
+                200,
+                {
+                    "issuer": "{issuer}",
+                    "token_endpoint": "https://256.256.256.256/token",
+                    "grant_types_supported": [
+                        "urn:ietf:params:oauth:grant-type:device_code"
+                    ],
+                },
+            ),
+            r'cannot run OAuth device authorization: issuer "https://.*" does not provide a device authorization endpoint',
+            id="missing device_authorization_endpoint",
+        ),
+        pytest.param(
+            (
+                200,
+                {
+                    "issuer": "{issuer}",
+                    "token_endpoint": "https://256.256.256.256/token",
+                    "grant_types_supported": [
+                        "urn:ietf:params:oauth:grant-type:device_code"
+                    ],
+                    "device_authorization_endpoint": "https://256.256.256.256/dev",
+                    "filler": "x" * 1024 * 1024,
+                },
+            ),
+            r"failed to fetch OpenID discovery document: response is too large",
+            id="gigantic discovery response",
+        ),
+        pytest.param(
+            (
+                200,
+                {
+                    "issuer": "{issuer}/path",
+                    "token_endpoint": "https://256.256.256.256/token",
+                    "grant_types_supported": [
+                        "urn:ietf:params:oauth:grant-type:device_code"
+                    ],
+                    "device_authorization_endpoint": "https://256.256.256.256/dev",
+                },
+            ),
+            r"failed to parse OpenID discovery document: the issuer identifier \(https://.*/path\) does not match oauth_issuer \(https://.*\)",
+            id="mismatched issuer identifier",
+        ),
+        pytest.param(
+            (
+                200,
+                RawResponse(
+                    """{
+                        "issuer": "https://256.256.256.256/path",
+                        "token_endpoint": "https://256.256.256.256/token",
+                        "grant_types_supported": [
+                            "urn:ietf:params:oauth:grant-type:device_code"
+                        ],
+                        "device_authorization_endpoint": "https://256.256.256.256/dev",
+                        "device_authorization_endpoint": "https://256.256.256.256/dev"
+                    }"""
+                ),
+            ),
+            r'failed to parse OpenID discovery document: field "device_authorization_endpoint" is duplicated',
+            id="duplicated field",
+        ),
+        #
+        # Exercise HTTP-level failures by breaking the protocol. Note that the
+        # error messages here are implementation-dependent.
+        #
+        pytest.param(
+            (1000, {}),
+            r"failed to fetch OpenID discovery document: Unsupported protocol \(.*\)",
+            id="invalid HTTP response code",
+        ),
+        pytest.param(
+            (200, {"Content-Length": -1}, {}),
+            r"failed to fetch OpenID discovery document: Weird server reply \(.*Content-Length.*\)",
+            id="bad HTTP Content-Length",
+        ),
+    ],
+)
+def test_oauth_discovery_provider_failure(
+    accept, openid_provider, bad_response, expected_error
+):
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id=secrets.token_hex(),
+    )
+
+    def failing_discovery_handler(headers, params):
+        try:
+            # Insert the correct issuer value if the test wants to.
+            resp = bad_response[1]
+            iss = resp["issuer"]
+            resp["issuer"] = iss.format(issuer=openid_provider.issuer)
+        except (AttributeError, KeyError, TypeError):
+            pass
+
+        return bad_response
+
+    openid_provider.register_endpoint(
+        None,
+        "GET",
+        "/.well-known/openid-configuration",
+        failing_discovery_handler,
+    )
+
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
[email protected](
+    "sasl_err,resp_type,resp_payload,expected_error",
+    [
+        pytest.param(
+            {"status": "invalid_request"},
+            pq3.types.ErrorResponse,
+            dict(
+                fields=[b"SFATAL", b"C28000", b"Mexpected error message", b""],
+            ),
+            "server rejected OAuth bearer token: invalid_request",
+            id="standard server error: invalid_request",
+        ),
+        pytest.param(
+            {"status": "invalid_token"},
+            pq3.types.ErrorResponse,
+            dict(
+                fields=[b"SFATAL", b"C28000", b"Mexpected error message", b""],
+            ),
+            "expected error message",
+            id="standard server error: invalid_token without discovery URI",
+        ),
+        pytest.param(
+            {"status": "invalid_token", "openid-configuration": ""},
+            pq3.types.AuthnRequest,
+            dict(type=pq3.authn.SASLContinue, body=b""),
+            "server sent additional OAuth data",
+            id="broken server: additional challenge after error",
+        ),
+        pytest.param(
+            {"status": "invalid_token", "openid-configuration": ""},
+            pq3.types.AuthnRequest,
+            dict(type=pq3.authn.SASLFinal),
+            "server sent additional OAuth data",
+            id="broken server: SASL success after error",
+        ),
+        pytest.param(
+            {"status": "invalid_token", "openid-configuration": ""},
+            pq3.types.AuthnRequest,
+            dict(type=pq3.authn.SASL, body=[b"OAUTHBEARER", b""]),
+            "duplicate SASL authentication request",
+            id="broken server: SASL reinitialization after error",
+        ),
+    ],
+)
+def test_oauth_server_error(
+    accept, auth_data_cb, sasl_err, resp_type, resp_payload, expected_error
+):
+    wkuri = f"https://256.256.256.256/.well-known/openid-configuration"
+    sock, client = accept(
+        oauth_issuer=wkuri,
+        oauth_client_id="some-id",
+    )
+
+    def bearer_hook(typ, pgconn, request):
+        """
+        Implementation of the PQAuthDataHook, which returns a token directly so
+        we don't need an openid_provider instance.
+        """
+        assert typ == PQAUTHDATA_OAUTH_BEARER_TOKEN
+        request.token = secrets.token_urlsafe().encode()
+        return 1
+
+    auth_data_cb.impl = bearer_hook
+
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            start_oauth_handshake(conn)
+
+            # Ignore the client data. Return an error "challenge".
+            if "openid-configuration" in sasl_err:
+                sasl_err["openid-configuration"] = wkuri
+
+            resp = json.dumps(sasl_err)
+            resp = resp.encode("utf-8")
+
+            pq3.send(
+                conn, pq3.types.AuthnRequest, type=pq3.authn.SASLContinue, body=resp
+            )
+
+            # Per RFC, the client is required to send a dummy ^A response.
+            pkt = pq3.recv1(conn)
+            assert pkt.type == pq3.types.PasswordMessage
+            assert pkt.payload == b"\x01"
+
+            # Now fail the SASL exchange (in either a valid way, or an
+            # invalid one, depending on the test).
+            pq3.send(conn, resp_type, **resp_payload)
+
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
+def test_oauth_interval_overflow(accept, openid_provider):
+    """
+    A really badly behaved server could send a huge interval and then
+    immediately tell us to slow_down; ensure we handle this without breaking.
+    """
+    # (should be equivalent to the INT_MAX in limits.h)
+    int_max = ctypes.c_uint(-1).value // 2
+
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id=secrets.token_hex(),
+    )
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        resp = {
+            "device_code": "my-device-code",
+            "user_code": "my-user-code",
+            "verification_uri": "https://example.com",
+            "expires_in": 5,
+            "interval": int_max,
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    def token_endpoint(headers, params):
+        return 400, {"error": "slow_down"}
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    expected_error = "slow_down interval overflow"
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
+def test_oauth_refuses_http(accept, openid_provider, monkeypatch):
+    """
+    HTTP must be refused without PGOAUTHDEBUG.
+    """
+    monkeypatch.delenv("PGOAUTHDEBUG")
+
+    def to_http(uri):
+        """Swaps out a URI's scheme for http."""
+        parts = urllib.parse.urlparse(uri)
+        parts = parts._replace(scheme="http")
+        return urllib.parse.urlunparse(parts)
+
+    sock, client = accept(
+        oauth_issuer=to_http(openid_provider.issuer),
+        oauth_client_id=secrets.token_hex(),
+    )
+
+    # No provider callbacks necessary; we should fail immediately.
+
+    with sock:
+        handle_discovery_connection(sock, to_http(openid_provider.discovery_uri))
+
+    expected_error = r'OAuth discovery URI ".*" must use HTTPS'
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
[email protected]("auth_type", [pq3.authn.OK, pq3.authn.SASLFinal])
+def test_discovery_incorrectly_permits_connection(accept, auth_type):
+    """
+    Incorrectly responds to a client's discovery request with AuthenticationOK
+    or AuthenticationSASLFinal. require_auth=oauth should catch the former, and
+    the mechanism itself should catch the latter.
+    """
+    issuer = "https://256.256.256.256"
+    sock, client = accept(
+        oauth_issuer=issuer,
+        oauth_client_id=secrets.token_hex(),
+        require_auth="oauth",
+    )
+
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            initial = start_oauth_handshake(conn)
+
+            auth = get_auth_value(initial)
+            assert auth == b""
+
+            # Incorrectly log the client in. It should immediately disconnect.
+            pq3.send(conn, pq3.types.AuthnRequest, type=auth_type)
+            assert not conn.read(1), "client sent unexpected data"
+
+    if auth_type == pq3.authn.OK:
+        expected_error = "server did not complete authentication"
+    else:
+        expected_error = "server sent unexpected additional OAuth data"
+
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
+def test_no_discovery_url_provided(accept):
+    """
+    Tests what happens when the client doesn't know who to contact and the
+    server doesn't tell it.
+    """
+    issuer = "https://256.256.256.256"
+    sock, client = accept(
+        oauth_issuer=issuer,
+        oauth_client_id=secrets.token_hex(),
+    )
+
+    with sock:
+        handle_discovery_connection(sock, discovery=None)
+
+    expected_error = "no discovery metadata was provided"
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
[email protected]("change_between_connections", [False, True])
+def test_discovery_url_changes(accept, openid_provider, change_between_connections):
+    """
+    Ensures that the client complains if the server agrees on the issuer, but
+    disagrees on the discovery URL to be used.
+    """
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        resp = {
+            "device_code": "DEV",
+            "user_code": "USER",
+            "interval": 0,
+            "verification_uri": "https://example.org",
+            "expires_in": 5,
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    def token_endpoint(headers, params):
+        resp = {
+            "access_token": secrets.token_urlsafe(),
+            "token_type": "bearer",
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    # Have the client connect.
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id="some-id",
+    )
+
+    other_wkuri = f"{openid_provider.issuer}/.well-known/oauth-authorization-server"
+
+    if not change_between_connections:
+        # Immediately respond with the wrong URL.
+        with sock:
+            handle_discovery_connection(sock, other_wkuri)
+
+    else:
+        # First connection; use the right URL to begin with.
+        with sock:
+            handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+        # Second connection. Reject the token and switch the URL.
+        sock, _ = accept()
+        with sock:
+            with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+                initial = start_oauth_handshake(conn)
+                get_auth_value(initial)
+
+                # Ignore the token; fail with a different discovery URL.
+                resp = {
+                    "status": "invalid_token",
+                    "openid-configuration": other_wkuri,
+                }
+                fail_oauth_handshake(conn, resp)
+
+    expected_error = rf"server's discovery document has moved to {other_wkuri} \(previous location was {openid_provider.discovery_uri}\)"
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
diff --git a/src/test/python/conftest.py b/src/test/python/conftest.py
new file mode 100644
index 00000000000..1a73865ee47
--- /dev/null
+++ b/src/test/python/conftest.py
@@ -0,0 +1,34 @@
+#
+# Copyright 2023 Timescale, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import os
+
+import pytest
+
+
+def pytest_addoption(parser):
+    """
+    Adds custom command line options to py.test. We add one to signal temporary
+    Postgres instance creation for the server tests.
+
+    Per pytest documentation, this must live in the top level test directory.
+    """
+    parser.addoption(
+        "--temp-instance",
+        metavar="DIR",
+        help="create a temporary Postgres instance in DIR",
+    )
+
+
[email protected](scope="session", autouse=True)
+def _check_PG_TEST_EXTRA(request):
+    """
+    Automatically skips the whole suite if PG_TEST_EXTRA doesn't contain
+    'python'. pytestmark doesn't seem to work in a top-level conftest.py, so
+    I've made this an autoused fixture instead.
+    """
+    extra_tests = os.getenv("PG_TEST_EXTRA", "").split()
+    if "python" not in extra_tests:
+        pytest.skip("Potentially unsafe test 'python' not enabled in PG_TEST_EXTRA")
diff --git a/src/test/python/meson.build b/src/test/python/meson.build
new file mode 100644
index 00000000000..e137df852ef
--- /dev/null
+++ b/src/test/python/meson.build
@@ -0,0 +1,47 @@
+# Copyright (c) 2023, PostgreSQL Global Development Group
+
+subdir('server')
+
+pytest_env = {
+  'with_libcurl': libcurl.found() ? 'yes' : 'no',
+
+  # Point to the default database; the tests will create their own databases as
+  # needed.
+  'PGDATABASE': 'postgres',
+
+  # Avoid the need for a Rust compiler on platforms without prebuilt wheels for
+  # pyca/cryptography.
+  'CRYPTOGRAPHY_DONT_BUILD_RUST': '1',
+}
+
+# Some modules (psycopg2) need OpenSSL at compile time; for platforms where we
+# might have multiple implementations installed (macOS+brew), try to use the
+# same one that libpq is using.
+if ssl.found()
+  pytest_incdir = ssl.get_variable(pkgconfig: 'includedir', default_value: '')
+  if pytest_incdir != ''
+    pytest_env += { 'CPPFLAGS': '-I@0@'.format(pytest_incdir) }
+  endif
+
+  pytest_libdir = ssl.get_variable(pkgconfig: 'libdir', default_value: '')
+  if pytest_libdir != ''
+    pytest_env += { 'LDFLAGS': '-L@0@'.format(pytest_libdir) }
+  endif
+endif
+
+tests += {
+  'name': 'python',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'pytest': {
+	'requirements': meson.current_source_dir() / 'requirements.txt',
+    'tests': [
+      './client',
+      './server',
+      './test_internals.py',
+      './test_pq3.py',
+    ],
+    'env': pytest_env,
+    'test_kwargs': {'priority': 50}, # python tests are slow, start early
+  },
+}
diff --git a/src/test/python/pq3.py b/src/test/python/pq3.py
new file mode 100644
index 00000000000..ef809e288af
--- /dev/null
+++ b/src/test/python/pq3.py
@@ -0,0 +1,740 @@
+#
+# Copyright 2021 VMware, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import contextlib
+import getpass
+import io
+import os
+import platform
+import ssl
+import sys
+import textwrap
+
+from construct import *
+
+import tls
+
+
+def protocol(major, minor):
+    """
+    Returns the protocol version, in integer format, corresponding to the given
+    major and minor version numbers.
+    """
+    return (major << 16) | minor
+
+
+# Startup
+
+StringList = GreedyRange(NullTerminated(GreedyBytes))
+
+
+class KeyValueAdapter(Adapter):
+    """
+    Turns a key-value store into a null-terminated list of null-terminated
+    strings, as presented on the wire in the startup packet.
+    """
+
+    def _encode(self, obj, context, path):
+        if isinstance(obj, list):
+            return obj
+
+        l = []
+
+        for k, v in obj.items():
+            if isinstance(k, str):
+                k = k.encode("utf-8")
+            l.append(k)
+
+            if isinstance(v, str):
+                v = v.encode("utf-8")
+            l.append(v)
+
+        l.append(b"")
+        return l
+
+    def _decode(self, obj, context, path):
+        # TODO: turn a list back into a dict
+        return obj
+
+
+KeyValues = KeyValueAdapter(StringList)
+
+_startup_payload = Switch(
+    this.proto,
+    {
+        protocol(3, 0): KeyValues,
+    },
+    default=GreedyBytes,
+)
+
+
+def _default_protocol(this):
+    try:
+        if isinstance(this.payload, (list, dict)):
+            return protocol(3, 0)
+    except AttributeError:
+        pass  # no payload passed during build
+
+    return 0
+
+
+def _startup_payload_len(this):
+    """
+    The payload field has a fixed size based on the length of the packet. But
+    if the caller hasn't supplied an explicit length at build time, we have to
+    build the payload to figure out how long it is, which requires us to know
+    the length first... This function exists solely to break the cycle.
+    """
+    assert this._building, "_startup_payload_len() cannot be called during parsing"
+
+    try:
+        payload = this.payload
+    except AttributeError:
+        return 0  # no payload
+
+    if isinstance(payload, bytes):
+        # already serialized; just use the given length
+        return len(payload)
+
+    try:
+        proto = this.proto
+    except AttributeError:
+        proto = _default_protocol(this)
+
+    data = _startup_payload.build(payload, proto=proto)
+    return len(data)
+
+
+Startup = Struct(
+    "len" / Default(Int32sb, lambda this: _startup_payload_len(this) + 8),
+    "proto" / Default(Hex(Int32sb), _default_protocol),
+    "payload" / FixedSized(this.len - 8, Default(_startup_payload, b"")),
+)
+
+# Pq3
+
+
+# Adapted from construct.core.EnumIntegerString
+class EnumNamedByte:
+    def __init__(self, val, name):
+        self._val = val
+        self._name = name
+
+    def __int__(self):
+        return ord(self._val)
+
+    def __str__(self):
+        return "(enum) %s %r" % (self._name, self._val)
+
+    def __repr__(self):
+        return "EnumNamedByte(%r)" % self._val
+
+    def __eq__(self, other):
+        if isinstance(other, EnumNamedByte):
+            other = other._val
+        if not isinstance(other, bytes):
+            return NotImplemented
+
+        return self._val == other
+
+    def __hash__(self):
+        return hash(self._val)
+
+
+# Adapted from construct.core.Enum
+class ByteEnum(Adapter):
+    def __init__(self, **mapping):
+        super(ByteEnum, self).__init__(Byte)
+        self.namemapping = {k: EnumNamedByte(v, k) for k, v in mapping.items()}
+        self.decmapping = {v: EnumNamedByte(v, k) for k, v in mapping.items()}
+
+    def __getattr__(self, name):
+        if name in self.namemapping:
+            return self.decmapping[self.namemapping[name]]
+        raise AttributeError
+
+    def _decode(self, obj, context, path):
+        b = bytes([obj])
+        try:
+            return self.decmapping[b]
+        except KeyError:
+            return EnumNamedByte(b, "(unknown)")
+
+    def _encode(self, obj, context, path):
+        if isinstance(obj, int):
+            return obj
+        elif isinstance(obj, bytes):
+            return ord(obj)
+        return int(obj)
+
+
+types = ByteEnum(
+    ErrorResponse=b"E",
+    ReadyForQuery=b"Z",
+    Query=b"Q",
+    EmptyQueryResponse=b"I",
+    AuthnRequest=b"R",
+    PasswordMessage=b"p",
+    BackendKeyData=b"K",
+    CommandComplete=b"C",
+    ParameterStatus=b"S",
+    DataRow=b"D",
+    Terminate=b"X",
+)
+
+
+authn = Enum(
+    Int32ub,
+    OK=0,
+    SASL=10,
+    SASLContinue=11,
+    SASLFinal=12,
+)
+
+
+_authn_body = Switch(
+    this.type,
+    {
+        authn.OK: Terminated,
+        authn.SASL: StringList,
+    },
+    default=GreedyBytes,
+)
+
+
+def _data_len(this):
+    assert this._building, "_data_len() cannot be called during parsing"
+
+    if not hasattr(this, "data") or this.data is None:
+        return -1
+
+    return len(this.data)
+
+
+# The protocol reuses the PasswordMessage for several authentication response
+# types, and there's no good way to figure out which is which without keeping
+# state for the entire stream. So this is a separate Construct that can be
+# explicitly parsed/built by code that knows it's needed.
+SASLInitialResponse = Struct(
+    "name" / NullTerminated(GreedyBytes),
+    "len" / Default(Int32sb, lambda this: _data_len(this)),
+    "data"
+    / IfThenElse(
+        # Allow tests to explicitly pass an incorrect length during testing, by
+        # not enforcing a FixedSized during build. (The len calculation above
+        # defaults to the correct size.)
+        this._building,
+        Optional(GreedyBytes),
+        If(this.len != -1, Default(FixedSized(this.len, GreedyBytes), b"")),
+    ),
+    Terminated,  # make sure the entire response is consumed
+)
+
+
+_column = FocusedSeq(
+    "data",
+    "len" / Default(Int32sb, lambda this: _data_len(this)),
+    "data" / If(this.len != -1, FixedSized(this.len, GreedyBytes)),
+)
+
+
+_payload_map = {
+    types.ErrorResponse: Struct("fields" / StringList),
+    types.ReadyForQuery: Struct("status" / Bytes(1)),
+    types.Query: Struct("query" / NullTerminated(GreedyBytes)),
+    types.EmptyQueryResponse: Terminated,
+    types.AuthnRequest: Struct("type" / authn, "body" / Default(_authn_body, b"")),
+    types.BackendKeyData: Struct("pid" / Int32ub, "key" / Hex(Int32ub)),
+    types.CommandComplete: Struct("tag" / NullTerminated(GreedyBytes)),
+    types.ParameterStatus: Struct(
+        "name" / NullTerminated(GreedyBytes), "value" / NullTerminated(GreedyBytes)
+    ),
+    types.DataRow: Struct("columns" / Default(PrefixedArray(Int16sb, _column), b"")),
+    types.Terminate: Terminated,
+}
+
+
+_payload = FocusedSeq(
+    "_payload",
+    "_payload"
+    / Switch(
+        this._.type,
+        _payload_map,
+        default=GreedyBytes,
+    ),
+    Terminated,  # make sure every payload consumes the entire packet
+)
+
+
+def _payload_len(this):
+    """
+    See _startup_payload_len() for an explanation.
+    """
+    assert this._building, "_payload_len() cannot be called during parsing"
+
+    try:
+        payload = this.payload
+    except AttributeError:
+        return 0  # no payload
+
+    if isinstance(payload, bytes):
+        # already serialized; just use the given length
+        return len(payload)
+
+    data = _payload.build(payload, type=this.type)
+    return len(data)
+
+
+Pq3 = Struct(
+    "type" / types,
+    "len" / Default(Int32ub, lambda this: _payload_len(this) + 4),
+    "payload"
+    / IfThenElse(
+        # Allow tests to explicitly pass an incorrect length during testing, by
+        # not enforcing a FixedSized during build. (The len calculation above
+        # defaults to the correct size.)
+        this._building,
+        Optional(_payload),
+        FixedSized(this.len - 4, Default(_payload, b"")),
+    ),
+)
+
+
+# Environment
+
+
+def pghost():
+    return os.environ.get("PGHOST", default="localhost")
+
+
+def pgport():
+    return int(os.environ.get("PGPORT", default=5432))
+
+
+def pguser():
+    try:
+        return os.environ["PGUSER"]
+    except KeyError:
+        if platform.system() == "Windows":
+            # libpq defaults to GetUserName() on Windows.
+            return os.getlogin()
+        return getpass.getuser()
+
+
+def pgdatabase():
+    return os.environ.get("PGDATABASE", default="postgres")
+
+
+# Connections
+
+
+def _hexdump_translation_map():
+    """
+    For hexdumps. Translates any unprintable or non-ASCII bytes into '.'.
+    """
+    input = bytearray()
+
+    for i in range(128):
+        c = chr(i)
+
+        if not c.isprintable():
+            input += bytes([i])
+
+    input += bytes(range(128, 256))
+
+    return bytes.maketrans(input, b"." * len(input))
+
+
+class _DebugStream(object):
+    """
+    Wraps a file-like object and adds hexdumps of the read and write data. Call
+    end_packet() on a _DebugStream to write the accumulated hexdumps to the
+    output stream, along with the packet that was sent.
+    """
+
+    _translation_map = _hexdump_translation_map()
+
+    def __init__(self, stream, out=sys.stdout):
+        """
+        Creates a new _DebugStream wrapping the given stream (which must have
+        been created by wrap()). All attributes not provided by the _DebugStream
+        are delegated to the wrapped stream. out is the text stream to which
+        hexdumps are written.
+        """
+        self.raw = stream
+        self._out = out
+        self._rbuf = io.BytesIO()
+        self._wbuf = io.BytesIO()
+
+    def __getattr__(self, name):
+        return getattr(self.raw, name)
+
+    def __setattr__(self, name, value):
+        if name in ("raw", "_out", "_rbuf", "_wbuf"):
+            return object.__setattr__(self, name, value)
+
+        setattr(self.raw, name, value)
+
+    def read(self, *args, **kwargs):
+        buf = self.raw.read(*args, **kwargs)
+
+        self._rbuf.write(buf)
+        return buf
+
+    def write(self, b):
+        self._wbuf.write(b)
+        return self.raw.write(b)
+
+    def recv(self, *args):
+        buf = self.raw.recv(*args)
+
+        self._rbuf.write(buf)
+        return buf
+
+    def _flush(self, buf, prefix):
+        width = 16
+        hexwidth = width * 3 - 1
+
+        count = 0
+        buf.seek(0)
+
+        while True:
+            line = buf.read(16)
+
+            if not line:
+                if count:
+                    self._out.write("\n")  # separate the output block with a newline
+                return
+
+            self._out.write("%s %04X:\t" % (prefix, count))
+            self._out.write("%*s\t" % (-hexwidth, line.hex(" ")))
+            self._out.write(line.translate(self._translation_map).decode("ascii"))
+            self._out.write("\n")
+
+            count += 16
+
+    def print_debug(self, obj, *, prefix=""):
+        contents = ""
+        if obj is not None:
+            contents = str(obj)
+
+        for line in contents.splitlines():
+            self._out.write("%s%s\n" % (prefix, line))
+
+        self._out.write("\n")
+
+    def flush_debug(self, *, prefix=""):
+        self._flush(self._rbuf, prefix + "<")
+        self._rbuf = io.BytesIO()
+
+        self._flush(self._wbuf, prefix + ">")
+        self._wbuf = io.BytesIO()
+
+    def end_packet(self, pkt, *, read=False, prefix="", indent="  "):
+        """
+        Marks the end of a logical "packet" of data. A string representation of
+        pkt will be printed, and the debug buffers will be flushed with an
+        indent. All lines can be optionally prefixed.
+
+        If read is True, the packet representation is written after the debug
+        buffers; otherwise the default of False (meaning write) causes the
+        packet representation to be dumped first. This is meant to capture the
+        logical flow of layer translation.
+        """
+        write = not read
+
+        if write:
+            self.print_debug(pkt, prefix=prefix + "> ")
+
+        self.flush_debug(prefix=prefix + indent)
+
+        if read:
+            self.print_debug(pkt, prefix=prefix + "< ")
+
+
[email protected]
+def wrap(socket, *, debug_stream=None):
+    """
+    Transforms a raw socket into a connection that can be used for Construct
+    building and parsing. The return value is a context manager and can be used
+    in a with statement.
+    """
+    # It is critical that buffering be disabled here, so that we can still
+    # manipulate the raw socket without desyncing the stream.
+    with socket.makefile("rwb", buffering=0) as sfile:
+        # Expose the original socket's recv() on the SocketIO object we return.
+        def recv(self, *args):
+            return socket.recv(*args)
+
+        sfile.recv = recv.__get__(sfile)
+
+        conn = sfile
+        if debug_stream:
+            conn = _DebugStream(conn, debug_stream)
+
+        try:
+            yield conn
+        finally:
+            if debug_stream:
+                conn.flush_debug(prefix="? ")
+
+
+def _send(stream, cls, obj):
+    debugging = hasattr(stream, "flush_debug")
+    out = io.BytesIO()
+
+    # Ideally we would build directly to the passed stream, but because we need
+    # to reparse the generated output for the debugging case, build to an
+    # intermediate BytesIO and send it instead.
+    cls.build_stream(obj, out)
+    buf = out.getvalue()
+
+    stream.write(buf)
+    if debugging:
+        pkt = cls.parse(buf)
+        stream.end_packet(pkt)
+
+    stream.flush()
+
+
+def send(stream, packet_type, payload_data=None, **payloadkw):
+    """
+    Sends a packet on the given pq3 connection. type is the pq3.types member
+    that should be assigned to the packet. If payload_data is given, it will be
+    used as the packet payload; otherwise the key/value pairs in payloadkw will
+    be the payload contents.
+    """
+    data = payloadkw
+
+    if payload_data is not None:
+        if payloadkw:
+            raise ValueError(
+                "payload_data and payload keywords may not be used simultaneously"
+            )
+
+        data = payload_data
+
+    _send(stream, Pq3, dict(type=packet_type, payload=data))
+
+
+def send_startup(stream, proto=None, **kwargs):
+    """
+    Sends a startup packet on the given pq3 connection. In most cases you should
+    use the handshake functions instead, which will do this for you.
+
+    By default, a protocol version 3 packet will be sent. This can be overridden
+    with the proto parameter.
+    """
+    pkt = {}
+
+    if proto is not None:
+        pkt["proto"] = proto
+    if kwargs:
+        pkt["payload"] = kwargs
+
+    _send(stream, Startup, pkt)
+
+
+def recv1(stream, *, cls=Pq3):
+    """
+    Receives a single pq3 packet from the given stream and returns it.
+    """
+    resp = cls.parse_stream(stream)
+
+    debugging = hasattr(stream, "flush_debug")
+    if debugging:
+        stream.end_packet(resp, read=True)
+
+    return resp
+
+
+def handshake(stream, **kwargs):
+    """
+    Performs a libpq v3 startup handshake. kwargs should contain the key/value
+    parameters to send to the server in the startup packet.
+    """
+    # Send our startup parameters.
+    send_startup(stream, **kwargs)
+
+    # Receive and dump packets until the server indicates it's ready for our
+    # first query.
+    while True:
+        resp = recv1(stream)
+        if resp is None:
+            raise RuntimeError("server closed connection during handshake")
+
+        if resp.type == types.ReadyForQuery:
+            return
+        elif resp.type == types.ErrorResponse:
+            raise RuntimeError(
+                f"received error response from peer: {resp.payload.fields!r}"
+            )
+
+
+# TLS
+
+
+class _TLSStream(object):
+    """
+    A file-like object that performs TLS encryption/decryption on a wrapped
+    stream. Differs from ssl.SSLSocket in that we have full visibility and
+    control over the TLS layer.
+    """
+
+    def __init__(self, stream, context):
+        self._stream = stream
+        self._debugging = hasattr(stream, "flush_debug")
+
+        self._in = ssl.MemoryBIO()
+        self._out = ssl.MemoryBIO()
+        self._ssl = context.wrap_bio(self._in, self._out)
+
+    def handshake(self):
+        try:
+            self._pump(lambda: self._ssl.do_handshake())
+        finally:
+            self._flush_debug(prefix="? ")
+
+    def read(self, *args):
+        return self._pump(lambda: self._ssl.read(*args))
+
+    def write(self, *args):
+        return self._pump(lambda: self._ssl.write(*args))
+
+    def _decode(self, buf):
+        """
+        Attempts to decode a buffer of TLS data into a packet representation
+        that can be printed.
+
+        TODO: handle buffers (and record fragments) that don't align with packet
+        boundaries.
+        """
+        end = len(buf)
+        bio = io.BytesIO(buf)
+
+        ret = io.StringIO()
+
+        while bio.tell() < end:
+            record = tls.Plaintext.parse_stream(bio)
+
+            if ret.tell() > 0:
+                ret.write("\n")
+            ret.write("[Record] ")
+            ret.write(str(record))
+            ret.write("\n")
+
+            if record.type == tls.ContentType.handshake:
+                record_cls = tls.Handshake
+            else:
+                continue
+
+            innerlen = len(record.fragment)
+            inner = io.BytesIO(record.fragment)
+
+            while inner.tell() < innerlen:
+                msg = record_cls.parse_stream(inner)
+
+                indented = "[Message] " + str(msg)
+                indented = textwrap.indent(indented, "    ")
+
+                ret.write("\n")
+                ret.write(indented)
+                ret.write("\n")
+
+        return ret.getvalue()
+
+    def flush(self):
+        if not self._out.pending:
+            self._stream.flush()
+            return
+
+        buf = self._out.read()
+        self._stream.write(buf)
+
+        if self._debugging:
+            pkt = self._decode(buf)
+            self._stream.end_packet(pkt, prefix="  ")
+
+        self._stream.flush()
+
+    def _pump(self, operation):
+        while True:
+            try:
+                return operation()
+            except (ssl.SSLWantReadError, ssl.SSLWantWriteError) as e:
+                want = e
+            self._read_write(want)
+
+    def _recv(self, maxsize):
+        buf = self._stream.recv(4096)
+        if not buf:
+            self._in.write_eof()
+            return
+
+        self._in.write(buf)
+
+        if not self._debugging:
+            return
+
+        pkt = self._decode(buf)
+        self._stream.end_packet(pkt, read=True, prefix="  ")
+
+    def _read_write(self, want):
+        # XXX This needs work. So many corner cases yet to handle. For one,
+        # doing blocking writes in flush may lead to distributed deadlock if the
+        # peer is already blocking on its writes.
+
+        if isinstance(want, ssl.SSLWantWriteError):
+            assert self._out.pending, "SSL backend wants write without data"
+
+        self.flush()
+
+        if isinstance(want, ssl.SSLWantReadError):
+            self._recv(4096)
+
+    def _flush_debug(self, prefix):
+        if not self._debugging:
+            return
+
+        self._stream.flush_debug(prefix=prefix)
+
+
[email protected]
+def tls_handshake(stream, context):
+    """
+    Performs a TLS handshake over the given stream (which must have been created
+    via a call to wrap()), and returns a new stream which transparently tunnels
+    data over the TLS connection.
+
+    If the passed stream has debugging enabled, the returned stream will also
+    have debugging, using the same output IO.
+    """
+    debugging = hasattr(stream, "flush_debug")
+
+    # Send our startup parameters.
+    send_startup(stream, proto=protocol(1234, 5679))
+
+    # Look at the SSL response.
+    resp = stream.read(1)
+    if debugging:
+        stream.flush_debug(prefix="  ")
+
+    if resp == b"N":
+        raise RuntimeError("server does not support SSLRequest")
+    if resp != b"S":
+        raise RuntimeError(f"unexpected response of type {resp!r} during TLS startup")
+
+    tls = _TLSStream(stream, context)
+    tls.handshake()
+
+    if debugging:
+        tls = _DebugStream(tls, stream._out)
+
+    try:
+        yield tls
+        # TODO: teardown/unwrap the connection?
+    finally:
+        if debugging:
+            tls.flush_debug(prefix="? ")
diff --git a/src/test/python/pytest.ini b/src/test/python/pytest.ini
new file mode 100644
index 00000000000..ab7a6e7fb96
--- /dev/null
+++ b/src/test/python/pytest.ini
@@ -0,0 +1,4 @@
+[pytest]
+
+markers =
+    slow: mark test as slow
diff --git a/src/test/python/requirements.txt b/src/test/python/requirements.txt
new file mode 100644
index 00000000000..0dfcffb83e0
--- /dev/null
+++ b/src/test/python/requirements.txt
@@ -0,0 +1,11 @@
+black
+# cryptography 35.x and later add many platform/toolchain restrictions, beware
+cryptography~=3.4.8
+# TODO: figure out why 2.10.70 broke things
+# (probably https://github.com/construct/construct/pull/1015)
+construct==2.10.69
+isort~=5.6
+# TODO: update to psycopg[c] 3.1
+psycopg2~=2.9.7
+pytest~=7.3
+pytest-asyncio~=0.21.0
diff --git a/src/test/python/server/__init__.py b/src/test/python/server/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/src/test/python/server/conftest.py b/src/test/python/server/conftest.py
new file mode 100644
index 00000000000..42af80c73ee
--- /dev/null
+++ b/src/test/python/server/conftest.py
@@ -0,0 +1,141 @@
+#
+# Portions Copyright 2021 VMware, Inc.
+# Portions Copyright 2023 Timescale, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import collections
+import contextlib
+import os
+import shutil
+import socket
+import subprocess
+import sys
+
+import pytest
+
+import pq3
+
+BLOCKING_TIMEOUT = 2  # the number of seconds to wait for blocking calls
+
+
+def cleanup_prior_instance(datadir):
+    """
+    Clean up an existing data directory, but make sure it actually looks like a
+    data directory first. (Empty folders will remain untouched, since initdb can
+    populate them.)
+    """
+    required_entries = set(["base", "PG_VERSION", "postgresql.conf"])
+    empty = True
+
+    try:
+        with os.scandir(datadir) as entries:
+            for e in entries:
+                empty = False
+                required_entries.discard(e.name)
+
+    except FileNotFoundError:
+        return  # nothing to clean up
+
+    if empty:
+        return  # initdb can handle an empty datadir
+
+    if required_entries:
+        pytest.fail(
+            f"--temp-instance directory \"{datadir}\" is not empty and doesn't look like a data directory (missing {', '.join(required_entries)})"
+        )
+
+    # Okay, seems safe enough now.
+    shutil.rmtree(datadir)
+
+
[email protected](scope="session")
+def postgres_instance(pytestconfig, unused_tcp_port_factory):
+    """
+    If --temp-instance has been passed to pytest, this fixture runs a temporary
+    Postgres instance on an available port. Otherwise, the fixture will attempt
+    to contact a running Postgres server on (PGHOST, PGPORT); dependent tests
+    will be skipped if the connection fails.
+
+    Yields a (host, port) tuple for connecting to the server.
+    """
+    PGInstance = collections.namedtuple("PGInstance", ["addr", "temporary"])
+
+    datadir = pytestconfig.getoption("temp_instance")
+    if datadir:
+        # We were told to create a temporary instance. Use pg_ctl to set it up
+        # on an unused port.
+        cleanup_prior_instance(datadir)
+        subprocess.run(["pg_ctl", "-D", datadir, "init"], check=True)
+
+        # The CI looks for *.log files to upload, so the file name here isn't
+        # completely arbitrary.
+        log = os.path.join(datadir, "postmaster.log")
+        port = unused_tcp_port_factory()
+
+        subprocess.run(
+            [
+                "pg_ctl",
+                "-D",
+                datadir,
+                "-l",
+                log,
+                "-o",
+                " ".join(
+                    [
+                        f"-c port={port}",
+                        "-c listen_addresses=localhost",
+                        "-c log_connections=on",
+                        "-c session_preload_libraries=oauthtest",
+                        "-c oauth_validator_libraries=oauthtest",
+                    ]
+                ),
+                "start",
+            ],
+            check=True,
+        )
+
+        yield ("localhost", port)
+
+        subprocess.run(["pg_ctl", "-D", datadir, "stop"], check=True)
+
+    else:
+        # Try to contact an already running server; skip the suite if we can't
+        # find one.
+        addr = (pq3.pghost(), pq3.pgport())
+
+        try:
+            with socket.create_connection(addr, timeout=BLOCKING_TIMEOUT):
+                pass
+        except ConnectionError as e:
+            pytest.skip(f"unable to connect to Postgres server at {addr}: {e}")
+
+        yield addr
+
+
[email protected]
+def connect(postgres_instance):
+    """
+    A factory fixture that, when called, returns a socket connected to a
+    Postgres server, wrapped in a pq3 connection. Dependent tests will be
+    skipped if no server is available.
+    """
+    addr = postgres_instance
+
+    # Set up an ExitStack to handle safe cleanup of all of the moving pieces.
+    with contextlib.ExitStack() as stack:
+
+        def conn_factory():
+            sock = socket.create_connection(addr, timeout=BLOCKING_TIMEOUT)
+
+            # Have ExitStack close our socket.
+            stack.enter_context(sock)
+
+            # Wrap the connection in a pq3 layer and have ExitStack clean it up
+            # too.
+            wrap_ctx = pq3.wrap(sock, debug_stream=sys.stdout)
+            conn = stack.enter_context(wrap_ctx)
+
+            return conn
+
+        yield conn_factory
diff --git a/src/test/python/server/meson.build b/src/test/python/server/meson.build
new file mode 100644
index 00000000000..85534b9cc99
--- /dev/null
+++ b/src/test/python/server/meson.build
@@ -0,0 +1,18 @@
+# Copyright (c) 2024, PostgreSQL Global Development Group
+
+oauthtest_sources = files(
+  'oauthtest.c',
+)
+
+if host_system == 'windows'
+  oauthtest_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'oauthtest',
+    '--FILEDESC', 'passthrough module to validate OAuth tests',
+  ])
+endif
+
+oauthtest = shared_module('oauthtest',
+  oauthtest_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += oauthtest
diff --git a/src/test/python/server/oauthtest.c b/src/test/python/server/oauthtest.c
new file mode 100644
index 00000000000..415748b9a66
--- /dev/null
+++ b/src/test/python/server/oauthtest.c
@@ -0,0 +1,118 @@
+/*-------------------------------------------------------------------------
+ *
+ * oauthtest.c
+ *	  Test module for serverside OAuth token validation callbacks
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/test/python/server/oauthtest.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "fmgr.h"
+#include "libpq/oauth.h"
+#include "utils/guc.h"
+#include "utils/memutils.h"
+
+PG_MODULE_MAGIC;
+
+static void test_startup(ValidatorModuleState *state);
+static void test_shutdown(ValidatorModuleState *state);
+static ValidatorModuleResult *test_validate(ValidatorModuleState *state,
+											const char *token,
+											const char *role);
+
+static const OAuthValidatorCallbacks callbacks = {
+	.startup_cb = test_startup,
+	.shutdown_cb = test_shutdown,
+	.validate_cb = test_validate,
+};
+
+static char *expected_bearer = "";
+static bool set_authn_id = false;
+static char *authn_id = "";
+static bool reflect_role = false;
+
+void
+_PG_init(void)
+{
+	DefineCustomStringVariable("oauthtest.expected_bearer",
+							   "Expected Bearer token for future connections",
+							   NULL,
+							   &expected_bearer,
+							   "",
+							   PGC_SIGHUP,
+							   0,
+							   NULL, NULL, NULL);
+
+	DefineCustomBoolVariable("oauthtest.set_authn_id",
+							 "Whether to set an authenticated identity",
+							 NULL,
+							 &set_authn_id,
+							 false,
+							 PGC_SIGHUP,
+							 0,
+							 NULL, NULL, NULL);
+	DefineCustomStringVariable("oauthtest.authn_id",
+							   "Authenticated identity to use for future connections",
+							   NULL,
+							   &authn_id,
+							   "",
+							   PGC_SIGHUP,
+							   0,
+							   NULL, NULL, NULL);
+
+	DefineCustomBoolVariable("oauthtest.reflect_role",
+							 "Ignore the bearer token; use the requested role as the authn_id",
+							 NULL,
+							 &reflect_role,
+							 false,
+							 PGC_SIGHUP,
+							 0,
+							 NULL, NULL, NULL);
+
+	MarkGUCPrefixReserved("oauthtest");
+}
+
+const OAuthValidatorCallbacks *
+_PG_oauth_validator_module_init(void)
+{
+	return &callbacks;
+}
+
+static void
+test_startup(ValidatorModuleState *state)
+{
+}
+
+static void
+test_shutdown(ValidatorModuleState *state)
+{
+}
+
+static ValidatorModuleResult *
+test_validate(ValidatorModuleState *state, const char *token, const char *role)
+{
+	ValidatorModuleResult *res;
+
+	res = palloc0(sizeof(ValidatorModuleResult));	/* TODO: palloc context? */
+
+	if (reflect_role)
+	{
+		res->authorized = true;
+		res->authn_id = pstrdup(role);	/* TODO: constify? */
+	}
+	else
+	{
+		if (*expected_bearer && strcmp(token, expected_bearer) == 0)
+			res->authorized = true;
+		if (set_authn_id)
+			res->authn_id = authn_id;
+	}
+
+	return res;
+}
diff --git a/src/test/python/server/test_oauth.py b/src/test/python/server/test_oauth.py
new file mode 100644
index 00000000000..2839343ffa1
--- /dev/null
+++ b/src/test/python/server/test_oauth.py
@@ -0,0 +1,1080 @@
+#
+# Copyright 2021 VMware, Inc.
+# Portions Copyright 2023 Timescale, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import base64
+import contextlib
+import json
+import os
+import pathlib
+import platform
+import secrets
+import shlex
+import shutil
+import socket
+import struct
+from multiprocessing import shared_memory
+
+import psycopg2
+import pytest
+from construct import Container
+from psycopg2 import sql
+
+import pq3
+
+from .conftest import BLOCKING_TIMEOUT
+
+MAX_SASL_MESSAGE_LENGTH = 65535
+
+INVALID_AUTHORIZATION_ERRCODE = b"28000"
+PROTOCOL_VIOLATION_ERRCODE = b"08P01"
+FEATURE_NOT_SUPPORTED_ERRCODE = b"0A000"
+
+SHARED_MEM_NAME = "oauth-pytest"
+MAX_UINT16 = 2**16 - 1
+
+
[email protected]
+def prepend_file(path, lines, *, suffix=".bak"):
+    """
+    A context manager that prepends a file on disk with the desired lines of
+    text. When the context manager is exited, the file will be restored to its
+    original contents.
+    """
+    # First make a backup of the original file.
+    bak = path + suffix
+    shutil.copy2(path, bak)
+
+    try:
+        # Write the new lines, followed by the original file content.
+        with open(path, "w") as new, open(bak, "r") as orig:
+            new.writelines(lines)
+            shutil.copyfileobj(orig, new)
+
+        # Return control to the calling code.
+        yield
+
+    finally:
+        # Put the backup back into place.
+        os.replace(bak, path)
+
+
[email protected](scope="module")
+def oauth_ctx(postgres_instance):
+    """
+    Creates a database and user that use the oauth auth method. The context
+    object contains the dbname and user attributes as strings to be used during
+    connection, as well as the issuer and scope that have been set in the HBA
+    configuration.
+
+    This fixture assumes that the standard PG* environment variables point to a
+    server running on a local machine, and that the PGUSER has rights to create
+    databases and roles.
+    """
+    id = secrets.token_hex(4)
+
+    class Context:
+        dbname = "oauth_test_" + id
+
+        user = "oauth_user_" + id
+        punct_user = "oauth_\"'? ;&!_user_" + id  # username w/ punctuation
+        map_user = "oauth_map_user_" + id
+        authz_user = "oauth_authz_user_" + id
+
+        issuer = "https://example.com/" + id
+        scope = "openid " + id
+
+    ctx = Context()
+    hba_lines = [
+        f'host {ctx.dbname} {ctx.map_user}   samehost oauth issuer="{ctx.issuer}" scope="{ctx.scope}" map=oauth\n',
+        f'host {ctx.dbname} {ctx.authz_user} samehost oauth issuer="{ctx.issuer}" scope="{ctx.scope}" delegate_ident_mapping=1\n',
+        f'host {ctx.dbname} all              samehost oauth issuer="{ctx.issuer}" scope="{ctx.scope}"\n',
+    ]
+    ident_lines = [r"oauth /^(.*)@example\.com$ \1"]
+
+    if platform.system() == "Windows":
+        # XXX why is 'samehost' not behaving as expected on Windows?
+        for l in list(hba_lines):
+            hba_lines.append(l.replace("samehost", "::1/128"))
+
+    host, port = postgres_instance
+    conn = psycopg2.connect(host=host, port=port)
+    conn.autocommit = True
+
+    with contextlib.closing(conn):
+        c = conn.cursor()
+
+        # Create our roles and database.
+        user = sql.Identifier(ctx.user)
+        punct_user = sql.Identifier(ctx.punct_user)
+        map_user = sql.Identifier(ctx.map_user)
+        authz_user = sql.Identifier(ctx.authz_user)
+        dbname = sql.Identifier(ctx.dbname)
+
+        c.execute(sql.SQL("CREATE ROLE {} LOGIN;").format(user))
+        c.execute(sql.SQL("CREATE ROLE {} LOGIN;").format(punct_user))
+        c.execute(sql.SQL("CREATE ROLE {} LOGIN;").format(map_user))
+        c.execute(sql.SQL("CREATE ROLE {} LOGIN;").format(authz_user))
+        c.execute(sql.SQL("CREATE DATABASE {};").format(dbname))
+
+        # Replace pg_hba and pg_ident.
+        c.execute("SHOW hba_file;")
+        hba = c.fetchone()[0]
+
+        c.execute("SHOW ident_file;")
+        ident = c.fetchone()[0]
+
+        with prepend_file(hba, hba_lines), prepend_file(ident, ident_lines):
+            c.execute("SELECT pg_reload_conf();")
+
+            # Use the new database and user.
+            yield ctx
+
+        # Put things back the way they were.
+        c.execute("SELECT pg_reload_conf();")
+
+        c.execute(sql.SQL("DROP DATABASE {};").format(dbname))
+        c.execute(sql.SQL("DROP ROLE {};").format(authz_user))
+        c.execute(sql.SQL("DROP ROLE {};").format(map_user))
+        c.execute(sql.SQL("DROP ROLE {};").format(punct_user))
+        c.execute(sql.SQL("DROP ROLE {};").format(user))
+
+
[email protected]()
+def conn(oauth_ctx, connect):
+    """
+    A convenience wrapper for connect(). The main purpose of this fixture is to
+    make sure oauth_ctx runs its setup code before the connection is made.
+    """
+    return connect()
+
+
+def bearer_token(*, size=16):
+    """
+    Generates a Bearer token using secrets.token_urlsafe(). The generated token
+    size in bytes may be specified; if unset, a small 16-byte token will be
+    generated.
+    """
+
+    if size % 4:
+        raise ValueError(f"requested token size {size} is not a multiple of 4")
+
+    token = secrets.token_urlsafe(size // 4 * 3)
+    assert len(token) == size
+
+    return token
+
+
+def begin_oauth_handshake(conn, oauth_ctx, *, user=None):
+    if user is None:
+        user = oauth_ctx.authz_user
+
+    pq3.send_startup(conn, user=user, database=oauth_ctx.dbname)
+
+    resp = pq3.recv1(conn)
+    assert resp.type == pq3.types.AuthnRequest
+
+    # The server should advertise exactly one mechanism.
+    assert resp.payload.type == pq3.authn.SASL
+    assert resp.payload.body == [b"OAUTHBEARER", b""]
+
+
+def send_initial_response(conn, *, auth=None, bearer=None):
+    """
+    Sends the OAUTHBEARER initial response on the connection, using the given
+    bearer token. Alternatively to a bearer token, the initial response's auth
+    field may be explicitly specified to test corner cases.
+    """
+    if bearer is not None and auth is not None:
+        raise ValueError("exactly one of the auth and bearer kwargs must be set")
+
+    if bearer is not None:
+        auth = b"Bearer " + bearer
+
+    if auth is None:
+        raise ValueError("exactly one of the auth and bearer kwargs must be set")
+
+    initial = pq3.SASLInitialResponse.build(
+        dict(
+            name=b"OAUTHBEARER",
+            data=b"n,,\x01auth=" + auth + b"\x01\x01",
+        )
+    )
+    pq3.send(conn, pq3.types.PasswordMessage, initial)
+
+
+def expect_handshake_success(conn):
+    """
+    Validates that the server responds with an AuthnOK message, and then drains
+    the connection until a ReadyForQuery message is received.
+    """
+    resp = pq3.recv1(conn)
+
+    assert resp.type == pq3.types.AuthnRequest
+    assert resp.payload.type == pq3.authn.OK
+    assert not resp.payload.body
+
+    receive_until(conn, pq3.types.ReadyForQuery)
+
+
+def expect_handshake_failure(conn, oauth_ctx):
+    """
+    Performs the OAUTHBEARER SASL failure "handshake" and validates the server's
+    side of the conversation, including the final ErrorResponse.
+    """
+
+    # We expect a discovery "challenge" back from the server before the authn
+    # failure message.
+    resp = pq3.recv1(conn)
+    assert resp.type == pq3.types.AuthnRequest
+
+    req = resp.payload
+    assert req.type == pq3.authn.SASLContinue
+
+    body = json.loads(req.body)
+    assert body["status"] == "invalid_token"
+    assert body["scope"] == oauth_ctx.scope
+
+    expected_config = oauth_ctx.issuer + "/.well-known/openid-configuration"
+    assert body["openid-configuration"] == expected_config
+
+    # Send the dummy response to complete the failed handshake.
+    pq3.send(conn, pq3.types.PasswordMessage, b"\x01")
+    resp = pq3.recv1(conn)
+
+    err = ExpectedError(INVALID_AUTHORIZATION_ERRCODE, "bearer authentication failed")
+    err.match(resp)
+
+
+def receive_until(conn, type):
+    """
+    receive_until pulls packets off the pq3 connection until a packet with the
+    desired type is found, or an error response is received.
+    """
+    while True:
+        pkt = pq3.recv1(conn)
+
+        if pkt.type == type:
+            return pkt
+        elif pkt.type == pq3.types.ErrorResponse:
+            raise RuntimeError(
+                f"received error response from peer: {pkt.payload.fields!r}"
+            )
+
+
[email protected]()
+def setup_validator(postgres_instance):
+    """
+    A per-test fixture that sets up the test validator with expected behavior.
+    The setting will be reverted during teardown.
+    """
+    host, port = postgres_instance
+    conn = psycopg2.connect(host=host, port=port)
+    conn.autocommit = True
+
+    with contextlib.closing(conn):
+        c = conn.cursor()
+        prev = dict()
+
+        def setter(**gucs):
+            for guc, val in gucs.items():
+                # Save the previous value.
+                c.execute(sql.SQL("SHOW oauthtest.{};").format(sql.Identifier(guc)))
+                prev[guc] = c.fetchone()[0]
+
+                c.execute(
+                    sql.SQL("ALTER SYSTEM SET oauthtest.{} TO %s;").format(
+                        sql.Identifier(guc)
+                    ),
+                    (val,),
+                )
+                c.execute("SELECT pg_reload_conf();")
+
+        yield setter
+
+        # Restore the previous values.
+        for guc, val in prev.items():
+            c.execute(
+                sql.SQL("ALTER SYSTEM SET oauthtest.{} TO %s;").format(
+                    sql.Identifier(guc)
+                ),
+                (val,),
+            )
+            c.execute("SELECT pg_reload_conf();")
+
+
[email protected]("token_len", [16, 1024, 4096])
[email protected](
+    "auth_prefix",
+    [
+        b"Bearer ",
+        b"bearer ",
+        b"Bearer    ",
+    ],
+)
+def test_oauth(setup_validator, connect, oauth_ctx, auth_prefix, token_len):
+    # Generate our bearer token with the desired length.
+    token = bearer_token(size=token_len)
+    setup_validator(expected_bearer=token)
+
+    conn = connect()
+    begin_oauth_handshake(conn, oauth_ctx)
+
+    auth = auth_prefix + token.encode("ascii")
+    send_initial_response(conn, auth=auth)
+    expect_handshake_success(conn)
+
+    # Make sure that the server has not set an authenticated ID.
+    pq3.send(conn, pq3.types.Query, query=b"SELECT system_user;")
+    resp = receive_until(conn, pq3.types.DataRow)
+
+    row = resp.payload
+    assert row.columns == [None]
+
+
[email protected](
+    "token_value",
+    [
+        "abcdzA==",
+        "123456M=",
+        "x-._~+/x",
+    ],
+)
+def test_oauth_bearer_corner_cases(setup_validator, connect, oauth_ctx, token_value):
+    setup_validator(expected_bearer=token_value)
+
+    conn = connect()
+    begin_oauth_handshake(conn, oauth_ctx)
+
+    send_initial_response(conn, bearer=token_value.encode("ascii"))
+
+    expect_handshake_success(conn)
+
+
[email protected](
+    "user,authn_id,should_succeed",
+    [
+        pytest.param(
+            lambda ctx: ctx.user,
+            lambda ctx: ctx.user,
+            True,
+            id="validator authn: succeeds when authn_id == username",
+        ),
+        pytest.param(
+            lambda ctx: ctx.user,
+            lambda ctx: None,
+            False,
+            id="validator authn: fails when authn_id is not set",
+        ),
+        pytest.param(
+            lambda ctx: ctx.user,
+            lambda ctx: "",
+            False,
+            id="validator authn: fails when authn_id is empty",
+        ),
+        pytest.param(
+            lambda ctx: ctx.user,
+            lambda ctx: ctx.authz_user,
+            False,
+            id="validator authn: fails when authn_id != username",
+        ),
+        pytest.param(
+            lambda ctx: ctx.map_user,
+            lambda ctx: ctx.map_user + "@example.com",
+            True,
+            id="validator with map: succeeds when authn_id matches map",
+        ),
+        pytest.param(
+            lambda ctx: ctx.map_user,
+            lambda ctx: None,
+            False,
+            id="validator with map: fails when authn_id is not set",
+        ),
+        pytest.param(
+            lambda ctx: ctx.map_user,
+            lambda ctx: ctx.map_user + "@example.net",
+            False,
+            id="validator with map: fails when authn_id doesn't match map",
+        ),
+        pytest.param(
+            lambda ctx: ctx.authz_user,
+            lambda ctx: None,
+            True,
+            id="validator authz: succeeds with no authn_id",
+        ),
+        pytest.param(
+            lambda ctx: ctx.authz_user,
+            lambda ctx: "",
+            True,
+            id="validator authz: succeeds with empty authn_id",
+        ),
+        pytest.param(
+            lambda ctx: ctx.authz_user,
+            lambda ctx: "postgres",
+            True,
+            id="validator authz: succeeds with basic username",
+        ),
+        pytest.param(
+            lambda ctx: ctx.authz_user,
+            lambda ctx: "[email protected]",
+            True,
+            id="validator authz: succeeds with email address",
+        ),
+    ],
+)
+def test_oauth_authn_id(
+    setup_validator, connect, oauth_ctx, user, authn_id, should_succeed
+):
+    token = bearer_token()
+    authn_id = authn_id(oauth_ctx)
+
+    # Set up the validator appropriately.
+    gucs = dict(expected_bearer=token)
+    if authn_id is not None:
+        gucs["set_authn_id"] = True
+        gucs["authn_id"] = authn_id
+    setup_validator(**gucs)
+
+    conn = connect()
+    username = user(oauth_ctx)
+    begin_oauth_handshake(conn, oauth_ctx, user=username)
+    send_initial_response(conn, bearer=token.encode("ascii"))
+
+    if not should_succeed:
+        expect_handshake_failure(conn, oauth_ctx)
+        return
+
+    expect_handshake_success(conn)
+
+    # Check the reported authn_id.
+    pq3.send(conn, pq3.types.Query, query=b"SELECT system_user;")
+    resp = receive_until(conn, pq3.types.DataRow)
+
+    expected = authn_id
+    if expected is not None:
+        expected = b"oauth:" + expected.encode("ascii")
+
+    row = resp.payload
+    assert row.columns == [expected]
+
+
+class ExpectedError(object):
+    def __init__(self, code, msg=None, detail=None):
+        self.code = code
+        self.msg = msg
+        self.detail = detail
+
+        # Protect against the footgun of an accidental empty string, which will
+        # "match" anything. If you don't want to match message or detail, just
+        # don't pass them.
+        if self.msg == "":
+            raise ValueError("msg must be non-empty or None")
+        if self.detail == "":
+            raise ValueError("detail must be non-empty or None")
+
+    def _getfield(self, resp, type):
+        """
+        Searches an ErrorResponse for a single field of the given type (e.g.
+        "M", "C", "D") and returns its value. Asserts if it doesn't find exactly
+        one field.
+        """
+        prefix = type.encode("ascii")
+        fields = [f for f in resp.payload.fields if f.startswith(prefix)]
+
+        assert len(fields) == 1
+        return fields[0][1:]  # strip off the type byte
+
+    def match(self, resp):
+        """
+        Checks that the given response matches the expected code, message, and
+        detail (if given). The error code must match exactly. The expected
+        message and detail must be contained within the actual strings.
+        """
+        assert resp.type == pq3.types.ErrorResponse
+
+        code = self._getfield(resp, "C")
+        assert code == self.code
+
+        if self.msg:
+            msg = self._getfield(resp, "M")
+            expected = self.msg.encode("utf-8")
+            assert expected in msg
+
+        if self.detail:
+            detail = self._getfield(resp, "D")
+            expected = self.detail.encode("utf-8")
+            assert expected in detail
+
+
+def test_oauth_rejected_bearer(conn, oauth_ctx):
+    begin_oauth_handshake(conn, oauth_ctx)
+
+    # Send a bearer token that doesn't match what the validator expects. It
+    # should fail the connection.
+    send_initial_response(conn, bearer=b"xxxxxx")
+
+    expect_handshake_failure(conn, oauth_ctx)
+
+
[email protected](
+    "bad_bearer",
+    [
+        b"Bearer    ",
+        b"Bearer a===b",
+        b"Bearer hello!",
+        b"Bearer trailingspace ",
+        b"Bearer trailingtab\t",
+        b"Bearer [email protected]",
+        b"Beare abcd",
+        b" Bearer leadingspace",
+        b'OAuth realm="Example"',
+        b"",
+    ],
+)
+def test_oauth_invalid_bearer(setup_validator, connect, oauth_ctx, bad_bearer):
+    # Tell the validator to accept any token. This ensures that the invalid
+    # bearer tokens are rejected before the validation step.
+    setup_validator(reflect_role=True)
+
+    conn = connect()
+    begin_oauth_handshake(conn, oauth_ctx)
+    send_initial_response(conn, auth=bad_bearer)
+
+    expect_handshake_failure(conn, oauth_ctx)
+
+
[email protected]
[email protected](
+    "resp_type,resp,err",
+    [
+        pytest.param(
+            None,
+            None,
+            None,
+            marks=pytest.mark.slow,
+            id="no response (expect timeout)",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            b"hello",
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "did not send a kvsep response",
+            ),
+            id="bad dummy response",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            b"\x01\x01",
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "did not send a kvsep response",
+            ),
+            id="multiple kvseps",
+        ),
+        pytest.param(
+            pq3.types.Query,
+            dict(query=b""),
+            ExpectedError(PROTOCOL_VIOLATION_ERRCODE, "expected SASL response"),
+            id="bad response message type",
+        ),
+    ],
+)
+def test_oauth_bad_response_to_error_challenge(conn, oauth_ctx, resp_type, resp, err):
+    begin_oauth_handshake(conn, oauth_ctx)
+
+    # Send an empty auth initial response, which will force an authn failure.
+    send_initial_response(conn, auth=b"")
+
+    # We expect a discovery "challenge" back from the server before the authn
+    # failure message.
+    pkt = pq3.recv1(conn)
+    assert pkt.type == pq3.types.AuthnRequest
+
+    req = pkt.payload
+    assert req.type == pq3.authn.SASLContinue
+
+    body = json.loads(req.body)
+    assert body["status"] == "invalid_token"
+
+    if resp_type is None:
+        # Do not send the dummy response. We should time out and not get a
+        # response from the server.
+        with pytest.raises(socket.timeout):
+            conn.read(1)
+
+        # Done with the test.
+        return
+
+    # Send the bad response.
+    pq3.send(conn, resp_type, resp)
+
+    # Make sure the server fails the connection correctly.
+    pkt = pq3.recv1(conn)
+    err.match(pkt)
+
+
[email protected](
+    "type,payload,err",
+    [
+        pytest.param(
+            pq3.types.ErrorResponse,
+            dict(fields=[b""]),
+            ExpectedError(PROTOCOL_VIOLATION_ERRCODE, "expected SASL response"),
+            id="error response in initial message",
+        ),
+        pytest.param(
+            None,
+            # Sending an actual 65k packet results in ECONNRESET on Windows, and
+            # it floods the tests' connection log uselessly, so just fake the
+            # length and send a smaller number of bytes.
+            dict(
+                type=pq3.types.PasswordMessage,
+                len=MAX_SASL_MESSAGE_LENGTH + 1,
+                payload=b"x" * 512,
+            ),
+            ExpectedError(
+                INVALID_AUTHORIZATION_ERRCODE, "bearer authentication failed"
+            ),
+            id="overlong initial response data",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"SCRAM-SHA-256")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE, "invalid SASL authentication mechanism"
+            ),
+            id="bad SASL mechanism selection",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", len=2, data=b"x")),
+            ExpectedError(PROTOCOL_VIOLATION_ERRCODE, "insufficient data"),
+            id="SASL data underflow",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", len=0, data=b"x")),
+            ExpectedError(PROTOCOL_VIOLATION_ERRCODE, "invalid message format"),
+            id="SASL data overflow",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "message is empty",
+            ),
+            id="empty",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(name=b"OAUTHBEARER", data=b"n,,\x01auth=\x01\x01\0")
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "length does not match input length",
+            ),
+            id="contains null byte",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"\x01")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "Unexpected channel-binding flag",  # XXX this is a bit strange
+            ),
+            id="initial error response",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(name=b"OAUTHBEARER", data=b"p=tls-server-end-point,,\x01")
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "server does not support channel binding",
+            ),
+            id="uses channel binding",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"x,,\x01")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "Unexpected channel-binding flag",
+            ),
+            id="invalid channel binding specifier",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"y")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "Comma expected",
+            ),
+            id="bad GS2 header: missing channel binding terminator",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"y,a")),
+            ExpectedError(
+                FEATURE_NOT_SUPPORTED_ERRCODE,
+                "client uses authorization identity",
+            ),
+            id="bad GS2 header: authzid in use",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"y,b,")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "Unexpected attribute",
+            ),
+            id="bad GS2 header: extra attribute",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"y,")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                'Unexpected attribute "0x00"',  # XXX this is a bit strange
+            ),
+            id="bad GS2 header: missing authzid terminator",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"y,,")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "Key-value separator expected",
+            ),
+            id="missing initial kvsep",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"y,,")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "Key-value separator expected",
+            ),
+            id="missing initial kvsep",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(name=b"OAUTHBEARER", data=b"y,,\x01\x01")
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "does not contain an auth value",
+            ),
+            id="missing auth value: empty key-value list",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(name=b"OAUTHBEARER", data=b"y,,\x01host=example.com\x01\x01")
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "does not contain an auth value",
+            ),
+            id="missing auth value: other keys present",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(name=b"OAUTHBEARER", data=b"y,,\x01host=example.com")
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "unterminated key/value pair",
+            ),
+            id="missing value terminator",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"y,,\x01")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "did not contain a final terminator",
+            ),
+            id="missing list terminator: empty list",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(name=b"OAUTHBEARER", data=b"y,,\x01auth=Bearer 0\x01")
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "did not contain a final terminator",
+            ),
+            id="missing list terminator: with auth value",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(name=b"OAUTHBEARER", data=b"y,,\x01auth=Bearer 0\x01\x01blah")
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "additional data after the final terminator",
+            ),
+            id="additional key after terminator",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(name=b"OAUTHBEARER", data=b"y,,\x01key\x01\x01")
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "key without a value",
+            ),
+            id="key without value",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(
+                    name=b"OAUTHBEARER",
+                    data=b"y,,\x01auth=Bearer 0\x01auth=Bearer 1\x01\x01",
+                )
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "contains multiple auth values",
+            ),
+            id="multiple auth values",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(
+                    name=b"OAUTHBEARER",
+                    data=b"y,,\x01=\x01\x01",
+                )
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "empty key name",
+            ),
+            id="empty key",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(
+                    name=b"OAUTHBEARER",
+                    data=b"y,,\x01my key= \x01\x01",
+                )
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "invalid key name",
+            ),
+            id="whitespace in key name",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(
+                    name=b"OAUTHBEARER",
+                    data=b"y,,\x01key=a\x05b\x01\x01",
+                )
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "invalid value",
+            ),
+            id="junk in value",
+        ),
+    ],
+)
+def test_oauth_bad_initial_response(conn, oauth_ctx, type, payload, err):
+    begin_oauth_handshake(conn, oauth_ctx)
+
+    # The server expects a SASL response; give it something else instead.
+    if type is not None:
+        # Build a new packet of the desired type.
+        if not isinstance(payload, dict):
+            payload = dict(payload_data=payload)
+        pq3.send(conn, type, **payload)
+    else:
+        # The test has a custom packet to send. (The only reason to do this is
+        # if the packet is corrupt or otherwise unbuildable/unparsable, so we
+        # don't use the standard pq3.send().)
+        conn.write(pq3.Pq3.build(payload))
+        conn.end_packet(Container(payload))
+
+    resp = pq3.recv1(conn)
+    err.match(resp)
+
+
+def test_oauth_empty_initial_response(setup_validator, connect, oauth_ctx):
+    token = bearer_token()
+    setup_validator(expected_bearer=token)
+
+    conn = connect()
+    begin_oauth_handshake(conn, oauth_ctx)
+
+    # Send an initial response without data.
+    initial = pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER"))
+    pq3.send(conn, pq3.types.PasswordMessage, initial)
+
+    # The server should respond with an empty challenge so we can send the data
+    # it wants.
+    pkt = pq3.recv1(conn)
+
+    assert pkt.type == pq3.types.AuthnRequest
+    assert pkt.payload.type == pq3.authn.SASLContinue
+    assert not pkt.payload.body
+
+    # Now send the initial data.
+    data = b"n,,\x01auth=Bearer " + token.encode("ascii") + b"\x01\x01"
+    pq3.send(conn, pq3.types.PasswordMessage, data)
+
+    # Server should now complete the handshake.
+    expect_handshake_success(conn)
+
+
+# TODO: see if there's a way to test this easily after the API switch
+def xtest_oauth_no_validator(setup_validator, oauth_ctx, connect):
+    # Clear out our validator command, then establish a new connection.
+    set_validator("")
+    conn = connect()
+
+    begin_oauth_handshake(conn, oauth_ctx)
+    send_initial_response(conn, bearer=bearer_token())
+
+    # The server should fail the connection.
+    expect_handshake_failure(conn, oauth_ctx)
+
+
[email protected](
+    "user",
+    [
+        pytest.param(
+            lambda ctx: ctx.user,
+            id="basic username",
+        ),
+        pytest.param(
+            lambda ctx: ctx.punct_user,
+            id="'unsafe' characters are passed through correctly",
+        ),
+    ],
+)
+def test_oauth_validator_role(setup_validator, oauth_ctx, connect, user):
+    username = user(oauth_ctx)
+
+    # Tell the validator to reflect the PGUSER as the authenticated identity.
+    setup_validator(reflect_role=True)
+    conn = connect()
+
+    # Log in. Note that reflection ignores the bearer token.
+    begin_oauth_handshake(conn, oauth_ctx, user=username)
+    send_initial_response(conn, bearer=b"dontcare")
+    expect_handshake_success(conn)
+
+    # Check the user identity.
+    pq3.send(conn, pq3.types.Query, query=b"SELECT system_user;")
+    resp = receive_until(conn, pq3.types.DataRow)
+
+    row = resp.payload
+    expected = b"oauth:" + username.encode("utf-8")
+    assert row.columns == [expected]
+
+
[email protected]
+def odd_oauth_ctx(postgres_instance, oauth_ctx):
+    """
+    Adds an HBA entry with messed up issuer/scope settings, to pin the server
+    behavior.
+
+    TODO: these should really be rejected in the HBA rather than passed through
+    by the server.
+    """
+    id = secrets.token_hex(4)
+
+    class Context:
+        user = oauth_ctx.user
+        dbname = oauth_ctx.dbname
+
+        # Both of these embedded double-quotes are invalid; they're prohibited
+        # in both URLs and OAuth scope identifiers.
+        issuer = oauth_ctx.issuer + '/"/'
+        scope = oauth_ctx.scope + ' quo"ted'
+
+    ctx = Context()
+    hba_issuer = ctx.issuer.replace('"', '""')
+    hba_scope = ctx.scope.replace('"', '""')
+    hba_lines = [
+        f'host {ctx.dbname} {ctx.user} samehost oauth issuer="{hba_issuer}" scope="{hba_scope}"\n',
+    ]
+
+    if platform.system() == "Windows":
+        # XXX why is 'samehost' not behaving as expected on Windows?
+        for l in list(hba_lines):
+            hba_lines.append(l.replace("samehost", "::1/128"))
+
+    host, port = postgres_instance
+    conn = psycopg2.connect(host=host, port=port)
+    conn.autocommit = True
+
+    with contextlib.closing(conn):
+        c = conn.cursor()
+
+        # Replace pg_hba. Note that it's already been replaced once by
+        # oauth_ctx, so use a different backup prefix in prepend_file().
+        c.execute("SHOW hba_file;")
+        hba = c.fetchone()[0]
+
+        with prepend_file(hba, hba_lines, suffix=".bak2"):
+            c.execute("SELECT pg_reload_conf();")
+
+            yield ctx
+
+        # Put things back the way they were.
+        c.execute("SELECT pg_reload_conf();")
+
+
+def test_odd_server_response(odd_oauth_ctx, connect):
+    """
+    Verifies that the server is correctly escaping the JSON in its failure
+    response.
+    """
+    conn = connect()
+    begin_oauth_handshake(conn, odd_oauth_ctx, user=odd_oauth_ctx.user)
+
+    # Send an empty auth initial response, which will force an authn failure.
+    send_initial_response(conn, auth=b"")
+
+    expect_handshake_failure(conn, odd_oauth_ctx)
diff --git a/src/test/python/server/test_server.py b/src/test/python/server/test_server.py
new file mode 100644
index 00000000000..02126dba792
--- /dev/null
+++ b/src/test/python/server/test_server.py
@@ -0,0 +1,21 @@
+#
+# Copyright 2021 VMware, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import pq3
+
+
+def test_handshake(connect):
+    """Basic sanity check."""
+    conn = connect()
+
+    pq3.handshake(conn, user=pq3.pguser(), database=pq3.pgdatabase())
+
+    pq3.send(conn, pq3.types.Query, query=b"")
+
+    resp = pq3.recv1(conn)
+    assert resp.type == pq3.types.EmptyQueryResponse
+
+    resp = pq3.recv1(conn)
+    assert resp.type == pq3.types.ReadyForQuery
diff --git a/src/test/python/test_internals.py b/src/test/python/test_internals.py
new file mode 100644
index 00000000000..dee4855fc0b
--- /dev/null
+++ b/src/test/python/test_internals.py
@@ -0,0 +1,138 @@
+#
+# Copyright 2021 VMware, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import io
+
+from pq3 import _DebugStream
+
+
+def test_DebugStream_read():
+    under = io.BytesIO(b"abcdefghijklmnopqrstuvwxyz")
+    out = io.StringIO()
+
+    stream = _DebugStream(under, out)
+
+    res = stream.read(5)
+    assert res == b"abcde"
+
+    res = stream.read(16)
+    assert res == b"fghijklmnopqrstu"
+
+    stream.flush_debug()
+
+    res = stream.read()
+    assert res == b"vwxyz"
+
+    stream.flush_debug()
+
+    expected = (
+        "< 0000:\t61 62 63 64 65 66 67 68 69 6a 6b 6c 6d 6e 6f 70\tabcdefghijklmnop\n"
+        "< 0010:\t71 72 73 74 75                                 \tqrstu\n"
+        "\n"
+        "< 0000:\t76 77 78 79 7a                                 \tvwxyz\n"
+        "\n"
+    )
+    assert out.getvalue() == expected
+
+
+def test_DebugStream_write():
+    under = io.BytesIO()
+    out = io.StringIO()
+
+    stream = _DebugStream(under, out)
+
+    stream.write(b"\x00\x01\x02")
+    stream.flush()
+
+    assert under.getvalue() == b"\x00\x01\x02"
+
+    stream.write(b"\xc0\xc1\xc2")
+    stream.flush()
+
+    assert under.getvalue() == b"\x00\x01\x02\xc0\xc1\xc2"
+
+    stream.flush_debug()
+
+    expected = "> 0000:\t00 01 02 c0 c1 c2                              \t......\n\n"
+    assert out.getvalue() == expected
+
+
+def test_DebugStream_read_write():
+    under = io.BytesIO(b"abcdefghijklmnopqrstuvwxyz")
+    out = io.StringIO()
+    stream = _DebugStream(under, out)
+
+    res = stream.read(5)
+    assert res == b"abcde"
+
+    stream.write(b"xxxxx")
+    stream.flush()
+
+    assert under.getvalue() == b"abcdexxxxxklmnopqrstuvwxyz"
+
+    res = stream.read(5)
+    assert res == b"klmno"
+
+    stream.write(b"xxxxx")
+    stream.flush()
+
+    assert under.getvalue() == b"abcdexxxxxklmnoxxxxxuvwxyz"
+
+    stream.flush_debug()
+
+    expected = (
+        "< 0000:\t61 62 63 64 65 6b 6c 6d 6e 6f                  \tabcdeklmno\n"
+        "\n"
+        "> 0000:\t78 78 78 78 78 78 78 78 78 78                  \txxxxxxxxxx\n"
+        "\n"
+    )
+    assert out.getvalue() == expected
+
+
+def test_DebugStream_end_packet():
+    under = io.BytesIO(b"abcdefghijklmnopqrstuvwxyz")
+    out = io.StringIO()
+    stream = _DebugStream(under, out)
+
+    stream.read(5)
+    stream.end_packet("read description", read=True, indent=" ")
+
+    stream.write(b"xxxxx")
+    stream.flush()
+    stream.end_packet("write description", indent=" ")
+
+    stream.read(5)
+    stream.write(b"xxxxx")
+    stream.flush()
+    stream.end_packet("read/write combo for read", read=True, indent=" ")
+
+    stream.read(5)
+    stream.write(b"xxxxx")
+    stream.flush()
+    stream.end_packet("read/write combo for write", indent=" ")
+
+    expected = (
+        " < 0000:\t61 62 63 64 65                                 \tabcde\n"
+        "\n"
+        "< read description\n"
+        "\n"
+        "> write description\n"
+        "\n"
+        " > 0000:\t78 78 78 78 78                                 \txxxxx\n"
+        "\n"
+        " < 0000:\t6b 6c 6d 6e 6f                                 \tklmno\n"
+        "\n"
+        " > 0000:\t78 78 78 78 78                                 \txxxxx\n"
+        "\n"
+        "< read/write combo for read\n"
+        "\n"
+        "> read/write combo for write\n"
+        "\n"
+        " < 0000:\t75 76 77 78 79                                 \tuvwxy\n"
+        "\n"
+        " > 0000:\t78 78 78 78 78                                 \txxxxx\n"
+        "\n"
+    )
+    assert out.getvalue() == expected
diff --git a/src/test/python/test_pq3.py b/src/test/python/test_pq3.py
new file mode 100644
index 00000000000..7c6817de31c
--- /dev/null
+++ b/src/test/python/test_pq3.py
@@ -0,0 +1,574 @@
+#
+# Copyright 2021 VMware, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import contextlib
+import getpass
+import io
+import os
+import platform
+import struct
+import sys
+
+import pytest
+from construct import Container, PaddingError, StreamError, TerminatedError
+
+import pq3
+
+
[email protected](
+    "raw,expected,extra",
+    [
+        pytest.param(
+            b"\x00\x00\x00\x10\x00\x04\x00\x00abcdefgh",
+            Container(len=16, proto=0x40000, payload=b"abcdefgh"),
+            b"",
+            id="8-byte payload",
+        ),
+        pytest.param(
+            b"\x00\x00\x00\x08\x00\x04\x00\x00",
+            Container(len=8, proto=0x40000, payload=b""),
+            b"",
+            id="no payload",
+        ),
+        pytest.param(
+            b"\x00\x00\x00\x09\x00\x04\x00\x00abcde",
+            Container(len=9, proto=0x40000, payload=b"a"),
+            b"bcde",
+            id="1-byte payload and extra padding",
+        ),
+        pytest.param(
+            b"\x00\x00\x00\x0B\x00\x03\x00\x00hi\x00",
+            Container(len=11, proto=pq3.protocol(3, 0), payload=[b"hi"]),
+            b"",
+            id="implied parameter list when using proto version 3.0",
+        ),
+    ],
+)
+def test_Startup_parse(raw, expected, extra):
+    with io.BytesIO(raw) as stream:
+        actual = pq3.Startup.parse_stream(stream)
+
+        assert actual == expected
+        assert stream.read() == extra
+
+
[email protected](
+    "packet,expected_bytes",
+    [
+        pytest.param(
+            dict(),
+            b"\x00\x00\x00\x08\x00\x00\x00\x00",
+            id="nothing set",
+        ),
+        pytest.param(
+            dict(len=10, proto=0x12345678),
+            b"\x00\x00\x00\x0A\x12\x34\x56\x78\x00\x00",
+            id="len and proto set explicitly",
+        ),
+        pytest.param(
+            dict(proto=0x12345678),
+            b"\x00\x00\x00\x08\x12\x34\x56\x78",
+            id="implied len with no payload",
+        ),
+        pytest.param(
+            dict(proto=0x12345678, payload=b"abcd"),
+            b"\x00\x00\x00\x0C\x12\x34\x56\x78abcd",
+            id="implied len with payload",
+        ),
+        pytest.param(
+            dict(payload=[b""]),
+            b"\x00\x00\x00\x09\x00\x03\x00\x00\x00",
+            id="implied proto version 3 when sending parameters",
+        ),
+        pytest.param(
+            dict(payload=[b"hi", b""]),
+            b"\x00\x00\x00\x0C\x00\x03\x00\x00hi\x00\x00",
+            id="implied proto version 3 and len when sending more than one parameter",
+        ),
+        pytest.param(
+            dict(payload=dict(user="jsmith", database="postgres")),
+            b"\x00\x00\x00\x27\x00\x03\x00\x00user\x00jsmith\x00database\x00postgres\x00\x00",
+            id="auto-serialization of dict parameters",
+        ),
+    ],
+)
+def test_Startup_build(packet, expected_bytes):
+    actual = pq3.Startup.build(packet)
+    assert actual == expected_bytes
+
+
[email protected](
+    "raw,expected,extra",
+    [
+        pytest.param(
+            b"*\x00\x00\x00\x08abcd",
+            dict(type=b"*", len=8, payload=b"abcd"),
+            b"",
+            id="4-byte payload",
+        ),
+        pytest.param(
+            b"*\x00\x00\x00\x04",
+            dict(type=b"*", len=4, payload=b""),
+            b"",
+            id="no payload",
+        ),
+        pytest.param(
+            b"*\x00\x00\x00\x05xabcd",
+            dict(type=b"*", len=5, payload=b"x"),
+            b"abcd",
+            id="1-byte payload with extra padding",
+        ),
+        pytest.param(
+            b"R\x00\x00\x00\x08\x00\x00\x00\x00",
+            dict(
+                type=pq3.types.AuthnRequest,
+                len=8,
+                payload=dict(type=pq3.authn.OK, body=None),
+            ),
+            b"",
+            id="AuthenticationOk",
+        ),
+        pytest.param(
+            b"R\x00\x00\x00\x12\x00\x00\x00\x0AEXTERNAL\x00\x00",
+            dict(
+                type=pq3.types.AuthnRequest,
+                len=18,
+                payload=dict(type=pq3.authn.SASL, body=[b"EXTERNAL", b""]),
+            ),
+            b"",
+            id="AuthenticationSASL",
+        ),
+        pytest.param(
+            b"R\x00\x00\x00\x0D\x00\x00\x00\x0B12345",
+            dict(
+                type=pq3.types.AuthnRequest,
+                len=13,
+                payload=dict(type=pq3.authn.SASLContinue, body=b"12345"),
+            ),
+            b"",
+            id="AuthenticationSASLContinue",
+        ),
+        pytest.param(
+            b"R\x00\x00\x00\x0D\x00\x00\x00\x0C12345",
+            dict(
+                type=pq3.types.AuthnRequest,
+                len=13,
+                payload=dict(type=pq3.authn.SASLFinal, body=b"12345"),
+            ),
+            b"",
+            id="AuthenticationSASLFinal",
+        ),
+        pytest.param(
+            b"p\x00\x00\x00\x0Bhunter2",
+            dict(
+                type=pq3.types.PasswordMessage,
+                len=11,
+                payload=b"hunter2",
+            ),
+            b"",
+            id="PasswordMessage",
+        ),
+        pytest.param(
+            b"K\x00\x00\x00\x0C\x00\x00\x00\x00\x12\x34\x56\x78",
+            dict(
+                type=pq3.types.BackendKeyData,
+                len=12,
+                payload=dict(pid=0, key=0x12345678),
+            ),
+            b"",
+            id="BackendKeyData",
+        ),
+        pytest.param(
+            b"C\x00\x00\x00\x08SET\x00",
+            dict(
+                type=pq3.types.CommandComplete,
+                len=8,
+                payload=dict(tag=b"SET"),
+            ),
+            b"",
+            id="CommandComplete",
+        ),
+        pytest.param(
+            b"E\x00\x00\x00\x11Mbad!\x00Mdog!\x00\x00",
+            dict(type=b"E", len=17, payload=dict(fields=[b"Mbad!", b"Mdog!", b""])),
+            b"",
+            id="ErrorResponse",
+        ),
+        pytest.param(
+            b"S\x00\x00\x00\x08a\x00b\x00",
+            dict(
+                type=pq3.types.ParameterStatus,
+                len=8,
+                payload=dict(name=b"a", value=b"b"),
+            ),
+            b"",
+            id="ParameterStatus",
+        ),
+        pytest.param(
+            b"Z\x00\x00\x00\x05x",
+            dict(type=b"Z", len=5, payload=dict(status=b"x")),
+            b"",
+            id="ReadyForQuery",
+        ),
+        pytest.param(
+            b"Q\x00\x00\x00\x06!\x00",
+            dict(type=pq3.types.Query, len=6, payload=dict(query=b"!")),
+            b"",
+            id="Query",
+        ),
+        pytest.param(
+            b"D\x00\x00\x00\x0B\x00\x01\x00\x00\x00\x01!",
+            dict(type=pq3.types.DataRow, len=11, payload=dict(columns=[b"!"])),
+            b"",
+            id="DataRow",
+        ),
+        pytest.param(
+            b"D\x00\x00\x00\x06\x00\x00extra",
+            dict(type=pq3.types.DataRow, len=6, payload=dict(columns=[])),
+            b"extra",
+            id="DataRow with extra data",
+        ),
+        pytest.param(
+            b"I\x00\x00\x00\x04",
+            dict(type=pq3.types.EmptyQueryResponse, len=4, payload=None),
+            b"",
+            id="EmptyQueryResponse",
+        ),
+        pytest.param(
+            b"I\x00\x00\x00\x04\xFF",
+            dict(type=b"I", len=4, payload=None),
+            b"\xFF",
+            id="EmptyQueryResponse with extra bytes",
+        ),
+        pytest.param(
+            b"X\x00\x00\x00\x04",
+            dict(type=pq3.types.Terminate, len=4, payload=None),
+            b"",
+            id="Terminate",
+        ),
+    ],
+)
+def test_Pq3_parse(raw, expected, extra):
+    with io.BytesIO(raw) as stream:
+        actual = pq3.Pq3.parse_stream(stream)
+
+        assert actual == expected
+        assert stream.read() == extra
+
+
[email protected](
+    "fields,expected",
+    [
+        pytest.param(
+            dict(type=b"*", len=5),
+            b"*\x00\x00\x00\x05",
+            id="type and len set explicitly",
+        ),
+        pytest.param(
+            dict(type=b"*"),
+            b"*\x00\x00\x00\x04",
+            id="implied len with no payload",
+        ),
+        pytest.param(
+            dict(type=b"*", payload=b"1234"),
+            b"*\x00\x00\x00\x081234",
+            id="implied len with payload",
+        ),
+        pytest.param(
+            dict(type=b"*", len=12, payload=b"1234"),
+            b"*\x00\x00\x00\x0C1234",
+            id="overridden len (payload underflow)",
+        ),
+        pytest.param(
+            dict(type=b"*", len=5, payload=b"1234"),
+            b"*\x00\x00\x00\x051234",
+            id="overridden len (payload overflow)",
+        ),
+        pytest.param(
+            dict(type=pq3.types.AuthnRequest, payload=dict(type=pq3.authn.OK)),
+            b"R\x00\x00\x00\x08\x00\x00\x00\x00",
+            id="implied len/type for AuthenticationOK",
+        ),
+        pytest.param(
+            dict(
+                type=pq3.types.AuthnRequest,
+                payload=dict(
+                    type=pq3.authn.SASL,
+                    body=[b"SCRAM-SHA-256-PLUS", b"SCRAM-SHA-256", b""],
+                ),
+            ),
+            b"R\x00\x00\x00\x2A\x00\x00\x00\x0ASCRAM-SHA-256-PLUS\x00SCRAM-SHA-256\x00\x00",
+            id="implied len/type for AuthenticationSASL",
+        ),
+        pytest.param(
+            dict(
+                type=pq3.types.AuthnRequest,
+                payload=dict(type=pq3.authn.SASLContinue, body=b"12345"),
+            ),
+            b"R\x00\x00\x00\x0D\x00\x00\x00\x0B12345",
+            id="implied len/type for AuthenticationSASLContinue",
+        ),
+        pytest.param(
+            dict(
+                type=pq3.types.AuthnRequest,
+                payload=dict(type=pq3.authn.SASLFinal, body=b"12345"),
+            ),
+            b"R\x00\x00\x00\x0D\x00\x00\x00\x0C12345",
+            id="implied len/type for AuthenticationSASLFinal",
+        ),
+        pytest.param(
+            dict(
+                type=pq3.types.PasswordMessage,
+                payload=b"hunter2",
+            ),
+            b"p\x00\x00\x00\x0Bhunter2",
+            id="implied len/type for PasswordMessage",
+        ),
+        pytest.param(
+            dict(type=pq3.types.BackendKeyData, payload=dict(pid=1, key=7)),
+            b"K\x00\x00\x00\x0C\x00\x00\x00\x01\x00\x00\x00\x07",
+            id="implied len/type for BackendKeyData",
+        ),
+        pytest.param(
+            dict(type=pq3.types.CommandComplete, payload=dict(tag=b"SET")),
+            b"C\x00\x00\x00\x08SET\x00",
+            id="implied len/type for CommandComplete",
+        ),
+        pytest.param(
+            dict(type=pq3.types.ErrorResponse, payload=dict(fields=[b"error", b""])),
+            b"E\x00\x00\x00\x0Berror\x00\x00",
+            id="implied len/type for ErrorResponse",
+        ),
+        pytest.param(
+            dict(type=pq3.types.ParameterStatus, payload=dict(name=b"a", value=b"b")),
+            b"S\x00\x00\x00\x08a\x00b\x00",
+            id="implied len/type for ParameterStatus",
+        ),
+        pytest.param(
+            dict(type=pq3.types.ReadyForQuery, payload=dict(status=b"I")),
+            b"Z\x00\x00\x00\x05I",
+            id="implied len/type for ReadyForQuery",
+        ),
+        pytest.param(
+            dict(type=pq3.types.Query, payload=dict(query=b"SELECT 1;")),
+            b"Q\x00\x00\x00\x0eSELECT 1;\x00",
+            id="implied len/type for Query",
+        ),
+        pytest.param(
+            dict(type=pq3.types.DataRow, payload=dict(columns=[b"abcd"])),
+            b"D\x00\x00\x00\x0E\x00\x01\x00\x00\x00\x04abcd",
+            id="implied len/type for DataRow",
+        ),
+        pytest.param(
+            dict(type=pq3.types.EmptyQueryResponse),
+            b"I\x00\x00\x00\x04",
+            id="implied len for EmptyQueryResponse",
+        ),
+        pytest.param(
+            dict(type=pq3.types.Terminate),
+            b"X\x00\x00\x00\x04",
+            id="implied len for Terminate",
+        ),
+    ],
+)
+def test_Pq3_build(fields, expected):
+    actual = pq3.Pq3.build(fields)
+    assert actual == expected
+
+
[email protected](
+    "raw,expected,extra",
+    [
+        pytest.param(
+            b"\x00\x00",
+            dict(columns=[]),
+            b"",
+            id="no columns",
+        ),
+        pytest.param(
+            b"\x00\x01\x00\x00\x00\x04abcd",
+            dict(columns=[b"abcd"]),
+            b"",
+            id="one column",
+        ),
+        pytest.param(
+            b"\x00\x02\x00\x00\x00\x04abcd\x00\x00\x00\x01x",
+            dict(columns=[b"abcd", b"x"]),
+            b"",
+            id="multiple columns",
+        ),
+        pytest.param(
+            b"\x00\x02\x00\x00\x00\x00\x00\x00\x00\x01x",
+            dict(columns=[b"", b"x"]),
+            b"",
+            id="empty column value",
+        ),
+        pytest.param(
+            b"\x00\x02\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF",
+            dict(columns=[None, None]),
+            b"",
+            id="null columns",
+        ),
+    ],
+)
+def test_DataRow_parse(raw, expected, extra):
+    pkt = b"D" + struct.pack("!i", len(raw) + 4) + raw
+    with io.BytesIO(pkt) as stream:
+        actual = pq3.Pq3.parse_stream(stream)
+
+        assert actual.type == pq3.types.DataRow
+        assert actual.payload == expected
+        assert stream.read() == extra
+
+
[email protected](
+    "fields,expected",
+    [
+        pytest.param(
+            dict(),
+            b"\x00\x00",
+            id="no columns",
+        ),
+        pytest.param(
+            dict(columns=[None, None]),
+            b"\x00\x02\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF",
+            id="null columns",
+        ),
+    ],
+)
+def test_DataRow_build(fields, expected):
+    actual = pq3.Pq3.build(dict(type=pq3.types.DataRow, payload=fields))
+
+    expected = b"D" + struct.pack("!i", len(expected) + 4) + expected
+    assert actual == expected
+
+
[email protected](
+    "raw,expected,exception",
+    [
+        pytest.param(
+            b"EXTERNAL\x00\xFF\xFF\xFF\xFF",
+            dict(name=b"EXTERNAL", len=-1, data=None),
+            None,
+            id="no initial response",
+        ),
+        pytest.param(
+            b"EXTERNAL\x00\x00\x00\x00\x02me",
+            dict(name=b"EXTERNAL", len=2, data=b"me"),
+            None,
+            id="initial response",
+        ),
+        pytest.param(
+            b"EXTERNAL\x00\x00\x00\x00\x02meextra",
+            None,
+            TerminatedError,
+            id="extra data",
+        ),
+        pytest.param(
+            b"EXTERNAL\x00\x00\x00\x00\xFFme",
+            None,
+            StreamError,
+            id="underflow",
+        ),
+    ],
+)
+def test_SASLInitialResponse_parse(raw, expected, exception):
+    ctx = contextlib.nullcontext()
+    if exception:
+        ctx = pytest.raises(exception)
+
+    with ctx:
+        actual = pq3.SASLInitialResponse.parse(raw)
+        assert actual == expected
+
+
[email protected](
+    "fields,expected",
+    [
+        pytest.param(
+            dict(name=b"EXTERNAL"),
+            b"EXTERNAL\x00\xFF\xFF\xFF\xFF",
+            id="no initial response",
+        ),
+        pytest.param(
+            dict(name=b"EXTERNAL", data=None),
+            b"EXTERNAL\x00\xFF\xFF\xFF\xFF",
+            id="no initial response (explicit None)",
+        ),
+        pytest.param(
+            dict(name=b"EXTERNAL", data=b""),
+            b"EXTERNAL\x00\x00\x00\x00\x00",
+            id="empty response",
+        ),
+        pytest.param(
+            dict(name=b"EXTERNAL", data=b"[email protected]"),
+            b"EXTERNAL\x00\x00\x00\x00\[email protected]",
+            id="initial response",
+        ),
+        pytest.param(
+            dict(name=b"EXTERNAL", len=2, data=b"[email protected]"),
+            b"EXTERNAL\x00\x00\x00\x00\[email protected]",
+            id="data overflow",
+        ),
+        pytest.param(
+            dict(name=b"EXTERNAL", len=14, data=b"me"),
+            b"EXTERNAL\x00\x00\x00\x00\x0Eme",
+            id="data underflow",
+        ),
+    ],
+)
+def test_SASLInitialResponse_build(fields, expected):
+    actual = pq3.SASLInitialResponse.build(fields)
+    assert actual == expected
+
+
[email protected](
+    "version,expected_bytes",
+    [
+        pytest.param((3, 0), b"\x00\x03\x00\x00", id="version 3"),
+        pytest.param((1234, 5679), b"\x04\xd2\x16\x2f", id="SSLRequest"),
+    ],
+)
+def test_protocol(version, expected_bytes):
+    # Make sure the integer returned by protocol is correctly serialized on the
+    # wire.
+    assert struct.pack("!i", pq3.protocol(*version)) == expected_bytes
+
+
[email protected](
+    "envvar,func,expected",
+    [
+        ("PGHOST", pq3.pghost, "localhost"),
+        ("PGPORT", pq3.pgport, 5432),
+        (
+            "PGUSER",
+            pq3.pguser,
+            os.getlogin() if platform.system() == "Windows" else getpass.getuser(),
+        ),
+        ("PGDATABASE", pq3.pgdatabase, "postgres"),
+    ],
+)
+def test_env_defaults(monkeypatch, envvar, func, expected):
+    monkeypatch.delenv(envvar, raising=False)
+
+    actual = func()
+    assert actual == expected
+
+
[email protected](
+    "envvars,func,expected",
+    [
+        (dict(PGHOST="otherhost"), pq3.pghost, "otherhost"),
+        (dict(PGPORT="6789"), pq3.pgport, 6789),
+        (dict(PGUSER="postgres"), pq3.pguser, "postgres"),
+        (dict(PGDATABASE="template1"), pq3.pgdatabase, "template1"),
+    ],
+)
+def test_env(monkeypatch, envvars, func, expected):
+    for k, v in envvars.items():
+        monkeypatch.setenv(k, v)
+
+    actual = func()
+    assert actual == expected
diff --git a/src/test/python/tls.py b/src/test/python/tls.py
new file mode 100644
index 00000000000..075c02c1ca6
--- /dev/null
+++ b/src/test/python/tls.py
@@ -0,0 +1,195 @@
+#
+# Copyright 2021 VMware, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+from construct import *
+
+#
+# TLS 1.3
+#
+# Most of the types below are transcribed from RFC 8446:
+#
+#     https://tools.ietf.org/html/rfc8446
+#
+
+
+def _Vector(size_field, element):
+    return Prefixed(size_field, GreedyRange(element))
+
+
+# Alerts
+
+AlertLevel = Enum(
+    Byte,
+    warning=1,
+    fatal=2,
+)
+
+AlertDescription = Enum(
+    Byte,
+    close_notify=0,
+    unexpected_message=10,
+    bad_record_mac=20,
+    decryption_failed_RESERVED=21,
+    record_overflow=22,
+    decompression_failure=30,
+    handshake_failure=40,
+    no_certificate_RESERVED=41,
+    bad_certificate=42,
+    unsupported_certificate=43,
+    certificate_revoked=44,
+    certificate_expired=45,
+    certificate_unknown=46,
+    illegal_parameter=47,
+    unknown_ca=48,
+    access_denied=49,
+    decode_error=50,
+    decrypt_error=51,
+    export_restriction_RESERVED=60,
+    protocol_version=70,
+    insufficient_security=71,
+    internal_error=80,
+    user_canceled=90,
+    no_renegotiation=100,
+    unsupported_extension=110,
+)
+
+Alert = Struct(
+    "level" / AlertLevel,
+    "description" / AlertDescription,
+)
+
+
+# Extensions
+
+ExtensionType = Enum(
+    Int16ub,
+    server_name=0,
+    max_fragment_length=1,
+    status_request=5,
+    supported_groups=10,
+    signature_algorithms=13,
+    use_srtp=14,
+    heartbeat=15,
+    application_layer_protocol_negotiation=16,
+    signed_certificate_timestamp=18,
+    client_certificate_type=19,
+    server_certificate_type=20,
+    padding=21,
+    pre_shared_key=41,
+    early_data=42,
+    supported_versions=43,
+    cookie=44,
+    psk_key_exchange_modes=45,
+    certificate_authorities=47,
+    oid_filters=48,
+    post_handshake_auth=49,
+    signature_algorithms_cert=50,
+    key_share=51,
+)
+
+Extension = Struct(
+    "extension_type" / ExtensionType,
+    "extension_data" / Prefixed(Int16ub, GreedyBytes),
+)
+
+
+# ClientHello
+
+
+class _CipherSuiteAdapter(Adapter):
+    class _hextuple(tuple):
+        def __repr__(self):
+            return f"(0x{self[0]:02X}, 0x{self[1]:02X})"
+
+    def _encode(self, obj, context, path):
+        return bytes(obj)
+
+    def _decode(self, obj, context, path):
+        assert len(obj) == 2
+        return self._hextuple(obj)
+
+
+ProtocolVersion = Hex(Int16ub)
+
+Random = Hex(Bytes(32))
+
+CipherSuite = _CipherSuiteAdapter(Byte[2])
+
+ClientHello = Struct(
+    "legacy_version" / ProtocolVersion,
+    "random" / Random,
+    "legacy_session_id" / Prefixed(Byte, Hex(GreedyBytes)),
+    "cipher_suites" / _Vector(Int16ub, CipherSuite),
+    "legacy_compression_methods" / Prefixed(Byte, GreedyBytes),
+    "extensions" / _Vector(Int16ub, Extension),
+)
+
+# ServerHello
+
+ServerHello = Struct(
+    "legacy_version" / ProtocolVersion,
+    "random" / Random,
+    "legacy_session_id_echo" / Prefixed(Byte, Hex(GreedyBytes)),
+    "cipher_suite" / CipherSuite,
+    "legacy_compression_method" / Hex(Byte),
+    "extensions" / _Vector(Int16ub, Extension),
+)
+
+# Handshake
+
+HandshakeType = Enum(
+    Byte,
+    client_hello=1,
+    server_hello=2,
+    new_session_ticket=4,
+    end_of_early_data=5,
+    encrypted_extensions=8,
+    certificate=11,
+    certificate_request=13,
+    certificate_verify=15,
+    finished=20,
+    key_update=24,
+    message_hash=254,
+)
+
+Handshake = Struct(
+    "msg_type" / HandshakeType,
+    "length" / Int24ub,
+    "payload"
+    / Switch(
+        this.msg_type,
+        {
+            HandshakeType.client_hello: ClientHello,
+            HandshakeType.server_hello: ServerHello,
+            # HandshakeType.end_of_early_data: EndOfEarlyData,
+            # HandshakeType.encrypted_extensions: EncryptedExtensions,
+            # HandshakeType.certificate_request: CertificateRequest,
+            # HandshakeType.certificate: Certificate,
+            # HandshakeType.certificate_verify: CertificateVerify,
+            # HandshakeType.finished: Finished,
+            # HandshakeType.new_session_ticket: NewSessionTicket,
+            # HandshakeType.key_update: KeyUpdate,
+        },
+        default=FixedSized(this.length, GreedyBytes),
+    ),
+)
+
+# Records
+
+ContentType = Enum(
+    Byte,
+    invalid=0,
+    change_cipher_spec=20,
+    alert=21,
+    handshake=22,
+    application_data=23,
+)
+
+Plaintext = Struct(
+    "type" / ContentType,
+    "legacy_record_version" / ProtocolVersion,
+    "length" / Int16ub,
+    "fragment" / FixedSized(this.length, GreedyBytes),
+)
diff --git a/src/tools/make_venv b/src/tools/make_venv
new file mode 100755
index 00000000000..804307ee120
--- /dev/null
+++ b/src/tools/make_venv
@@ -0,0 +1,56 @@
+#!/usr/bin/env python3
+
+import argparse
+import subprocess
+import os
+import platform
+import sys
+
+parser = argparse.ArgumentParser()
+
+parser.add_argument('--requirements', help='path to pip requirements file', type=str)
+parser.add_argument('--privatedir', help='private directory for target', type=str)
+parser.add_argument('venv_path', help='desired venv location')
+
+args = parser.parse_args()
+
+# Decide whether or not to capture stdout into a log file. We only do this if
+# we've been given our own private directory.
+#
+# FIXME Unfortunately this interferes with debugging on Cirrus, because the
+# private directory isn't uploaded in the sanity check's artifacts. When we
+# don't capture the log file, it gets spammed to stdout during build... Is there
+# a way to push this into the meson-log somehow? For now, the capture
+# implementation is commented out.
+logfile = None
+
+if args.privatedir:
+    if not os.path.isdir(args.privatedir):
+        os.mkdir(args.privatedir)
+
+    # FIXME see above comment
+    # logpath = os.path.join(args.privatedir, 'stdout.txt')
+    # logfile = open(logpath, 'w')
+
+def run(*args):
+    kwargs = dict(check=True)
+    if logfile:
+        kwargs.update(stdout=logfile)
+
+    subprocess.run(args, **kwargs)
+
+# Create the virtualenv first.
+run(sys.executable, '-m', 'venv', args.venv_path)
+
+# Update pip next. This helps avoid old pip bugs; the version inside system
+# Pythons tends to be pretty out of date.
+bindir = 'Scripts' if platform.system() == 'Windows' else 'bin'
+python = os.path.join(args.venv_path, bindir, 'python3')
+run(python, '-m', 'pip', 'install', '-U', 'pip')
+
+# Finally, install the test's requirements. We need pytest and pytest-tap, no
+# matter what the test needs.
+pip = os.path.join(args.venv_path, bindir, 'pip')
+run(pip, 'install', 'pytest', 'pytest-tap')
+if args.requirements:
+    run(pip, 'install', '-r', args.requirements)
diff --git a/src/tools/testwrap b/src/tools/testwrap
index 8ae8fb79ba7..ffdf760d79a 100755
--- a/src/tools/testwrap
+++ b/src/tools/testwrap
@@ -14,6 +14,7 @@ parser.add_argument('--testgroup', help='test group', type=str)
 parser.add_argument('--testname', help='test name', type=str)
 parser.add_argument('--skip', help='skip test (with reason)', type=str)
 parser.add_argument('--pg-test-extra', help='extra tests', type=str)
+parser.add_argument('--skip-without-extra', help='skip if PG_TEST_EXTRA is missing this arg', type=str)
 parser.add_argument('test_command', nargs='*')
 
 args = parser.parse_args()
@@ -29,6 +30,12 @@ if args.skip is not None:
     print('1..0 # Skipped: ' + args.skip)
     sys.exit(0)
 
+if args.skip_without_extra is not None:
+    extras = os.environ.get("PG_TEST_EXTRA", args.pg_test_extra)
+    if extras is None or args.skip_without_extra not in extras.split():
+        print(f'1..0 # Skipped: PG_TEST_EXTRA does not contain "{args.skip_without_extra}"')
+        sys.exit(0)
+
 if os.path.exists(testdir) and os.path.isdir(testdir):
     shutil.rmtree(testdir)
 os.makedirs(testdir)
-- 
2.34.1



^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-01-31 16:23  Daniel Gustafsson <[email protected]>
  parent: Jacob Champion <[email protected]>
  0 siblings, 1 reply; 101+ messages in thread

From: Daniel Gustafsson @ 2025-01-31 16:23 UTC (permalink / raw)
  To: Jacob Champion <[email protected]>; +Cc: Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

> On 28 Jan 2025, at 01:59, Jacob Champion <[email protected]> wrote:
> On Mon, Jan 27, 2025 at 2:50 PM Daniel Gustafsson <[email protected]> wrote:
>> Unless there are objections I aim at committing these patches reasonably soon
>> to lower the barrier for getting OAuth support committed.

After staring at the patchset even more I committed patches 0001 and 0002 today
as a preparatory step for getting OAuth in.  I will work on the 0003 (which is
now 0001) next.

Attached is a v45 which is v44 without the now committer patches to keep the
CFBot happy.

--
Daniel Gustafsson



Attachments:

  [application/octet-stream] v45-0004-DO-NOT-MERGE-Add-pytest-suite-for-OAuth.patch (212.2K, ../../[email protected]/2-v45-0004-DO-NOT-MERGE-Add-pytest-suite-for-OAuth.patch)
  download | inline diff:
From e587019c58f2f571ccbb69bcfbb9ead13ecf4ca3 Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Fri, 4 Jun 2021 09:06:38 -0700
Subject: [PATCH v45 4/4] DO NOT MERGE: Add pytest suite for OAuth

Requires Python 3. On the first run of `make installcheck` or `meson
test` the dependencies will be installed into a local virtualenv for
you. See the README for more details.

Cirrus has been updated to build OAuth support on Debian and FreeBSD.

The suite contains a --temp-instance option, analogous to pg_regress's
option of the same name, which allows an ephemeral server to be spun up
during a test run.

TODOs:
- The --tap-stream option to pytest-tap is slightly broken during test
  failures (it suppresses error information), which impedes debugging.
- pyca/cryptography is pinned at an old version. Since we use it for
  testing and not security, this isn't a critical problem yet, but it's
  not ideal. Newer versions require a Rust compiler to build, and while
  many platforms have precompiled wheels, some (FreeBSD) do not. Even
  with the Rust pieces bypassed, compilation on FreeBSD takes a while.
- The with_oauth test skip logic should probably be integrated into the
  Makefile side as well...
- See if 32-bit tests can be enabled with a 32-bit Python.
---
 .cirrus.tasks.yml                     |    6 +-
 meson.build                           |  103 +
 src/test/meson.build                  |    1 +
 src/test/python/.gitignore            |    2 +
 src/test/python/Makefile              |   38 +
 src/test/python/README                |   66 +
 src/test/python/client/__init__.py    |    0
 src/test/python/client/conftest.py    |  196 ++
 src/test/python/client/test_client.py |  186 ++
 src/test/python/client/test_oauth.py  | 2659 +++++++++++++++++++++++++
 src/test/python/conftest.py           |   34 +
 src/test/python/meson.build           |   47 +
 src/test/python/pq3.py                |  740 +++++++
 src/test/python/pytest.ini            |    4 +
 src/test/python/requirements.txt      |   11 +
 src/test/python/server/__init__.py    |    0
 src/test/python/server/conftest.py    |  141 ++
 src/test/python/server/meson.build    |   18 +
 src/test/python/server/oauthtest.c    |  118 ++
 src/test/python/server/test_oauth.py  | 1080 ++++++++++
 src/test/python/server/test_server.py |   21 +
 src/test/python/test_internals.py     |  138 ++
 src/test/python/test_pq3.py           |  574 ++++++
 src/test/python/tls.py                |  195 ++
 src/tools/make_venv                   |   56 +
 src/tools/testwrap                    |    7 +
 26 files changed, 6440 insertions(+), 1 deletion(-)
 create mode 100644 src/test/python/.gitignore
 create mode 100644 src/test/python/Makefile
 create mode 100644 src/test/python/README
 create mode 100644 src/test/python/client/__init__.py
 create mode 100644 src/test/python/client/conftest.py
 create mode 100644 src/test/python/client/test_client.py
 create mode 100644 src/test/python/client/test_oauth.py
 create mode 100644 src/test/python/conftest.py
 create mode 100644 src/test/python/meson.build
 create mode 100644 src/test/python/pq3.py
 create mode 100644 src/test/python/pytest.ini
 create mode 100644 src/test/python/requirements.txt
 create mode 100644 src/test/python/server/__init__.py
 create mode 100644 src/test/python/server/conftest.py
 create mode 100644 src/test/python/server/meson.build
 create mode 100644 src/test/python/server/oauthtest.c
 create mode 100644 src/test/python/server/test_oauth.py
 create mode 100644 src/test/python/server/test_server.py
 create mode 100644 src/test/python/test_internals.py
 create mode 100644 src/test/python/test_pq3.py
 create mode 100644 src/test/python/tls.py
 create mode 100755 src/tools/make_venv

diff --git a/.cirrus.tasks.yml b/.cirrus.tasks.yml
index 97bb38c72c..a6fab60bfd 100644
--- a/.cirrus.tasks.yml
+++ b/.cirrus.tasks.yml
@@ -20,7 +20,7 @@ env:
   MTEST_ARGS: --print-errorlogs --no-rebuild -C build
   PGCTLTIMEOUT: 120 # avoids spurious failures during parallel tests
   TEMP_CONFIG: ${CIRRUS_WORKING_DIR}/src/tools/ci/pg_ci_base.conf
-  PG_TEST_EXTRA: kerberos ldap ssl libpq_encryption load_balance oauth
+  PG_TEST_EXTRA: kerberos ldap ssl libpq_encryption load_balance oauth python
 
 
 # What files to preserve in case tests fail
@@ -318,6 +318,7 @@ task:
     DEBIAN_FRONTEND=noninteractive apt-get -y install \
       libcurl4-openssl-dev \
       libcurl4-openssl-dev:i386 \
+      python3-venv \
 
   matrix:
     - name: Linux - Debian Bookworm - Autoconf
@@ -402,8 +403,11 @@ task:
       # can easily provide some here by running one of the sets of tests that
       # way. Newer versions of python insist on changing the LC_CTYPE away
       # from C, prevent that with PYTHONCOERCECLOCALE.
+      # XXX 32-bit Python tests are currently disabled, as the system's 64-bit
+      # Python modules can't link against libpq.
       test_world_32_script: |
         su postgres <<-EOF
+          export PG_TEST_EXTRA="${PG_TEST_EXTRA//python}"
           ulimit -c unlimited
           PYTHONCOERCECLOCALE=0 LANG=C meson test $MTEST_ARGS -C build-32 --num-processes ${TEST_JOBS}
         EOF
diff --git a/meson.build b/meson.build
index 3b35f1f0c9..3ee040a90f 100644
--- a/meson.build
+++ b/meson.build
@@ -3408,6 +3408,9 @@ else
 endif
 
 testwrap = files('src/tools/testwrap')
+make_venv = files('src/tools/make_venv')
+
+checked_working_venv = false
 
 foreach test_dir : tests
   testwrap_base = [
@@ -3576,6 +3579,106 @@ foreach test_dir : tests
         )
       endforeach
       install_suites += test_group
+    elif kind == 'pytest'
+      venv_name = test_dir['name'] + '_venv'
+      venv_path = meson.build_root() / venv_name
+
+      # The Python tests require a working venv module. This is part of the
+      # standard library, but some platforms disable it until a separate package
+      # is installed. Those same platforms don't provide an easy way to check
+      # whether the venv command will work until the first time you try it, so
+      # we decide whether or not to enable these tests on the fly.
+      if not checked_working_venv
+        cmd = run_command(python, '-m', 'venv', venv_path, check: false)
+
+        have_working_venv = (cmd.returncode() == 0)
+        if not have_working_venv
+          warning('A working Python venv module is required to run Python tests.')
+        endif
+
+        checked_working_venv = true
+      endif
+
+      if not have_working_venv
+        continue
+      endif
+
+      # Make sure the temporary installation is in PATH (necessary both for
+      # --temp-instance and for any pip modules compiling against libpq, like
+      # psycopg2).
+      env = test_env
+      env.prepend('PATH', temp_install_bindir, test_dir['bd'])
+
+      foreach name, value : t.get('env', {})
+        env.set(name, value)
+      endforeach
+
+      reqs = files(t['requirements'])
+      test('install_' + venv_name,
+        python,
+        args: [ make_venv, '--requirements', reqs, venv_path ],
+        env: env,
+        priority: setup_tests_priority - 1,  # must run after tmp_install
+        is_parallel: false,
+        suite: ['setup'],
+        timeout: 60,  # 30s is too short for the cryptography package compile
+      )
+
+      test_group = test_dir['name']
+      test_output = test_result_dir / test_group / kind
+      test_kwargs = {
+        #'protocol': 'tap',
+        'suite': test_group,
+        'timeout': 1000,
+        'depends': test_deps,
+        'env': env,
+      } + t.get('test_kwargs', {})
+
+      if fs.is_dir(venv_path / 'Scripts')
+        # Windows virtualenv layout
+        pytest = venv_path / 'Scripts' / 'py.test'
+      else
+        pytest = venv_path / 'bin' / 'py.test'
+      endif
+
+      test_command = [
+        pytest,
+        # Avoid running these tests against an existing database.
+        '--temp-instance', test_output / 'data',
+
+        # FIXME pytest-tap's stream feature accidentally suppresses errors that
+        # are critical for debugging:
+        #     https://github.com/python-tap/pytest-tap/issues/30
+        # Don't use the meson TAP protocol for now...
+        #'--tap-stream',
+      ]
+
+      foreach pyt : t['tests']
+        # Similarly to TAP, strip ./ and .py to make the names prettier
+        pyt_p = pyt
+        if pyt_p.startswith('./')
+          pyt_p = pyt_p.split('./')[1]
+        endif
+        if pyt_p.endswith('.py')
+          pyt_p = fs.stem(pyt_p)
+        endif
+
+        testwrap_pytest = testwrap_base + [
+          '--testgroup', test_group,
+          '--testname', pyt_p,
+          '--skip-without-extra', 'python',
+        ]
+
+        test(test_group / pyt_p,
+          python,
+          kwargs: test_kwargs,
+          args: testwrap_pytest + [
+            '--', test_command,
+            test_dir['sd'] / pyt,
+          ],
+        )
+      endforeach
+      install_suites += test_group
     else
       error('unknown kind @0@ of test in @1@'.format(kind, test_dir['sd']))
     endif
diff --git a/src/test/meson.build b/src/test/meson.build
index ccc31d6a86..236057cd99 100644
--- a/src/test/meson.build
+++ b/src/test/meson.build
@@ -8,6 +8,7 @@ subdir('postmaster')
 subdir('recovery')
 subdir('subscription')
 subdir('modules')
+subdir('python')
 
 if ssl.found()
   subdir('ssl')
diff --git a/src/test/python/.gitignore b/src/test/python/.gitignore
new file mode 100644
index 0000000000..0e8f027b2e
--- /dev/null
+++ b/src/test/python/.gitignore
@@ -0,0 +1,2 @@
+__pycache__/
+/venv/
diff --git a/src/test/python/Makefile b/src/test/python/Makefile
new file mode 100644
index 0000000000..b0695b6287
--- /dev/null
+++ b/src/test/python/Makefile
@@ -0,0 +1,38 @@
+#
+# Copyright 2021 VMware, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+# Only Python 3 is supported, but if it's named something different on your
+# system you can override it with the PYTHON3 variable.
+PYTHON3 := python3
+
+# All dependencies are placed into this directory. The default is .gitignored
+# for you, but you can override it if you'd like.
+VENV := ./venv
+
+override VBIN   := $(VENV)/bin
+override PIP    := $(VBIN)/pip
+override PYTEST := $(VBIN)/py.test
+override ISORT  := $(VBIN)/isort
+override BLACK  := $(VBIN)/black
+
+.PHONY: installcheck indent
+
+installcheck: $(PYTEST)
+	$(PYTEST) -v -rs
+
+indent: $(ISORT) $(BLACK)
+	$(ISORT) --profile black *.py client/*.py server/*.py
+	$(BLACK) *.py client/*.py server/*.py
+
+$(PYTEST) $(ISORT) $(BLACK) &: requirements.txt | $(PIP)
+	$(PIP) install --force-reinstall -r $<
+
+$(PIP):
+	$(PYTHON3) -m venv $(VENV)
+
+# A convenience recipe to rebuild psycopg2 against the local libpq.
+.PHONY: rebuild-psycopg2
+rebuild-psycopg2: | $(PIP)
+	$(PIP) install --force-reinstall --no-binary :all: $(shell grep psycopg2 requirements.txt)
diff --git a/src/test/python/README b/src/test/python/README
new file mode 100644
index 0000000000..acf339a589
--- /dev/null
+++ b/src/test/python/README
@@ -0,0 +1,66 @@
+A test suite for exercising both the libpq client and the server backend at the
+protocol level, based on pytest and Construct.
+
+WARNING! This suite takes superuser-level control of the cluster under test,
+writing to the server config, creating and destroying databases, etc. It also
+spins up various ephemeral TCP services. This is not safe for production servers
+and therefore must be explicitly opted into by setting PG_TEST_EXTRA=python in
+the environment.
+
+The test suite currently assumes that the standard PG* environment variables
+point to the database under test and are sufficient to log in a superuser on
+that system. In other words, a bare `psql` needs to Just Work before the test
+suite can do its thing. For a newly built dev cluster, typically all that I need
+to do is a
+
+    export PGDATABASE=postgres
+
+but you can adjust as needed for your setup. See also 'Advanced Usage' below.
+
+## Requirements
+
+A supported version (3.6+) of Python.
+
+The first run of
+
+    make installcheck PG_TEST_EXTRA=python
+
+will install a local virtual environment and all needed dependencies. During
+development, if libpq changes incompatibly, you can issue
+
+    $ make rebuild-psycopg2
+
+to force a rebuild of the client library.
+
+## Hacking
+
+The code style is enforced by a _very_ opinionated autoformatter. Running the
+
+    make indent
+
+recipe will invoke it for you automatically. Don't fight the tool; part of the
+zen is in knowing that if the formatter makes your code ugly, there's probably a
+cleaner way to write your code.
+
+## Advanced Usage
+
+The Makefile is there for convenience, but you don't have to use it. Activate
+the virtualenv to be able to use pytest directly:
+
+    $ export PG_TEST_EXTRA=python
+    $ source venv/bin/activate
+    $ py.test -k oauth
+    ...
+    $ py.test ./server/test_server.py
+    ...
+    $ deactivate  # puts the PATH et al back the way it was before
+
+To make quick smoke tests possible, slow tests have been marked explicitly. You
+can skip them by saying e.g.
+
+    $ py.test -m 'not slow'
+
+If you'd rather not test against an existing server, you can have the suite spin
+up a temporary one using whatever pg_ctl it finds in PATH:
+
+    $ py.test --temp-instance=./tmp_check
diff --git a/src/test/python/client/__init__.py b/src/test/python/client/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/test/python/client/conftest.py b/src/test/python/client/conftest.py
new file mode 100644
index 0000000000..20e72a404a
--- /dev/null
+++ b/src/test/python/client/conftest.py
@@ -0,0 +1,196 @@
+#
+# Copyright 2021 VMware, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import contextlib
+import datetime
+import functools
+import ipaddress
+import os
+import socket
+import sys
+import threading
+
+import psycopg2
+import psycopg2.extras
+import pytest
+from cryptography import x509
+from cryptography.hazmat.primitives import hashes, serialization
+from cryptography.hazmat.primitives.asymmetric import rsa
+from cryptography.x509.oid import NameOID
+
+import pq3
+
+BLOCKING_TIMEOUT = 2  # the number of seconds to wait for blocking calls
+
+
[email protected]
+def server_socket(unused_tcp_port_factory):
+    """
+    Returns a listening socket bound to an ephemeral port.
+    """
+    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+        s.bind(("127.0.0.1", unused_tcp_port_factory()))
+        s.listen(1)
+        s.settimeout(BLOCKING_TIMEOUT)
+        yield s
+
+
+class ClientHandshake(threading.Thread):
+    """
+    A thread that connects to a local Postgres server using psycopg2. Once the
+    opening handshake completes, the connection will be immediately closed.
+    """
+
+    def __init__(self, *, port, **kwargs):
+        super().__init__()
+
+        kwargs["port"] = port
+        self._kwargs = kwargs
+
+        self.exception = None
+
+    def run(self):
+        try:
+            conn = psycopg2.connect(host="127.0.0.1", **self._kwargs)
+            with contextlib.closing(conn):
+                self._pump_async(conn)
+        except Exception as e:
+            self.exception = e
+
+    def check_completed(self, timeout=BLOCKING_TIMEOUT):
+        """
+        Joins the client thread. Raises an exception if the thread could not be
+        joined, or if it threw an exception itself. (The exception will be
+        cleared, so future calls to check_completed will succeed.)
+        """
+        self.join(timeout)
+
+        if self.is_alive():
+            raise TimeoutError("client thread did not handshake within the timeout")
+        elif self.exception:
+            e = self.exception
+            self.exception = None
+            raise e
+
+    def _pump_async(self, conn):
+        """
+        Polls a psycopg2 connection until it's completed. (Synchronous
+        connections will work here too; they'll just immediately return OK.)
+        """
+        psycopg2.extras.wait_select(conn)
+
+
[email protected]
+def accept(server_socket):
+    """
+    Returns a factory function that, when called, returns a pair (sock, client)
+    where sock is a server socket that has accepted a connection from client,
+    and client is an instance of ClientHandshake. Clients will complete their
+    handshakes and cleanly disconnect.
+
+    The default connstring options may be extended or overridden by passing
+    arbitrary keyword arguments. Keep in mind that you generally should not
+    override the host or port, since they point to the local test server.
+
+    For situations where a client needs to connect more than once to complete a
+    handshake, the accept function may be called more than once. (The client
+    returned for subsequent calls will always be the same client that was
+    returned for the first call.)
+
+    Tests must either complete the handshake so that the client thread can be
+    automatically joined during teardown, or else call client.check_completed()
+    and manually handle any expected errors.
+    """
+    _, port = server_socket.getsockname()
+
+    client = None
+    default_opts = dict(
+        port=port,
+        user=pq3.pguser(),
+        sslmode="disable",
+    )
+
+    def factory(**kwargs):
+        nonlocal client
+
+        if client is None:
+            opts = dict(default_opts)
+            opts.update(kwargs)
+
+            # The server_socket is already listening, so the client thread can
+            # be safely started; it'll block on the connection until we accept.
+            client = ClientHandshake(**opts)
+            client.start()
+
+        sock, _ = server_socket.accept()
+        sock.settimeout(BLOCKING_TIMEOUT)
+        return sock, client
+
+    yield factory
+
+    if client is not None:
+        client.check_completed()
+
+
[email protected]
+def conn(accept):
+    """
+    Returns an accepted, wrapped pq3 connection to a psycopg2 client. The socket
+    will be closed when the test finishes, and the client will be checked for a
+    cleanly completed handshake.
+    """
+    sock, client = accept()
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            yield conn
+
+
[email protected](scope="session")
+def certpair(tmp_path_factory):
+    """
+    Yields a (cert, key) pair of file paths that can be used by a TLS server.
+    The certificate is issued for "localhost" and its standard IPv4/6 addresses.
+    """
+
+    tmpdir = tmp_path_factory.mktemp("certs")
+    now = datetime.datetime.now(datetime.timezone.utc)
+
+    # https://cryptography.io/en/latest/x509/tutorial/#creating-a-self-signed-certificate
+    key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
+
+    subject = issuer = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "localhost")])
+    altNames = [
+        x509.DNSName("localhost"),
+        x509.IPAddress(ipaddress.IPv4Address("127.0.0.1")),
+        x509.IPAddress(ipaddress.IPv6Address("::1")),
+    ]
+    cert = (
+        x509.CertificateBuilder()
+        .subject_name(subject)
+        .issuer_name(issuer)
+        .public_key(key.public_key())
+        .serial_number(x509.random_serial_number())
+        .not_valid_before(now)
+        .not_valid_after(now + datetime.timedelta(minutes=10))
+        .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
+        .add_extension(x509.SubjectAlternativeName(altNames), critical=False)
+    ).sign(key, hashes.SHA256())
+
+    # Writing the key with mode 0600 lets us use this from the server side, too.
+    keypath = str(tmpdir / "key.pem")
+    with open(keypath, "wb", opener=functools.partial(os.open, mode=0o600)) as f:
+        f.write(
+            key.private_bytes(
+                encoding=serialization.Encoding.PEM,
+                format=serialization.PrivateFormat.PKCS8,
+                encryption_algorithm=serialization.NoEncryption(),
+            )
+        )
+
+    certpath = str(tmpdir / "cert.pem")
+    with open(certpath, "wb") as f:
+        f.write(cert.public_bytes(serialization.Encoding.PEM))
+
+    return certpath, keypath
diff --git a/src/test/python/client/test_client.py b/src/test/python/client/test_client.py
new file mode 100644
index 0000000000..8372376ede
--- /dev/null
+++ b/src/test/python/client/test_client.py
@@ -0,0 +1,186 @@
+#
+# Copyright 2021 VMware, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import base64
+import sys
+
+import psycopg2
+import pytest
+from cryptography.hazmat.primitives import hashes, hmac
+
+import pq3
+
+from .test_oauth import alt_patterns
+
+
+def finish_handshake(conn):
+    """
+    Sends the AuthenticationOK message and the standard opening salvo of server
+    messages, then asserts that the client immediately sends a Terminate message
+    to close the connection cleanly.
+    """
+    pq3.send(conn, pq3.types.AuthnRequest, type=pq3.authn.OK)
+    pq3.send(conn, pq3.types.ParameterStatus, name=b"client_encoding", value=b"UTF-8")
+    pq3.send(conn, pq3.types.ParameterStatus, name=b"DateStyle", value=b"ISO, MDY")
+    pq3.send(conn, pq3.types.ReadyForQuery, status=b"I")
+
+    pkt = pq3.recv1(conn)
+    assert pkt.type == pq3.types.Terminate
+
+
+def test_handshake(conn):
+    startup = pq3.recv1(conn, cls=pq3.Startup)
+    assert startup.proto == pq3.protocol(3, 0)
+
+    finish_handshake(conn)
+
+
+def test_aborted_connection(accept):
+    """
+    Make sure the client correctly reports an early close during handshakes.
+    """
+    sock, client = accept()
+    sock.close()
+
+    expected = alt_patterns(
+        "server closed the connection unexpectedly",
+        # On some platforms, ECONNABORTED gets set instead.
+        "Software caused connection abort",
+    )
+    with pytest.raises(psycopg2.OperationalError, match=expected):
+        client.check_completed()
+
+
+#
+# SCRAM-SHA-256 (see RFC 5802: https://tools.ietf.org/html/rfc5802)
+#
+
+
[email protected]
+def password():
+    """
+    Returns a password for use by both client and server.
+    """
+    # TODO: parameterize this with passwords that require SASLprep.
+    return "secret"
+
+
[email protected]
+def pwconn(accept, password):
+    """
+    Like the conn fixture, but uses a password in the connection.
+    """
+    sock, client = accept(password=password)
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            yield conn
+
+
+def sha256(data):
+    """The H(str) function from Section 2.2."""
+    digest = hashes.Hash(hashes.SHA256())
+    digest.update(data)
+    return digest.finalize()
+
+
+def hmac_256(key, data):
+    """The HMAC(key, str) function from Section 2.2."""
+    h = hmac.HMAC(key, hashes.SHA256())
+    h.update(data)
+    return h.finalize()
+
+
+def xor(a, b):
+    """The XOR operation from Section 2.2."""
+    res = bytearray(a)
+    for i, byte in enumerate(b):
+        res[i] ^= byte
+    return bytes(res)
+
+
+def h_i(data, salt, i):
+    """The Hi(str, salt, i) function from Section 2.2."""
+    assert i > 0
+
+    acc = hmac_256(data, salt + b"\x00\x00\x00\x01")
+    last = acc
+    i -= 1
+
+    while i:
+        u = hmac_256(data, last)
+        acc = xor(acc, u)
+
+        last = u
+        i -= 1
+
+    return acc
+
+
+def test_scram(pwconn, password):
+    startup = pq3.recv1(pwconn, cls=pq3.Startup)
+    assert startup.proto == pq3.protocol(3, 0)
+
+    pq3.send(
+        pwconn,
+        pq3.types.AuthnRequest,
+        type=pq3.authn.SASL,
+        body=[b"SCRAM-SHA-256", b""],
+    )
+
+    # Get the client-first-message.
+    pkt = pq3.recv1(pwconn)
+    assert pkt.type == pq3.types.PasswordMessage
+
+    initial = pq3.SASLInitialResponse.parse(pkt.payload)
+    assert initial.name == b"SCRAM-SHA-256"
+
+    c_bind, authzid, c_name, c_nonce = initial.data.split(b",")
+    assert c_bind == b"n"  # no channel bindings on a plaintext connection
+    assert authzid == b""  # we don't support authzid currently
+    assert c_name == b"n="  # libpq doesn't honor the GS2 username
+    assert c_nonce.startswith(b"r=")
+
+    # Send the server-first-message.
+    salt = b"12345"
+    iterations = 2
+
+    s_nonce = c_nonce + b"somenonce"
+    s_salt = b"s=" + base64.b64encode(salt)
+    s_iterations = b"i=%d" % iterations
+
+    msg = b",".join([s_nonce, s_salt, s_iterations])
+    pq3.send(pwconn, pq3.types.AuthnRequest, type=pq3.authn.SASLContinue, body=msg)
+
+    # Get the client-final-message.
+    pkt = pq3.recv1(pwconn)
+    assert pkt.type == pq3.types.PasswordMessage
+
+    c_bind_final, c_nonce_final, c_proof = pkt.payload.split(b",")
+    assert c_bind_final == b"c=" + base64.b64encode(c_bind + b"," + authzid + b",")
+    assert c_nonce_final == s_nonce
+
+    # Calculate what the client proof should be.
+    salted_password = h_i(password.encode("ascii"), salt, iterations)
+    client_key = hmac_256(salted_password, b"Client Key")
+    stored_key = sha256(client_key)
+
+    auth_message = b",".join(
+        [c_name, c_nonce, s_nonce, s_salt, s_iterations, c_bind_final, c_nonce_final]
+    )
+    client_signature = hmac_256(stored_key, auth_message)
+    client_proof = xor(client_key, client_signature)
+
+    expected = b"p=" + base64.b64encode(client_proof)
+    assert c_proof == expected
+
+    # Send the correct server signature.
+    server_key = hmac_256(salted_password, b"Server Key")
+    server_signature = hmac_256(server_key, auth_message)
+
+    s_verify = b"v=" + base64.b64encode(server_signature)
+    pq3.send(pwconn, pq3.types.AuthnRequest, type=pq3.authn.SASLFinal, body=s_verify)
+
+    # Done!
+    finish_handshake(pwconn)
diff --git a/src/test/python/client/test_oauth.py b/src/test/python/client/test_oauth.py
new file mode 100644
index 0000000000..ea1aeaed48
--- /dev/null
+++ b/src/test/python/client/test_oauth.py
@@ -0,0 +1,2659 @@
+#
+# Copyright 2021 VMware, Inc.
+# Portions Copyright 2023 Timescale, Inc.
+# Portions Copyright 2024 PostgreSQL Global Development Group
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import base64
+import collections
+import contextlib
+import ctypes
+import http.server
+import json
+import logging
+import os
+import platform
+import secrets
+import socket
+import ssl
+import sys
+import threading
+import time
+import traceback
+import types
+import urllib.parse
+from numbers import Number
+
+import psycopg2
+import pytest
+
+import pq3
+
+from .conftest import BLOCKING_TIMEOUT
+
+# The client tests need libpq to have been compiled with OAuth support; skip
+# them otherwise.
+pytestmark = pytest.mark.skipif(
+    os.getenv("with_libcurl") != "yes",
+    reason="OAuth client tests require --with-libcurl support",
+)
+
+if platform.system() == "Darwin":
+    libpq = ctypes.cdll.LoadLibrary("libpq.5.dylib")
+elif platform.system() == "Windows":
+    pass  # TODO
+else:
+    libpq = ctypes.cdll.LoadLibrary("libpq.so.5")
+
+
+def finish_handshake(conn):
+    """
+    Sends the AuthenticationOK message and the standard opening salvo of server
+    messages, then asserts that the client immediately sends a Terminate message
+    to close the connection cleanly.
+    """
+    pq3.send(conn, pq3.types.AuthnRequest, type=pq3.authn.OK)
+    pq3.send(conn, pq3.types.ParameterStatus, name=b"client_encoding", value=b"UTF-8")
+    pq3.send(conn, pq3.types.ParameterStatus, name=b"DateStyle", value=b"ISO, MDY")
+    pq3.send(conn, pq3.types.ReadyForQuery, status=b"I")
+
+    pkt = pq3.recv1(conn)
+    assert pkt.type == pq3.types.Terminate
+
+
+#
+# OAUTHBEARER (see RFC 7628: https://tools.ietf.org/html/rfc7628)
+#
+
+
+def start_oauth_handshake(conn):
+    """
+    Negotiates an OAUTHBEARER SASL challenge. Returns the client's initial
+    response data.
+    """
+    startup = pq3.recv1(conn, cls=pq3.Startup)
+    assert startup.proto == pq3.protocol(3, 0)
+
+    pq3.send(
+        conn, pq3.types.AuthnRequest, type=pq3.authn.SASL, body=[b"OAUTHBEARER", b""]
+    )
+
+    pkt = pq3.recv1(conn)
+    assert pkt.type == pq3.types.PasswordMessage
+
+    initial = pq3.SASLInitialResponse.parse(pkt.payload)
+    assert initial.name == b"OAUTHBEARER"
+
+    return initial.data
+
+
+def get_auth_value(initial):
+    """
+    Finds the auth value (e.g. "Bearer somedata..." in the client's initial SASL
+    response.
+    """
+    kvpairs = initial.split(b"\x01")
+    assert kvpairs[0] == b"n,,"  # no channel binding or authzid
+    assert kvpairs[2] == b""  # ends with an empty kvpair
+    assert kvpairs[3] == b""  # ...and there's nothing after it
+    assert len(kvpairs) == 4
+
+    key, value = kvpairs[1].split(b"=", 2)
+    assert key == b"auth"
+
+    return value
+
+
+def fail_oauth_handshake(conn, sasl_resp, *, errmsg="doesn't matter"):
+    """
+    Sends a failure response via the OAUTHBEARER mechanism, consumes the
+    client's dummy response, and issues a FATAL error to end the exchange.
+
+    sasl_resp is a dictionary which will be serialized as the OAUTHBEARER JSON
+    response. If provided, errmsg is used in the FATAL ErrorResponse.
+    """
+    resp = json.dumps(sasl_resp)
+    pq3.send(
+        conn,
+        pq3.types.AuthnRequest,
+        type=pq3.authn.SASLContinue,
+        body=resp.encode("utf-8"),
+    )
+
+    # Per RFC, the client is required to send a dummy ^A response.
+    pkt = pq3.recv1(conn)
+    assert pkt.type == pq3.types.PasswordMessage
+    assert pkt.payload == b"\x01"
+
+    # Now fail the SASL exchange.
+    pq3.send(
+        conn,
+        pq3.types.ErrorResponse,
+        fields=[
+            b"SFATAL",
+            b"C28000",
+            b"M" + errmsg.encode("utf-8"),
+            b"",
+        ],
+    )
+
+
+def handle_discovery_connection(sock, discovery=None, *, response=None):
+    """
+    Helper for all tests that expect an initial discovery connection from the
+    client. The provided discovery URI will be used in a standard error response
+    from the server (or response may be set, to provide a custom dictionary),
+    and the SASL exchange will be failed.
+
+    By default, the client is expected to complete the entire handshake. Set
+    finish to False if the client should immediately disconnect when it receives
+    the error response.
+    """
+    if response is None:
+        response = {"status": "invalid_token"}
+        if discovery is not None:
+            response["openid-configuration"] = discovery
+
+    with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+        # Initiate a handshake.
+        initial = start_oauth_handshake(conn)
+
+        # For discovery, the client should send an empty auth header. See RFC
+        # 7628, Sec. 4.3.
+        auth = get_auth_value(initial)
+        assert auth == b""
+
+        # The discovery handshake is doomed to fail.
+        fail_oauth_handshake(conn, response)
+
+
+class RawResponse(str):
+    """
+    Returned by registered endpoint callbacks to take full control of the
+    response. Usually, return values are converted to JSON; a RawResponse body
+    will be passed to the client as-is, allowing endpoint implementations to
+    issue invalid JSON.
+    """
+
+    pass
+
+
+class RawBytes(bytes):
+    """
+    Like RawResponse, but bypasses the UTF-8 encoding step as well, allowing
+    implementations to issue invalid encodings.
+    """
+
+    pass
+
+
+class OpenIDProvider(threading.Thread):
+    """
+    A thread that runs a mock OpenID provider server on an SSL-enabled socket.
+    """
+
+    def __init__(self, ssl_socket):
+        super().__init__()
+
+        self.exception = None
+
+        _, port = ssl_socket.getsockname()
+
+        oauth = self._OAuthState()
+        oauth.host = f"localhost:{port}"
+        oauth.issuer = f"https://localhost:{port}"
+
+        # The following endpoints are required to be advertised by providers,
+        # even though our chosen client implementation does not actually make
+        # use of them.
+        oauth.register_endpoint(
+            "authorization_endpoint", "POST", "/authorize", self._authorization_handler
+        )
+        oauth.register_endpoint("jwks_uri", "GET", "/keys", self._jwks_handler)
+
+        self.server = self._HTTPSServer(ssl_socket, self._Handler)
+        self.server.oauth = oauth
+
+    def run(self):
+        try:
+            # XXX socketserver.serve_forever() has a serious architectural
+            # issue: its select loop wakes up every `poll_interval` seconds to
+            # see if the server is shutting down. The default, 500 ms, only lets
+            # us run two tests every second. But the faster we go, the more CPU
+            # we burn unnecessarily...
+            self.server.serve_forever(poll_interval=0.01)
+        except Exception as e:
+            self.exception = e
+
+    def stop(self, timeout=BLOCKING_TIMEOUT):
+        """
+        Shuts down the server and joins its thread. Raises an exception if the
+        thread could not be joined, or if it threw an exception itself. Must
+        only be called once, after start().
+        """
+        self.server.shutdown()
+        self.join(timeout)
+
+        if self.is_alive():
+            raise TimeoutError("client thread did not handshake within the timeout")
+        elif self.exception:
+            e = self.exception
+            raise e
+
+    class _OAuthState(object):
+        def __init__(self):
+            self.endpoint_paths = {}
+            self._endpoints = {}
+
+            # Provide a standard discovery document by default; tests can
+            # override it.
+            self.register_endpoint(
+                None,
+                "GET",
+                "/.well-known/openid-configuration",
+                self._default_discovery_handler,
+            )
+
+            # Default content type unless overridden.
+            self.content_type = "application/json"
+
+        @property
+        def discovery_uri(self):
+            return f"{self.issuer}/.well-known/openid-configuration"
+
+        def register_endpoint(self, name, method, path, func):
+            if method not in self._endpoints:
+                self._endpoints[method] = {}
+
+            self._endpoints[method][path] = func
+
+            if name is not None:
+                self.endpoint_paths[name] = path
+
+        def endpoint(self, method, path):
+            if method not in self._endpoints:
+                return None
+
+            return self._endpoints[method].get(path)
+
+        def _default_discovery_handler(self, headers, params):
+            doc = {
+                "issuer": self.issuer,
+                "response_types_supported": ["token"],
+                "subject_types_supported": ["public"],
+                "id_token_signing_alg_values_supported": ["RS256"],
+                "grant_types_supported": [
+                    "authorization_code",
+                    "urn:ietf:params:oauth:grant-type:device_code",
+                ],
+            }
+
+            for name, path in self.endpoint_paths.items():
+                doc[name] = self.issuer + path
+
+            return 200, doc
+
+    class _HTTPSServer(http.server.HTTPServer):
+        def __init__(self, ssl_socket, handler_cls):
+            # Attach the SSL socket to the server. We don't bind/activate since
+            # the socket is already listening.
+            super().__init__(None, handler_cls, bind_and_activate=False)
+            self.socket = ssl_socket
+            self.server_address = self.socket.getsockname()
+
+        def shutdown_request(self, request):
+            # Cleanly unwrap the SSL socket before shutting down the connection;
+            # otherwise careful clients will complain about truncation.
+            try:
+                request = request.unwrap()
+            except (ssl.SSLEOFError, ConnectionResetError, BrokenPipeError):
+                # The client already closed (or aborted) the connection without
+                # a clean shutdown. This is seen on some platforms during tests
+                # that break the HTTP protocol. Just return and have the server
+                # close the socket.
+                return
+            except ssl.SSLError as err:
+                # FIXME OpenSSL 3.4 introduced an incompatibility with Python's
+                # TLS error handling, resulting in a bogus "[SYS] unknown error"
+                # on some platforms. Hopefully this is fixed in 2025's set of
+                # maintenance releases and this case can be removed.
+                #
+                #     https://github.com/python/cpython/issues/127257
+                #
+                if "[SYS] unknown error" in str(err):
+                    return
+                raise
+
+            super().shutdown_request(request)
+
+        def handle_error(self, request, addr):
+            self.shutdown_request(request)
+            raise
+
+    @staticmethod
+    def _jwks_handler(headers, params):
+        return 200, {"keys": []}
+
+    @staticmethod
+    def _authorization_handler(headers, params):
+        # We don't actually want this to be called during these tests -- we
+        # should be using the device authorization endpoint instead.
+        assert (
+            False
+        ), "authorization handler called instead of device authorization handler"
+
+    class _Handler(http.server.BaseHTTPRequestHandler):
+        timeout = BLOCKING_TIMEOUT
+
+        def _handle(self, *, params=None, handler=None):
+            oauth = self.server.oauth
+            assert self.headers["Host"] == oauth.host
+
+            # XXX: BaseHTTPRequestHandler collapses leading slashes in the path
+            # to work around an open redirection vuln (gh-87389) in
+            # SimpleHTTPServer. But we're not using SimpleHTTPServer, and we
+            # want to test repeating leading slashes, so that's not very
+            # helpful. Put them back.
+            orig_path = self.raw_requestline.split()[1]
+            orig_path = str(orig_path, "iso-8859-1")
+            assert orig_path.endswith(self.path)  # sanity check
+            self.path = orig_path
+
+            if handler is None:
+                handler = oauth.endpoint(self.command, self.path)
+                assert (
+                    handler is not None
+                ), f"no registered endpoint for {self.command} {self.path}"
+
+            result = handler(self.headers, params)
+
+            if len(result) == 2:
+                headers = {"Content-Type": oauth.content_type}
+                code, resp = result
+            else:
+                code, headers, resp = result
+
+            self.send_response(code)
+            for h, v in headers.items():
+                self.send_header(h, v)
+            self.end_headers()
+
+            if resp is not None:
+                if not isinstance(resp, RawBytes):
+                    if not isinstance(resp, RawResponse):
+                        resp = json.dumps(resp)
+                    resp = resp.encode("utf-8")
+                self.wfile.write(resp)
+
+            self.close_connection = True
+
+        def do_GET(self):
+            self._handle()
+
+        def _request_body(self):
+            length = self.headers["Content-Length"]
+
+            # Handle only an explicit content-length.
+            assert length is not None
+            length = int(length)
+
+            return self.rfile.read(length).decode("utf-8")
+
+        def do_POST(self):
+            assert self.headers["Content-Type"] == "application/x-www-form-urlencoded"
+
+            body = self._request_body()
+            if body:
+                # parse_qs() is understandably fairly lax when it comes to
+                # acceptable characters, but we're stricter. Spaces must be
+                # encoded, and they must use the '+' encoding rather than "%20".
+                assert " " not in body
+                assert "%20" not in body
+
+                params = urllib.parse.parse_qs(
+                    body,
+                    keep_blank_values=True,
+                    strict_parsing=True,
+                    encoding="utf-8",
+                    errors="strict",
+                )
+            else:
+                params = {}
+
+            self._handle(params=params)
+
+
[email protected](autouse=True)
+def enable_client_oauth_debugging(monkeypatch):
+    """
+    HTTP providers aren't allowed by default; enable them via envvar.
+    """
+    monkeypatch.setenv("PGOAUTHDEBUG", "UNSAFE")
+
+
[email protected](autouse=True)
+def trust_certpair_in_client(monkeypatch, certpair):
+    """
+    Set a trusted CA file for OAuth client connections.
+    """
+    monkeypatch.setenv("PGOAUTHCAFILE", certpair[0])
+
+
[email protected](scope="session")
+def ssl_socket(certpair):
+    """
+    A listening server-side socket for SSL connections, using the certpair
+    fixture.
+    """
+    sock = socket.create_server(("", 0))
+
+    # The TLS connections we're making are incredibly sensitive to delayed ACKs
+    # from the client. (Without TCP_NODELAY, test performance degrades 4-5x.)
+    sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
+
+    with contextlib.closing(sock):
+        # Wrap the server socket for TLS.
+        ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
+        ctx.load_cert_chain(*certpair)
+
+        yield ctx.wrap_socket(sock, server_side=True)
+
+
[email protected]
+def openid_provider(ssl_socket):
+    """
+    A fixture that returns the OAuth state of a running OpenID provider server. The
+    server will be stopped when the fixture is torn down.
+    """
+    thread = OpenIDProvider(ssl_socket)
+    thread.start()
+
+    try:
+        yield thread.server.oauth
+    finally:
+        thread.stop()
+
+
+#
+# PQAuthDataHook implementation, matching libpq.h
+#
+
+
+PQAUTHDATA_PROMPT_OAUTH_DEVICE = 0
+PQAUTHDATA_OAUTH_BEARER_TOKEN = 1
+
+PGRES_POLLING_FAILED = 0
+PGRES_POLLING_READING = 1
+PGRES_POLLING_WRITING = 2
+PGRES_POLLING_OK = 3
+
+
+class PGPromptOAuthDevice(ctypes.Structure):
+    _fields_ = [
+        ("verification_uri", ctypes.c_char_p),
+        ("user_code", ctypes.c_char_p),
+    ]
+
+
+class PGOAuthBearerRequest(ctypes.Structure):
+    pass
+
+
+PGOAuthBearerRequest._fields_ = [
+    ("openid_configuration", ctypes.c_char_p),
+    ("scope", ctypes.c_char_p),
+    (
+        "async_",
+        ctypes.CFUNCTYPE(
+            ctypes.c_int,
+            ctypes.c_void_p,
+            ctypes.POINTER(PGOAuthBearerRequest),
+            ctypes.POINTER(ctypes.c_int),
+        ),
+    ),
+    (
+        "cleanup",
+        ctypes.CFUNCTYPE(None, ctypes.c_void_p, ctypes.POINTER(PGOAuthBearerRequest)),
+    ),
+    ("token", ctypes.c_char_p),
+    ("user", ctypes.c_void_p),
+]
+
+
[email protected]
+def auth_data_cb():
+    """
+    Tracks calls to the libpq authdata hook. The yielded object contains a calls
+    member that records the data sent to the hook. If a test needs to perform
+    custom actions during a call, it can set the yielded object's impl callback;
+    beware that the callback takes place on a different thread.
+
+    This is done differently from the other callback implementations on purpose.
+    For the others, we can declare test-specific callbacks and have them perform
+    direct assertions on the data they receive. But that won't work for a C
+    callback, because there's no way for us to bubble up the assertion through
+    libpq. Instead, this mock-style approach is taken, where we just record the
+    calls and let the test examine them later.
+    """
+
+    class _Call:
+        pass
+
+    class _cb(object):
+        def __init__(self):
+            self.calls = []
+
+    cb = _cb()
+    cb.impl = None
+
+    # The callback will occur on a different thread, so protect the cb object.
+    cb_lock = threading.Lock()
+
+    @ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_byte, ctypes.c_void_p, ctypes.c_void_p)
+    def auth_data_cb(typ, pgconn, data):
+        handle_by_default = 0  # does an implementation have to be provided?
+
+        if typ == PQAUTHDATA_PROMPT_OAUTH_DEVICE:
+            cls = PGPromptOAuthDevice
+            handle_by_default = 1
+        elif typ == PQAUTHDATA_OAUTH_BEARER_TOKEN:
+            cls = PGOAuthBearerRequest
+        else:
+            return 0
+
+        call = _Call()
+        call.type = typ
+
+        # The lifetime of the underlying data being pointed to doesn't
+        # necessarily match the lifetime of the Python object, so we can't
+        # reference a Structure's fields after returning. Explicitly copy the
+        # contents over, field by field.
+        data = ctypes.cast(data, ctypes.POINTER(cls))
+        for name, _ in cls._fields_:
+            setattr(call, name, getattr(data.contents, name))
+
+        with cb_lock:
+            cb.calls.append(call)
+
+        if cb.impl:
+            # Pass control back to the test.
+            try:
+                return cb.impl(typ, pgconn, data.contents)
+            except Exception:
+                # This can't escape into the C stack, but we can fail the flow
+                # and hope the traceback gives us enough detail.
+                logging.error(
+                    "Exception during authdata hook callback:\n"
+                    + traceback.format_exc()
+                )
+                return -1
+
+        return handle_by_default
+
+    libpq.PQsetAuthDataHook(auth_data_cb)
+    try:
+        yield cb
+    finally:
+        # The callback is about to go out of scope, so make sure libpq is
+        # disconnected from it. (We wouldn't want to accidentally influence
+        # later tests anyway.)
+        libpq.PQsetAuthDataHook(None)
+
+
[email protected](
+    "success, abnormal_failure",
+    [
+        pytest.param(True, False, id="success"),
+        pytest.param(False, False, id="normal failure"),
+        pytest.param(False, True, id="abnormal failure"),
+    ],
+)
[email protected]("secret", [None, "", "hunter2"])
[email protected]("scope", [None, "", "openid email"])
[email protected]("retries", [0, 1])
[email protected](
+    "content_type",
+    [
+        pytest.param("application/json", id="standard"),
+        pytest.param("application/json;charset=utf-8", id="charset"),
+        pytest.param("application/json \t;\t charset=utf-8", id="charset (whitespace)"),
+    ],
+)
[email protected]("uri_spelling", ["verification_url", "verification_uri"])
[email protected](
+    "asynchronous",
+    [
+        pytest.param(False, id="synchronous"),
+        pytest.param(True, id="asynchronous"),
+    ],
+)
+def test_oauth_with_explicit_discovery_uri(
+    accept,
+    openid_provider,
+    asynchronous,
+    uri_spelling,
+    content_type,
+    retries,
+    scope,
+    secret,
+    auth_data_cb,
+    success,
+    abnormal_failure,
+):
+    client_id = secrets.token_hex()
+    openid_provider.content_type = content_type
+
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id=client_id,
+        oauth_client_secret=secret,
+        oauth_scope=scope,
+        async_=asynchronous,
+    )
+
+    device_code = secrets.token_hex()
+    user_code = f"{secrets.token_hex(2)}-{secrets.token_hex(2)}"
+    verification_url = "https://example.com/device"
+
+    access_token = secrets.token_urlsafe()
+
+    def check_client_authn(headers, params):
+        if secret is None:
+            assert "Authorization" not in headers
+            assert params["client_id"] == [client_id]
+            return
+
+        # Require the client to use Basic authn; request-body credentials are
+        # NOT RECOMMENDED (RFC 6749, Sec. 2.3.1).
+        assert "Authorization" in headers
+        assert "client_id" not in params
+
+        method, creds = headers["Authorization"].split()
+        assert method == "Basic"
+
+        expected = f"{client_id}:{secret}"
+        assert base64.b64decode(creds) == expected.encode("ascii")
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        check_client_authn(headers, params)
+
+        if scope:
+            assert params["scope"] == [scope]
+        else:
+            assert "scope" not in params
+
+        resp = {
+            "device_code": device_code,
+            "user_code": user_code,
+            "interval": 0,
+            uri_spelling: verification_url,
+            "expires_in": 5,
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    attempts = 0
+    retry_lock = threading.Lock()
+
+    def token_endpoint(headers, params):
+        check_client_authn(headers, params)
+
+        assert params["grant_type"] == ["urn:ietf:params:oauth:grant-type:device_code"]
+        assert params["device_code"] == [device_code]
+
+        now = time.monotonic()
+
+        with retry_lock:
+            nonlocal attempts
+
+            # If the test wants to force the client to retry, return an
+            # authorization_pending response and decrement the retry count.
+            if attempts < retries:
+                attempts += 1
+                return 400, {"error": "authorization_pending"}
+
+        # Successfully finish the request by sending the access bearer token.
+        resp = {
+            "access_token": access_token,
+            "token_type": "bearer",
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    # First connection is a discovery request, which should result in the above
+    # endpoints being called.
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    # Client should reconnect.
+    sock, _ = accept()
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            initial = start_oauth_handshake(conn)
+
+            # Validate and accept the token.
+            auth = get_auth_value(initial)
+            assert auth == f"Bearer {access_token}".encode("ascii")
+
+            if success:
+                finish_handshake(conn)
+
+            elif abnormal_failure:
+                # Send an empty error response, which should result in a
+                # mechanism-level failure in the client. This test ensures that
+                # the client doesn't try a third connection for this case.
+                expected_error = "server sent error response without a status"
+                fail_oauth_handshake(conn, {})
+
+            else:
+                # Simulate token validation failure.
+                resp = {
+                    "status": "invalid_token",
+                    "openid-configuration": openid_provider.discovery_uri,
+                }
+                expected_error = "test token validation failure"
+                fail_oauth_handshake(conn, resp, errmsg=expected_error)
+
+    if retries:
+        # Finally, make sure that the client prompted the user once with the
+        # expected authorization URL and user code.
+        assert len(auth_data_cb.calls) == 2
+
+        # First call should have been for a custom flow, which we ignored.
+        assert auth_data_cb.calls[0].type == PQAUTHDATA_OAUTH_BEARER_TOKEN
+
+        # Second call is for our user prompt.
+        call = auth_data_cb.calls[1]
+        assert call.type == PQAUTHDATA_PROMPT_OAUTH_DEVICE
+        assert call.verification_uri.decode() == verification_url
+        assert call.user_code.decode() == user_code
+
+    if not success:
+        # The client should not try to connect again.
+        with pytest.raises(psycopg2.OperationalError, match=expected_error):
+            client.check_completed()
+
+
[email protected](
+    "server_discovery",
+    [
+        pytest.param(True, id="server discovery"),
+        pytest.param(False, id="direct discovery"),
+    ],
+)
[email protected](
+    "issuer, path",
+    [
+        pytest.param(
+            "{issuer}",
+            "/.well-known/oauth-authorization-server",
+            id="oauth",
+        ),
+        pytest.param(
+            "{issuer}/alt",
+            "/.well-known/oauth-authorization-server/alt",
+            id="oauth with path, IETF style",
+        ),
+        pytest.param(
+            "{issuer}/alt",
+            "/alt/.well-known/oauth-authorization-server",
+            id="oauth with path, broken OIDC style",
+        ),
+        pytest.param(
+            "{issuer}/alt",
+            "/alt/.well-known/openid-configuration",
+            id="openid with path, OIDC style",
+        ),
+        pytest.param(
+            "{issuer}/alt",
+            "/.well-known/openid-configuration/alt",
+            id="openid with path, IETF style",
+        ),
+        pytest.param(
+            "{issuer}/",
+            "//.well-known/openid-configuration",
+            id="empty path segment, OIDC style",
+        ),
+        pytest.param(
+            "{issuer}/",
+            "/.well-known/openid-configuration/",
+            id="empty path segment, IETF style",
+        ),
+    ],
+)
+def test_alternate_well_known_paths(
+    accept, openid_provider, issuer, path, server_discovery
+):
+    issuer = issuer.format(issuer=openid_provider.issuer)
+    discovery_uri = openid_provider.issuer + path
+
+    client_id = secrets.token_hex()
+    access_token = secrets.token_urlsafe()
+
+    def discovery_handler(*args):
+        """
+        Pass-through implementation of the discovery handler. Modifies the
+        default document to contain this test's issuer identifier.
+        """
+        code, doc = openid_provider._default_discovery_handler(*args)
+        doc["issuer"] = issuer
+        return code, doc
+
+    openid_provider.register_endpoint(None, "GET", path, discovery_handler)
+
+    def authorization_endpoint(headers, params):
+        resp = {
+            "device_code": "12345",
+            "user_code": "ABCDE",
+            "interval": 0,
+            "verification_url": "https://example.com/device",
+            "expires_in": 5,
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    def token_endpoint(headers, params):
+        # Successfully finish the request by sending the access bearer token.
+        resp = {
+            "access_token": access_token,
+            "token_type": "bearer",
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    kwargs = dict(oauth_client_id=client_id)
+    if server_discovery:
+        kwargs.update(oauth_issuer=issuer)
+    else:
+        kwargs.update(oauth_issuer=discovery_uri)
+
+    sock, client = accept(**kwargs)
+
+    with sock:
+        handle_discovery_connection(sock, discovery_uri)
+
+    # Expect the client to connect again.
+    sock, _ = accept()
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            initial = start_oauth_handshake(conn)
+
+            # Validate the token.
+            auth = get_auth_value(initial)
+            assert auth == f"Bearer {access_token}".encode("ascii")
+
+            finish_handshake(conn)
+
+
[email protected](
+    "server_discovery",
+    [
+        pytest.param(True, id="server discovery"),
+        pytest.param(False, id="direct discovery"),
+    ],
+)
[email protected](
+    "issuer, path, expected_error",
+    [
+        pytest.param(
+            "{issuer}",
+            "/.well-known/oauth-authorization-server/",
+            None,
+            id="extra empty segment (no path)",
+        ),
+        pytest.param(
+            "{issuer}/path",
+            "/.well-known/oauth-authorization-server/path/",
+            None,
+            id="extra empty segment (with path)",
+        ),
+        pytest.param(
+            "{issuer}",
+            "?/.well-known/oauth-authorization-server",
+            r'OAuth discovery URI ".*" must not contain query or fragment components',
+            id="query",
+        ),
+        pytest.param(
+            "{issuer}",
+            "#/.well-known/oauth-authorization-server",
+            r'OAuth discovery URI ".*" must not contain query or fragment components',
+            id="fragment",
+        ),
+        pytest.param(
+            "{issuer}/sub/path",
+            "/sub/.well-known/oauth-authorization-server/path",
+            r'OAuth discovery URI ".*" uses an invalid format',
+            id="sandwiched prefix",
+        ),
+        pytest.param(
+            "{issuer}/path",
+            "/path/openid-configuration",
+            r'OAuth discovery URI ".*" is not a .well-known URI',
+            id="not .well-known",
+        ),
+        pytest.param(
+            "{issuer}",
+            "https://.well-known/oauth-authorization-server",
+            r'OAuth discovery URI ".*" is not a .well-known URI',
+            id=".well-known prefix buried in the authority",
+        ),
+        pytest.param(
+            "{issuer}",
+            "/.well-known/oauth-protected-resource",
+            r'OAuth discovery URI ".*" uses an unsupported .well-known suffix',
+            id="unknown well-known suffix",
+        ),
+        pytest.param(
+            "{issuer}/path",
+            "/path/.well-known/openid-configuration-2",
+            r'OAuth discovery URI ".*" uses an unsupported .well-known suffix',
+            id="unknown well-known suffix, OIDC style",
+        ),
+        pytest.param(
+            "{issuer}/path",
+            "/.well-known/oauth-authorization-server-2/path",
+            r'OAuth discovery URI ".*" uses an unsupported .well-known suffix',
+            id="unknown well-known suffix, IETF style",
+        ),
+        pytest.param(
+            "{issuer}",
+            "file:///.well-known/oauth-authorization-server",
+            r'OAuth discovery URI ".*" must use HTTPS',
+            id="unsupported scheme",
+        ),
+    ],
+)
+def test_bad_well_known_paths(
+    accept, openid_provider, issuer, path, expected_error, server_discovery
+):
+    if not server_discovery and "/.well-known/" not in path:
+        # An oauth_issuer without a /.well-known/ path segment is just a normal
+        # issuer identifier, so this isn't an interesting test.
+        pytest.skip("not interesting: direct discovery requires .well-known")
+
+    issuer = issuer.format(issuer=openid_provider.issuer)
+    discovery_uri = urllib.parse.urljoin(openid_provider.issuer, path)
+
+    client_id = secrets.token_hex()
+
+    def discovery_handler(*args):
+        """
+        Pass-through implementation of the discovery handler. Modifies the
+        default document to contain this test's issuer identifier.
+        """
+        code, doc = openid_provider._default_discovery_handler(*args)
+        doc["issuer"] = issuer
+        return code, doc
+
+    openid_provider.register_endpoint(None, "GET", path, discovery_handler)
+
+    def fail(*args):
+        """
+        No other endpoints should be contacted; fail if the client tries.
+        """
+        assert False, "endpoint unexpectedly called"
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", fail
+    )
+    openid_provider.register_endpoint("token_endpoint", "POST", "/token", fail)
+
+    kwargs = dict(oauth_client_id=client_id)
+    if server_discovery:
+        kwargs.update(oauth_issuer=issuer)
+    else:
+        kwargs.update(oauth_issuer=discovery_uri)
+
+    sock, client = accept(**kwargs)
+    with sock:
+        if expected_error and not server_discovery:
+            # If the client already knows the URL, it should disconnect as soon
+            # as it realizes it's not valid.
+            expect_disconnected_handshake(sock)
+        else:
+            # Otherwise, it should complete the connection.
+            handle_discovery_connection(sock, discovery_uri)
+
+    # The client should not reconnect.
+
+    if expected_error is None:
+        if server_discovery:
+            expected_error = rf"server's discovery document at {discovery_uri} \(issuer \".*\"\) is incompatible with oauth_issuer \({issuer}\)"
+        else:
+            expected_error = rf"the issuer identifier \({issuer}\) does not match oauth_issuer \(.*\)"
+
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
+def expect_disconnected_handshake(sock):
+    """
+    Helper for any tests that expect the client to disconnect immediately after
+    being sent the OAUTHBEARER SASL method. Generally speaking, this requires
+    the client to have an oauth_issuer set so that it doesn't try to go through
+    discovery.
+    """
+    with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+        # Initiate a handshake.
+        startup = pq3.recv1(conn, cls=pq3.Startup)
+        assert startup.proto == pq3.protocol(3, 0)
+
+        pq3.send(
+            conn,
+            pq3.types.AuthnRequest,
+            type=pq3.authn.SASL,
+            body=[b"OAUTHBEARER", b""],
+        )
+
+        # The client should disconnect at this point.
+        assert not conn.read(1), "client sent unexpected data"
+
+
[email protected](
+    "missing",
+    [
+        pytest.param(["oauth_issuer"], id="missing oauth_issuer"),
+        pytest.param(["oauth_client_id"], id="missing oauth_client_id"),
+        pytest.param(["oauth_client_id", "oauth_issuer"], id="missing both"),
+    ],
+)
+def test_oauth_requires_issuer_and_client_id(accept, openid_provider, missing):
+    params = dict(
+        oauth_issuer=openid_provider.issuer,
+        oauth_client_id="some-id",
+    )
+
+    # Remove required parameters. This should cause a client error after the
+    # server asks for OAUTHBEARER and the client tries to contact the issuer.
+    for k in missing:
+        del params[k]
+
+    sock, client = accept(**params)
+    with sock:
+        expect_disconnected_handshake(sock)
+
+    expected_error = "oauth_issuer and oauth_client_id are not both set"
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
+# See https://datatracker.ietf.org/doc/html/rfc6749#appendix-A for character
+# class definitions.
+all_vschars = "".join([chr(c) for c in range(0x20, 0x7F)])
+all_nqchars = "".join([chr(c) for c in range(0x21, 0x7F) if c not in (0x22, 0x5C)])
+
+
[email protected]("client_id", ["", ":", " + ", r'+=&"\/~', all_vschars])
[email protected]("secret", [None, "", ":", " + ", r'+=&"\/~', all_vschars])
[email protected]("device_code", ["", " + ", r'+=&"\/~', all_vschars])
[email protected]("scope", ["&", r"+=&/", all_nqchars])
+def test_url_encoding(accept, openid_provider, client_id, secret, device_code, scope):
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id=client_id,
+        oauth_client_secret=secret,
+        oauth_scope=scope,
+    )
+
+    user_code = f"{secrets.token_hex(2)}-{secrets.token_hex(2)}"
+    verification_url = "https://example.com/device"
+
+    access_token = secrets.token_urlsafe()
+
+    def check_client_authn(headers, params):
+        if secret is None:
+            assert "Authorization" not in headers
+            assert params["client_id"] == [client_id]
+            return
+
+        # Require the client to use Basic authn; request-body credentials are
+        # NOT RECOMMENDED (RFC 6749, Sec. 2.3.1).
+        assert "Authorization" in headers
+        assert "client_id" not in params
+
+        method, creds = headers["Authorization"].split()
+        assert method == "Basic"
+
+        decoded = base64.b64decode(creds).decode("utf-8")
+        username, password = decoded.split(":", 1)
+
+        expected_username = urllib.parse.quote_plus(client_id)
+        expected_password = urllib.parse.quote_plus(secret)
+
+        assert [username, password] == [expected_username, expected_password]
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        check_client_authn(headers, params)
+
+        if scope:
+            assert params["scope"] == [scope]
+        else:
+            assert "scope" not in params
+
+        resp = {
+            "device_code": device_code,
+            "user_code": user_code,
+            "interval": 0,
+            "verification_url": verification_url,
+            "expires_in": 5,
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    def token_endpoint(headers, params):
+        check_client_authn(headers, params)
+
+        assert params["grant_type"] == ["urn:ietf:params:oauth:grant-type:device_code"]
+        assert params["device_code"] == [device_code]
+
+        # Successfully finish the request by sending the access bearer token.
+        resp = {
+            "access_token": access_token,
+            "token_type": "bearer",
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    # First connection is a discovery request, which should result in the above
+    # endpoints being called.
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    # Second connection sends the token.
+    sock, _ = accept()
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            initial = start_oauth_handshake(conn)
+
+            # Validate and accept the token.
+            auth = get_auth_value(initial)
+            assert auth == f"Bearer {access_token}".encode("ascii")
+
+            finish_handshake(conn)
+
+
[email protected]
[email protected]("error_code", ["authorization_pending", "slow_down"])
[email protected]("retries", [1, 2])
[email protected]("omit_interval", [True, False])
+def test_oauth_retry_interval(
+    accept, openid_provider, omit_interval, retries, error_code
+):
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id="some-id",
+    )
+
+    expected_retry_interval = 5 if omit_interval else 1
+    access_token = secrets.token_urlsafe()
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        resp = {
+            "device_code": "my-device-code",
+            "user_code": "my-user-code",
+            "verification_uri": "https://example.com",
+            "expires_in": 5,
+        }
+
+        if not omit_interval:
+            resp["interval"] = expected_retry_interval
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    attempts = 0
+    last_retry = None
+    retry_lock = threading.Lock()
+    token_sent = threading.Event()
+
+    def token_endpoint(headers, params):
+        now = time.monotonic()
+
+        with retry_lock:
+            nonlocal attempts, last_retry, expected_retry_interval
+
+            # Make sure the retry interval is being respected by the client.
+            if last_retry is not None:
+                interval = now - last_retry
+                assert interval >= expected_retry_interval
+
+            last_retry = now
+
+            # If the test wants to force the client to retry, return the desired
+            # error response and decrement the retry count.
+            if attempts < retries:
+                attempts += 1
+
+                # A slow_down code requires the client to additionally increase
+                # its interval by five seconds.
+                if error_code == "slow_down":
+                    expected_retry_interval += 5
+
+                return 400, {"error": error_code}
+
+        # Successfully finish the request by sending the access bearer token,
+        # and signal the main thread to continue.
+        resp = {
+            "access_token": access_token,
+            "token_type": "bearer",
+        }
+        token_sent.set()
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    # First connection is a discovery request, which should result in the above
+    # endpoints being called.
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    # At this point the client is talking to the authorization server. Wait for
+    # that to succeed so we don't run into the accept() timeout.
+    token_sent.wait()
+
+    # Client should reconnect and send the token.
+    sock, _ = accept()
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            initial = start_oauth_handshake(conn)
+
+            # Validate and accept the token.
+            auth = get_auth_value(initial)
+            assert auth == f"Bearer {access_token}".encode("ascii")
+
+            finish_handshake(conn)
+
+
[email protected]
+def self_pipe():
+    """
+    Yields a pipe fd pair.
+    """
+
+    class _Pipe:
+        pass
+
+    p = _Pipe()
+    p.readfd, p.writefd = os.pipe()
+
+    try:
+        yield p
+    finally:
+        os.close(p.readfd)
+        os.close(p.writefd)
+
+
[email protected]("scope", [None, "", "openid email"])
[email protected](
+    "retries",
+    [
+        -1,  # no async callback
+        0,  # async callback immediately returns token
+        1,  # async callback waits on altsock once
+        2,  # async callback waits on altsock twice
+    ],
+)
[email protected](
+    "asynchronous",
+    [
+        pytest.param(False, id="synchronous"),
+        pytest.param(True, id="asynchronous"),
+    ],
+)
+def test_user_defined_flow(
+    accept, auth_data_cb, self_pipe, scope, retries, asynchronous
+):
+    issuer = "http://localhost"
+    discovery_uri = issuer + "/.well-known/openid-configuration"
+    access_token = secrets.token_urlsafe()
+
+    sock, client = accept(
+        oauth_issuer=discovery_uri,
+        oauth_client_id="some-id",
+        oauth_scope=scope,
+        async_=asynchronous,
+    )
+
+    # Track callbacks.
+    attempts = 0
+    wakeup_called = False
+    cleanup_calls = 0
+    lock = threading.Lock()
+
+    def wakeup():
+        """Writes a byte to the wakeup pipe."""
+        nonlocal wakeup_called
+        with lock:
+            wakeup_called = True
+            os.write(self_pipe.writefd, b"\0")
+
+    def get_token(pgconn, request, p_altsock):
+        """
+        Async token callback. While attempts < retries, libpq will be instructed
+        to wait on the self_pipe. When attempts == retries, the token will be
+        set.
+
+        Note that assertions and exceptions raised here are allowed but not very
+        helpful, since they can't bubble through the libpq stack to be collected
+        by the test suite. Try not to rely too heavily on them.
+        """
+        # Make sure libpq passed our user data through.
+        assert request.user == 42
+
+        with lock:
+            nonlocal attempts, wakeup_called
+
+            if attempts:
+                # If we've already started the timer, we shouldn't get a
+                # call back before it trips.
+                assert wakeup_called, "authdata hook was called before the timer"
+
+                # Drain the wakeup byte.
+                os.read(self_pipe.readfd, 1)
+
+            if attempts < retries:
+                attempts += 1
+
+                # Wake up the client in a little bit of time.
+                wakeup_called = False
+                threading.Timer(0.1, wakeup).start()
+
+                # Tell libpq to wait on the other end of the wakeup pipe.
+                p_altsock[0] = self_pipe.readfd
+                return PGRES_POLLING_READING
+
+        # Done!
+        request.token = access_token.encode()
+        return PGRES_POLLING_OK
+
+    @ctypes.CFUNCTYPE(
+        ctypes.c_int,
+        ctypes.c_void_p,
+        ctypes.POINTER(PGOAuthBearerRequest),
+        ctypes.POINTER(ctypes.c_int),
+    )
+    def get_token_wrapper(pgconn, p_request, p_altsock):
+        """
+        Translation layer between C and Python for the async callback.
+        Assertions and exceptions will be swallowed at the boundary, so make
+        sure they don't escape here.
+        """
+        try:
+            return get_token(pgconn, p_request.contents, p_altsock)
+        except Exception:
+            logging.error("Exception during async callback:\n" + traceback.format_exc())
+            return PGRES_POLLING_FAILED
+
+    @ctypes.CFUNCTYPE(None, ctypes.c_void_p, ctypes.POINTER(PGOAuthBearerRequest))
+    def cleanup(pgconn, p_request):
+        """
+        Should be called exactly once per connection.
+        """
+        nonlocal cleanup_calls
+        with lock:
+            cleanup_calls += 1
+
+    def bearer_hook(typ, pgconn, request):
+        """
+        Implementation of the PQAuthDataHook, which either sets up an async
+        callback or returns the token directly, depending on the value of
+        retries.
+
+        As above, try not to rely too much on assertions/exceptions here.
+        """
+        assert typ == PQAUTHDATA_OAUTH_BEARER_TOKEN
+        request.cleanup = cleanup
+
+        if retries < 0:
+            # Special case: return a token immediately without a callback.
+            request.token = access_token.encode()
+            return 1
+
+        # Tell libpq to call us back.
+        request.async_ = get_token_wrapper
+        request.user = ctypes.c_void_p(42)  # will be checked in the callback
+        return 1
+
+    auth_data_cb.impl = bearer_hook
+
+    # Now drive the server side.
+    if retries >= 0:
+        # First connection is a discovery request, which should result in the
+        # hook being invoked.
+        with sock:
+            handle_discovery_connection(sock, discovery_uri)
+
+        # Client should reconnect to send the token.
+        sock, _ = accept()
+
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            # Initiate a handshake, which should result in our custom callback
+            # being invoked to fetch the token.
+            initial = start_oauth_handshake(conn)
+
+            # Validate and accept the token.
+            auth = get_auth_value(initial)
+            assert auth == f"Bearer {access_token}".encode("ascii")
+
+            finish_handshake(conn)
+
+    # Check the data provided to the hook.
+    assert len(auth_data_cb.calls) == 1
+
+    call = auth_data_cb.calls[0]
+    assert call.type == PQAUTHDATA_OAUTH_BEARER_TOKEN
+    assert call.openid_configuration.decode() == discovery_uri
+    assert call.scope == (None if scope is None else scope.encode())
+
+    # Make sure we clean up after ourselves when the connection is finished.
+    client.check_completed()
+    assert cleanup_calls == 1
+
+
+def alt_patterns(*patterns):
+    """
+    Just combines multiple alternative regexes into one. It's not very efficient
+    but IMO it's easier to read and maintain.
+    """
+    pat = ""
+
+    for p in patterns:
+        if pat:
+            pat += "|"
+        pat += f"({p})"
+
+    return pat
+
+
[email protected](
+    "failure_mode, error_pattern",
+    [
+        pytest.param(
+            (
+                401,
+                {
+                    "error": "invalid_client",
+                    "error_description": "client authentication failed",
+                },
+            ),
+            r"failed to obtain device authorization: client authentication failed \(invalid_client\)",
+            id="authentication failure with description",
+        ),
+        pytest.param(
+            (400, {"error": "invalid_request"}),
+            r"failed to obtain device authorization: \(invalid_request\)",
+            id="invalid request without description",
+        ),
+        pytest.param(
+            (400, {"error": "invalid_request", "padding": "x" * 1024 * 1024}),
+            r"failed to obtain device authorization: response is too large",
+            id="gigantic authz response",
+        ),
+        pytest.param(
+            (400, {}),
+            r'failed to parse token error response: field "error" is missing',
+            id="broken error response",
+        ),
+        pytest.param(
+            (401, {"error": "invalid_client"}),
+            r"failed to obtain device authorization: provider requires client authentication, and no oauth_client_secret is set \(invalid_client\)",
+            id="failed authentication without description",
+        ),
+        pytest.param(
+            (200, RawResponse(r'{ "interval": 3.5.8 }')),
+            r"failed to parse device authorization: Token .* is invalid",
+            id="non-numeric interval",
+        ),
+        pytest.param(
+            (200, RawResponse(r'{ "interval": 08 }')),
+            r"failed to parse device authorization: Token .* is invalid",
+            id="invalid numeric interval",
+        ),
+    ],
+)
+def test_oauth_device_authorization_failures(
+    accept, openid_provider, failure_mode, error_pattern
+):
+    client_id = secrets.token_hex()
+
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id=client_id,
+    )
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        return failure_mode
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    def token_endpoint(headers, params):
+        assert False, "token endpoint was invoked unexpectedly"
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    # Now make sure the client correctly failed.
+    with pytest.raises(psycopg2.OperationalError, match=error_pattern):
+        client.check_completed()
+
+
+Missing = object()  # sentinel for test_oauth_device_authorization_bad_json()
+
+
[email protected](
+    "bad_value",
+    [
+        pytest.param({"device_code": 3}, id="object"),
+        pytest.param([1, 2, 3], id="array"),
+        pytest.param("some string", id="string"),
+        pytest.param(4, id="numeric"),
+        pytest.param(False, id="boolean"),
+        pytest.param(None, id="null"),
+        pytest.param(Missing, id="missing"),
+    ],
+)
[email protected](
+    "field_name,ok_type,required",
+    [
+        ("device_code", str, True),
+        ("user_code", str, True),
+        ("verification_uri", str, True),
+        ("interval", int, False),
+    ],
+)
+def test_oauth_device_authorization_bad_json_schema(
+    accept, openid_provider, field_name, ok_type, required, bad_value
+):
+    # To make the test matrix easy, just skip the tests that aren't actually
+    # interesting (field of the correct type, missing optional field).
+    if bad_value is Missing and not required:
+        pytest.skip("not interesting: optional field")
+    elif type(bad_value) == ok_type:  # not isinstance(), because bool is an int
+        pytest.skip("not interesting: correct type")
+
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id=secrets.token_hex(),
+    )
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        # Begin with an acceptable base response...
+        resp = {
+            "device_code": "my-device-code",
+            "user_code": "my-user-code",
+            "interval": 0,
+            "verification_uri": "https://example.com",
+            "expires_in": 5,
+        }
+
+        # ...then tweak it so the client fails.
+        if bad_value is Missing:
+            del resp[field_name]
+        else:
+            resp[field_name] = bad_value
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    def token_endpoint(headers, params):
+        assert False, "token endpoint was invoked unexpectedly"
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    # Now make sure the client correctly failed.
+    if bad_value is Missing:
+        error_pattern = f'field "{field_name}" is missing'
+    elif ok_type == str:
+        error_pattern = f'field "{field_name}" must be a string'
+    elif ok_type == int:
+        error_pattern = f'field "{field_name}" must be a number'
+    else:
+        assert False, "update error_pattern for new failure mode"
+
+    with pytest.raises(psycopg2.OperationalError, match=error_pattern):
+        client.check_completed()
+
+
[email protected](
+    "failure_mode, error_pattern",
+    [
+        pytest.param(
+            (
+                400,
+                {
+                    "error": "expired_token",
+                    "error_description": "the device code has expired",
+                },
+            ),
+            r"failed to obtain access token: the device code has expired \(expired_token\)",
+            id="expired token with description",
+        ),
+        pytest.param(
+            (400, {"error": "access_denied"}),
+            r"failed to obtain access token: \(access_denied\)",
+            id="access denied without description",
+        ),
+        pytest.param(
+            (400, {"error": "access_denied", "padding": "x" * 1024 * 1024}),
+            r"failed to obtain access token: response is too large",
+            id="gigantic token response",
+        ),
+        pytest.param(
+            (400, {}),
+            r'failed to parse token error response: field "error" is missing',
+            id="empty error response",
+        ),
+        pytest.param(
+            (401, {"error": "invalid_client"}),
+            r"failed to obtain access token: provider requires client authentication, and no oauth_client_secret is set \(invalid_client\)",
+            id="authentication failure without description",
+        ),
+        pytest.param(
+            (200, {}, {}),
+            r"failed to parse access token response: no content type was provided",
+            id="missing content type",
+        ),
+        pytest.param(
+            (200, {"Content-Type": "text/plain"}, {}),
+            r"failed to parse access token response: unexpected content type",
+            id="wrong content type",
+        ),
+        pytest.param(
+            (200, {"Content-Type": "application/jsonx"}, {}),
+            r"failed to parse access token response: unexpected content type",
+            id="wrong content type (correct prefix)",
+        ),
+    ],
+)
[email protected]("retries", [0, 1])
+def test_oauth_token_failures(
+    accept, openid_provider, retries, failure_mode, error_pattern
+):
+    client_id = secrets.token_hex()
+
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id=client_id,
+    )
+
+    device_code = secrets.token_hex()
+    user_code = f"{secrets.token_hex(2)}-{secrets.token_hex(2)}"
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        assert params["client_id"] == [client_id]
+
+        resp = {
+            "device_code": device_code,
+            "user_code": user_code,
+            "interval": 0,
+            "verification_uri": "https://example.com/device",
+            "expires_in": 5,
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    retry_lock = threading.Lock()
+    final_sent = False
+
+    def token_endpoint(headers, params):
+        with retry_lock:
+            nonlocal retries, final_sent
+
+            # If the test wants to force the client to retry, return an
+            # authorization_pending response and decrement the retry count.
+            if retries > 0:
+                retries -= 1
+                return 400, {"error": "authorization_pending"}
+
+            # We should only return our failure_mode response once; any further
+            # requests indicate that the client isn't correctly bailing out.
+            assert not final_sent, "client continued after token error"
+
+            final_sent = True
+
+        return failure_mode
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    # Now make sure the client correctly failed.
+    with pytest.raises(psycopg2.OperationalError, match=error_pattern):
+        client.check_completed()
+
+
[email protected](
+    "bad_value",
+    [
+        pytest.param({"device_code": 3}, id="object"),
+        pytest.param([1, 2, 3], id="array"),
+        pytest.param("some string", id="string"),
+        pytest.param(4, id="numeric"),
+        pytest.param(False, id="boolean"),
+        pytest.param(None, id="null"),
+        pytest.param(Missing, id="missing"),
+    ],
+)
[email protected](
+    "field_name,ok_type,required",
+    [
+        ("access_token", str, True),
+        ("token_type", str, True),
+    ],
+)
+def test_oauth_token_bad_json_schema(
+    accept, openid_provider, field_name, ok_type, required, bad_value
+):
+    # To make the test matrix easy, just skip the tests that aren't actually
+    # interesting (field of the correct type, missing optional field).
+    if bad_value is Missing and not required:
+        pytest.skip("not interesting: optional field")
+    elif type(bad_value) == ok_type:  # not isinstance(), because bool is an int
+        pytest.skip("not interesting: correct type")
+
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id=secrets.token_hex(),
+    )
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        resp = {
+            "device_code": "my-device-code",
+            "user_code": "my-user-code",
+            "interval": 0,
+            "verification_uri": "https://example.com",
+            "expires_in": 5,
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    def token_endpoint(headers, params):
+        # Begin with an acceptable base response...
+        resp = {
+            "access_token": secrets.token_urlsafe(),
+            "token_type": "bearer",
+        }
+
+        # ...then tweak it so the client fails.
+        if bad_value is Missing:
+            del resp[field_name]
+        else:
+            resp[field_name] = bad_value
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    # Now make sure the client correctly failed.
+    error_pattern = "failed to parse access token response: "
+    if bad_value is Missing:
+        error_pattern += f'field "{field_name}" is missing'
+    elif ok_type == str:
+        error_pattern += f'field "{field_name}" must be a string'
+    elif ok_type == int:
+        error_pattern += f'field "{field_name}" must be a number'
+    else:
+        assert False, "update error_pattern for new failure mode"
+
+    with pytest.raises(psycopg2.OperationalError, match=error_pattern):
+        client.check_completed()
+
+
[email protected]("success", [True, False])
[email protected]("scope", [None, "openid email"])
[email protected](
+    "base_response",
+    [
+        {"status": "invalid_token"},
+        {"extra_object": {"key": "value"}, "status": "invalid_token"},
+        {"extra_object": {"status": 1}, "status": "invalid_token"},
+    ],
+)
+def test_oauth_discovery(accept, openid_provider, base_response, scope, success):
+    sock, client = accept(
+        oauth_issuer=openid_provider.issuer,
+        oauth_client_id=secrets.token_hex(),
+    )
+
+    device_code = secrets.token_hex()
+    user_code = f"{secrets.token_hex(2)}-{secrets.token_hex(2)}"
+    verification_url = "https://example.com/device"
+
+    access_token = secrets.token_urlsafe()
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        if scope:
+            assert params["scope"] == [scope]
+        else:
+            assert "scope" not in params
+
+        resp = {
+            "device_code": device_code,
+            "user_code": user_code,
+            "interval": 0,
+            "verification_uri": verification_url,
+            "expires_in": 5,
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    def token_endpoint(headers, params):
+        assert params["grant_type"] == ["urn:ietf:params:oauth:grant-type:device_code"]
+        assert params["device_code"] == [device_code]
+
+        # Successfully finish the request by sending the access bearer token.
+        resp = {
+            "access_token": access_token,
+            "token_type": "bearer",
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    # Construct the response to use when failing the SASL exchange. Return a
+    # link to the discovery document, pointing to the test provider server.
+    fail_resp = {
+        **base_response,
+        "openid-configuration": openid_provider.discovery_uri,
+    }
+
+    if scope:
+        fail_resp["scope"] = scope
+
+    with sock:
+        handle_discovery_connection(sock, response=fail_resp)
+
+    # The client will connect to us a second time, using the parameters we sent
+    # it.
+    sock, _ = accept()
+
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            initial = start_oauth_handshake(conn)
+
+            # Validate the token.
+            auth = get_auth_value(initial)
+            assert auth == f"Bearer {access_token}".encode("ascii")
+
+            if success:
+                finish_handshake(conn)
+
+            else:
+                # Simulate token validation failure.
+                expected_error = "test token validation failure"
+                fail_oauth_handshake(conn, fail_resp, errmsg=expected_error)
+
+    if not success:
+        # The client should not try to connect again.
+        with pytest.raises(psycopg2.OperationalError, match=expected_error):
+            client.check_completed()
+
+
[email protected](
+    "response,expected_error",
+    [
+        pytest.param(
+            "abcde",
+            'Token "abcde" is invalid',
+            id="bad JSON: invalid syntax",
+        ),
+        pytest.param(
+            b"\xFF\xFF\xFF\xFF",
+            "server's error response is not valid UTF-8",
+            id="bad JSON: invalid encoding",
+        ),
+        pytest.param(
+            '"abcde"',
+            "top-level element must be an object",
+            id="bad JSON: top-level element is a string",
+        ),
+        pytest.param(
+            "[]",
+            "top-level element must be an object",
+            id="bad JSON: top-level element is an array",
+        ),
+        pytest.param(
+            "{}",
+            "server sent error response without a status",
+            id="bad JSON: no status member",
+        ),
+        pytest.param(
+            '{ "status": null }',
+            'field "status" must be a string',
+            id="bad JSON: null status member",
+        ),
+        pytest.param(
+            '{ "status": 0 }',
+            'field "status" must be a string',
+            id="bad JSON: int status member",
+        ),
+        pytest.param(
+            '{ "status": [ "bad" ] }',
+            'field "status" must be a string',
+            id="bad JSON: array status member",
+        ),
+        pytest.param(
+            '{ "status": { "bad": "bad" } }',
+            'field "status" must be a string',
+            id="bad JSON: object status member",
+        ),
+        pytest.param(
+            '{ "nested": { "status": "bad" } }',
+            "server sent error response without a status",
+            id="bad JSON: nested status",
+        ),
+        pytest.param(
+            '{ "status": "invalid_token" ',
+            "The input string ended unexpectedly",
+            id="bad JSON: unterminated object",
+        ),
+        pytest.param(
+            '{ "status": "invalid_token" } { }',
+            'Expected end of input, but found "{"',
+            id="bad JSON: trailing data",
+        ),
+        pytest.param(
+            '{ "status": "invalid_token", "openid-configuration": 1 }',
+            'field "openid-configuration" must be a string',
+            id="bad JSON: int openid-configuration member",
+        ),
+        pytest.param(
+            '{ "status": "invalid_token", "openid-configuration": 1 }',
+            'field "openid-configuration" must be a string',
+            id="bad JSON: int openid-configuration member",
+        ),
+        pytest.param(
+            '{ "status": "invalid_token", "openid-configuration": "", "openid-configuration": "" }',
+            'field "openid-configuration" is duplicated',
+            id="bad JSON: duplicated field",
+        ),
+        pytest.param(
+            '{ "status": "invalid_token", "scope": 1 }',
+            'field "scope" must be a string',
+            id="bad JSON: int scope member",
+        ),
+    ],
+)
+def test_oauth_discovery_server_error(accept, response, expected_error):
+    sock, client = accept(
+        oauth_issuer="https://example.com",
+        oauth_client_id=secrets.token_hex(),
+    )
+
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            initial = start_oauth_handshake(conn)
+
+            if isinstance(response, str):
+                response = response.encode("utf-8")
+
+            # Fail the SASL exchange with an invalid JSON response.
+            pq3.send(
+                conn,
+                pq3.types.AuthnRequest,
+                type=pq3.authn.SASLContinue,
+                body=response,
+            )
+
+            # The client should disconnect, so the socket is closed here. (If
+            # the client doesn't disconnect, it will report a different error
+            # below and the test will fail.)
+
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
+# All of these tests are expected to fail before libpq tries to actually attempt
+# a connection to any endpoint. To avoid hitting the network in the event that a
+# test fails, an invalid IPv4 address (256.256.256.256) is used as a hostname.
[email protected](
+    "bad_response,expected_error",
+    [
+        pytest.param(
+            (200, {"Content-Type": "text/plain"}, {}),
+            r'failed to parse OpenID discovery document: unexpected content type: "text/plain"',
+            id="not JSON",
+        ),
+        pytest.param(
+            (200, {}, {}),
+            r"failed to parse OpenID discovery document: no content type was provided",
+            id="no Content-Type",
+        ),
+        pytest.param(
+            (204, {}, None),
+            r"failed to fetch OpenID discovery document: unexpected response code 204",
+            id="no content",
+        ),
+        pytest.param(
+            (301, {"Location": "https://localhost/"}, None),
+            r"failed to fetch OpenID discovery document: unexpected response code 301",
+            id="redirection",
+        ),
+        pytest.param(
+            (404, {}),
+            r"failed to fetch OpenID discovery document: unexpected response code 404",
+            id="not found",
+        ),
+        pytest.param(
+            (200, RawResponse("blah\x00blah")),
+            r"failed to parse OpenID discovery document: response contains embedded NULLs",
+            id="NULL bytes in document",
+        ),
+        pytest.param(
+            (200, RawBytes(b"blah\xFFblah")),
+            r"failed to parse OpenID discovery document: response is not valid UTF-8",
+            id="document is not UTF-8",
+        ),
+        pytest.param(
+            (200, 123),
+            r"failed to parse OpenID discovery document: top-level element must be an object",
+            id="scalar at top level",
+        ),
+        pytest.param(
+            (200, []),
+            r"failed to parse OpenID discovery document: top-level element must be an object",
+            id="array at top level",
+        ),
+        pytest.param(
+            (200, RawResponse("{")),
+            r"failed to parse OpenID discovery document.* input string ended unexpectedly",
+            id="unclosed object",
+        ),
+        pytest.param(
+            (200, RawResponse(r'{ "hello": ] }')),
+            r"failed to parse OpenID discovery document.* Expected JSON value",
+            id="bad array",
+        ),
+        pytest.param(
+            (200, {"issuer": 123}),
+            r'failed to parse OpenID discovery document: field "issuer" must be a string',
+            id="non-string issuer",
+        ),
+        pytest.param(
+            (200, {"issuer": ["something"]}),
+            r'failed to parse OpenID discovery document: field "issuer" must be a string',
+            id="issuer array",
+        ),
+        pytest.param(
+            (200, {"issuer": {}}),
+            r'failed to parse OpenID discovery document: field "issuer" must be a string',
+            id="issuer object",
+        ),
+        pytest.param(
+            (200, {"grant_types_supported": 123}),
+            r'failed to parse OpenID discovery document: field "grant_types_supported" must be an array of strings',
+            id="numeric grant types field",
+        ),
+        pytest.param(
+            (
+                200,
+                {
+                    "grant_types_supported": "urn:ietf:params:oauth:grant-type:device_code"
+                },
+            ),
+            r'failed to parse OpenID discovery document: field "grant_types_supported" must be an array of strings',
+            id="string grant types field",
+        ),
+        pytest.param(
+            (200, {"grant_types_supported": {}}),
+            r'failed to parse OpenID discovery document: field "grant_types_supported" must be an array of strings',
+            id="object grant types field",
+        ),
+        pytest.param(
+            (200, {"grant_types_supported": [123]}),
+            r'failed to parse OpenID discovery document: field "grant_types_supported" must be an array of strings',
+            id="non-string grant types",
+        ),
+        pytest.param(
+            (200, {"grant_types_supported": ["something", 123]}),
+            r'failed to parse OpenID discovery document: field "grant_types_supported" must be an array of strings',
+            id="non-string grant types later in the list",
+        ),
+        pytest.param(
+            (200, {"grant_types_supported": ["something", {}]}),
+            r'failed to parse OpenID discovery document: field "grant_types_supported" must be an array of strings',
+            id="object grant types later in the list",
+        ),
+        pytest.param(
+            (200, {"grant_types_supported": ["something", ["something"]]}),
+            r'failed to parse OpenID discovery document: field "grant_types_supported" must be an array of strings',
+            id="embedded array grant types later in the list",
+        ),
+        pytest.param(
+            (
+                200,
+                {
+                    "grant_types_supported": ["something"],
+                    "token_endpoint": "https://256.256.256.256/",
+                    "issuer": 123,
+                },
+            ),
+            r'failed to parse OpenID discovery document: field "issuer" must be a string',
+            id="non-string issuer after other valid fields",
+        ),
+        pytest.param(
+            (
+                200,
+                {
+                    "ignored": {"grant_types_supported": 123, "token_endpoint": 123},
+                    "issuer": 123,
+                },
+            ),
+            r'failed to parse OpenID discovery document: field "issuer" must be a string',
+            id="non-string issuer after other ignored fields",
+        ),
+        pytest.param(
+            (200, {"token_endpoint": "https://256.256.256.256/"}),
+            r'failed to parse OpenID discovery document: field "issuer" is missing',
+            id="missing issuer",
+        ),
+        pytest.param(
+            (200, {"issuer": "{issuer}"}),
+            r'failed to parse OpenID discovery document: field "token_endpoint" is missing',
+            id="missing token endpoint",
+        ),
+        pytest.param(
+            (
+                200,
+                {
+                    "issuer": "{issuer}",
+                    "token_endpoint": "https://256.256.256.256/token",
+                    "grant_types_supported": [
+                        "urn:ietf:params:oauth:grant-type:device_code"
+                    ],
+                },
+            ),
+            r'cannot run OAuth device authorization: issuer "https://.*" does not provide a device authorization endpoint',
+            id="missing device_authorization_endpoint",
+        ),
+        pytest.param(
+            (
+                200,
+                {
+                    "issuer": "{issuer}",
+                    "token_endpoint": "https://256.256.256.256/token",
+                    "grant_types_supported": [
+                        "urn:ietf:params:oauth:grant-type:device_code"
+                    ],
+                    "device_authorization_endpoint": "https://256.256.256.256/dev",
+                    "filler": "x" * 1024 * 1024,
+                },
+            ),
+            r"failed to fetch OpenID discovery document: response is too large",
+            id="gigantic discovery response",
+        ),
+        pytest.param(
+            (
+                200,
+                {
+                    "issuer": "{issuer}/path",
+                    "token_endpoint": "https://256.256.256.256/token",
+                    "grant_types_supported": [
+                        "urn:ietf:params:oauth:grant-type:device_code"
+                    ],
+                    "device_authorization_endpoint": "https://256.256.256.256/dev",
+                },
+            ),
+            r"failed to parse OpenID discovery document: the issuer identifier \(https://.*/path\) does not match oauth_issuer \(https://.*\)",
+            id="mismatched issuer identifier",
+        ),
+        pytest.param(
+            (
+                200,
+                RawResponse(
+                    """{
+                        "issuer": "https://256.256.256.256/path",
+                        "token_endpoint": "https://256.256.256.256/token",
+                        "grant_types_supported": [
+                            "urn:ietf:params:oauth:grant-type:device_code"
+                        ],
+                        "device_authorization_endpoint": "https://256.256.256.256/dev",
+                        "device_authorization_endpoint": "https://256.256.256.256/dev"
+                    }"""
+                ),
+            ),
+            r'failed to parse OpenID discovery document: field "device_authorization_endpoint" is duplicated',
+            id="duplicated field",
+        ),
+        #
+        # Exercise HTTP-level failures by breaking the protocol. Note that the
+        # error messages here are implementation-dependent.
+        #
+        pytest.param(
+            (1000, {}),
+            r"failed to fetch OpenID discovery document: Unsupported protocol \(.*\)",
+            id="invalid HTTP response code",
+        ),
+        pytest.param(
+            (200, {"Content-Length": -1}, {}),
+            r"failed to fetch OpenID discovery document: Weird server reply \(.*Content-Length.*\)",
+            id="bad HTTP Content-Length",
+        ),
+    ],
+)
+def test_oauth_discovery_provider_failure(
+    accept, openid_provider, bad_response, expected_error
+):
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id=secrets.token_hex(),
+    )
+
+    def failing_discovery_handler(headers, params):
+        try:
+            # Insert the correct issuer value if the test wants to.
+            resp = bad_response[1]
+            iss = resp["issuer"]
+            resp["issuer"] = iss.format(issuer=openid_provider.issuer)
+        except (AttributeError, KeyError, TypeError):
+            pass
+
+        return bad_response
+
+    openid_provider.register_endpoint(
+        None,
+        "GET",
+        "/.well-known/openid-configuration",
+        failing_discovery_handler,
+    )
+
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
[email protected](
+    "sasl_err,resp_type,resp_payload,expected_error",
+    [
+        pytest.param(
+            {"status": "invalid_request"},
+            pq3.types.ErrorResponse,
+            dict(
+                fields=[b"SFATAL", b"C28000", b"Mexpected error message", b""],
+            ),
+            "server rejected OAuth bearer token: invalid_request",
+            id="standard server error: invalid_request",
+        ),
+        pytest.param(
+            {"status": "invalid_token"},
+            pq3.types.ErrorResponse,
+            dict(
+                fields=[b"SFATAL", b"C28000", b"Mexpected error message", b""],
+            ),
+            "expected error message",
+            id="standard server error: invalid_token without discovery URI",
+        ),
+        pytest.param(
+            {"status": "invalid_token", "openid-configuration": ""},
+            pq3.types.AuthnRequest,
+            dict(type=pq3.authn.SASLContinue, body=b""),
+            "server sent additional OAuth data",
+            id="broken server: additional challenge after error",
+        ),
+        pytest.param(
+            {"status": "invalid_token", "openid-configuration": ""},
+            pq3.types.AuthnRequest,
+            dict(type=pq3.authn.SASLFinal),
+            "server sent additional OAuth data",
+            id="broken server: SASL success after error",
+        ),
+        pytest.param(
+            {"status": "invalid_token", "openid-configuration": ""},
+            pq3.types.AuthnRequest,
+            dict(type=pq3.authn.SASL, body=[b"OAUTHBEARER", b""]),
+            "duplicate SASL authentication request",
+            id="broken server: SASL reinitialization after error",
+        ),
+    ],
+)
+def test_oauth_server_error(
+    accept, auth_data_cb, sasl_err, resp_type, resp_payload, expected_error
+):
+    wkuri = f"https://256.256.256.256/.well-known/openid-configuration"
+    sock, client = accept(
+        oauth_issuer=wkuri,
+        oauth_client_id="some-id",
+    )
+
+    def bearer_hook(typ, pgconn, request):
+        """
+        Implementation of the PQAuthDataHook, which returns a token directly so
+        we don't need an openid_provider instance.
+        """
+        assert typ == PQAUTHDATA_OAUTH_BEARER_TOKEN
+        request.token = secrets.token_urlsafe().encode()
+        return 1
+
+    auth_data_cb.impl = bearer_hook
+
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            start_oauth_handshake(conn)
+
+            # Ignore the client data. Return an error "challenge".
+            if "openid-configuration" in sasl_err:
+                sasl_err["openid-configuration"] = wkuri
+
+            resp = json.dumps(sasl_err)
+            resp = resp.encode("utf-8")
+
+            pq3.send(
+                conn, pq3.types.AuthnRequest, type=pq3.authn.SASLContinue, body=resp
+            )
+
+            # Per RFC, the client is required to send a dummy ^A response.
+            pkt = pq3.recv1(conn)
+            assert pkt.type == pq3.types.PasswordMessage
+            assert pkt.payload == b"\x01"
+
+            # Now fail the SASL exchange (in either a valid way, or an
+            # invalid one, depending on the test).
+            pq3.send(conn, resp_type, **resp_payload)
+
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
+def test_oauth_interval_overflow(accept, openid_provider):
+    """
+    A really badly behaved server could send a huge interval and then
+    immediately tell us to slow_down; ensure we handle this without breaking.
+    """
+    # (should be equivalent to the INT_MAX in limits.h)
+    int_max = ctypes.c_uint(-1).value // 2
+
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id=secrets.token_hex(),
+    )
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        resp = {
+            "device_code": "my-device-code",
+            "user_code": "my-user-code",
+            "verification_uri": "https://example.com",
+            "expires_in": 5,
+            "interval": int_max,
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    def token_endpoint(headers, params):
+        return 400, {"error": "slow_down"}
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    expected_error = "slow_down interval overflow"
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
+def test_oauth_refuses_http(accept, openid_provider, monkeypatch):
+    """
+    HTTP must be refused without PGOAUTHDEBUG.
+    """
+    monkeypatch.delenv("PGOAUTHDEBUG")
+
+    def to_http(uri):
+        """Swaps out a URI's scheme for http."""
+        parts = urllib.parse.urlparse(uri)
+        parts = parts._replace(scheme="http")
+        return urllib.parse.urlunparse(parts)
+
+    sock, client = accept(
+        oauth_issuer=to_http(openid_provider.issuer),
+        oauth_client_id=secrets.token_hex(),
+    )
+
+    # No provider callbacks necessary; we should fail immediately.
+
+    with sock:
+        handle_discovery_connection(sock, to_http(openid_provider.discovery_uri))
+
+    expected_error = r'OAuth discovery URI ".*" must use HTTPS'
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
[email protected]("auth_type", [pq3.authn.OK, pq3.authn.SASLFinal])
+def test_discovery_incorrectly_permits_connection(accept, auth_type):
+    """
+    Incorrectly responds to a client's discovery request with AuthenticationOK
+    or AuthenticationSASLFinal. require_auth=oauth should catch the former, and
+    the mechanism itself should catch the latter.
+    """
+    issuer = "https://256.256.256.256"
+    sock, client = accept(
+        oauth_issuer=issuer,
+        oauth_client_id=secrets.token_hex(),
+        require_auth="oauth",
+    )
+
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            initial = start_oauth_handshake(conn)
+
+            auth = get_auth_value(initial)
+            assert auth == b""
+
+            # Incorrectly log the client in. It should immediately disconnect.
+            pq3.send(conn, pq3.types.AuthnRequest, type=auth_type)
+            assert not conn.read(1), "client sent unexpected data"
+
+    if auth_type == pq3.authn.OK:
+        expected_error = "server did not complete authentication"
+    else:
+        expected_error = "server sent unexpected additional OAuth data"
+
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
+def test_no_discovery_url_provided(accept):
+    """
+    Tests what happens when the client doesn't know who to contact and the
+    server doesn't tell it.
+    """
+    issuer = "https://256.256.256.256"
+    sock, client = accept(
+        oauth_issuer=issuer,
+        oauth_client_id=secrets.token_hex(),
+    )
+
+    with sock:
+        handle_discovery_connection(sock, discovery=None)
+
+    expected_error = "no discovery metadata was provided"
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
[email protected]("change_between_connections", [False, True])
+def test_discovery_url_changes(accept, openid_provider, change_between_connections):
+    """
+    Ensures that the client complains if the server agrees on the issuer, but
+    disagrees on the discovery URL to be used.
+    """
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        resp = {
+            "device_code": "DEV",
+            "user_code": "USER",
+            "interval": 0,
+            "verification_uri": "https://example.org",
+            "expires_in": 5,
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    def token_endpoint(headers, params):
+        resp = {
+            "access_token": secrets.token_urlsafe(),
+            "token_type": "bearer",
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    # Have the client connect.
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id="some-id",
+    )
+
+    other_wkuri = f"{openid_provider.issuer}/.well-known/oauth-authorization-server"
+
+    if not change_between_connections:
+        # Immediately respond with the wrong URL.
+        with sock:
+            handle_discovery_connection(sock, other_wkuri)
+
+    else:
+        # First connection; use the right URL to begin with.
+        with sock:
+            handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+        # Second connection. Reject the token and switch the URL.
+        sock, _ = accept()
+        with sock:
+            with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+                initial = start_oauth_handshake(conn)
+                get_auth_value(initial)
+
+                # Ignore the token; fail with a different discovery URL.
+                resp = {
+                    "status": "invalid_token",
+                    "openid-configuration": other_wkuri,
+                }
+                fail_oauth_handshake(conn, resp)
+
+    expected_error = rf"server's discovery document has moved to {other_wkuri} \(previous location was {openid_provider.discovery_uri}\)"
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
diff --git a/src/test/python/conftest.py b/src/test/python/conftest.py
new file mode 100644
index 0000000000..1a73865ee4
--- /dev/null
+++ b/src/test/python/conftest.py
@@ -0,0 +1,34 @@
+#
+# Copyright 2023 Timescale, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import os
+
+import pytest
+
+
+def pytest_addoption(parser):
+    """
+    Adds custom command line options to py.test. We add one to signal temporary
+    Postgres instance creation for the server tests.
+
+    Per pytest documentation, this must live in the top level test directory.
+    """
+    parser.addoption(
+        "--temp-instance",
+        metavar="DIR",
+        help="create a temporary Postgres instance in DIR",
+    )
+
+
[email protected](scope="session", autouse=True)
+def _check_PG_TEST_EXTRA(request):
+    """
+    Automatically skips the whole suite if PG_TEST_EXTRA doesn't contain
+    'python'. pytestmark doesn't seem to work in a top-level conftest.py, so
+    I've made this an autoused fixture instead.
+    """
+    extra_tests = os.getenv("PG_TEST_EXTRA", "").split()
+    if "python" not in extra_tests:
+        pytest.skip("Potentially unsafe test 'python' not enabled in PG_TEST_EXTRA")
diff --git a/src/test/python/meson.build b/src/test/python/meson.build
new file mode 100644
index 0000000000..e137df852e
--- /dev/null
+++ b/src/test/python/meson.build
@@ -0,0 +1,47 @@
+# Copyright (c) 2023, PostgreSQL Global Development Group
+
+subdir('server')
+
+pytest_env = {
+  'with_libcurl': libcurl.found() ? 'yes' : 'no',
+
+  # Point to the default database; the tests will create their own databases as
+  # needed.
+  'PGDATABASE': 'postgres',
+
+  # Avoid the need for a Rust compiler on platforms without prebuilt wheels for
+  # pyca/cryptography.
+  'CRYPTOGRAPHY_DONT_BUILD_RUST': '1',
+}
+
+# Some modules (psycopg2) need OpenSSL at compile time; for platforms where we
+# might have multiple implementations installed (macOS+brew), try to use the
+# same one that libpq is using.
+if ssl.found()
+  pytest_incdir = ssl.get_variable(pkgconfig: 'includedir', default_value: '')
+  if pytest_incdir != ''
+    pytest_env += { 'CPPFLAGS': '-I@0@'.format(pytest_incdir) }
+  endif
+
+  pytest_libdir = ssl.get_variable(pkgconfig: 'libdir', default_value: '')
+  if pytest_libdir != ''
+    pytest_env += { 'LDFLAGS': '-L@0@'.format(pytest_libdir) }
+  endif
+endif
+
+tests += {
+  'name': 'python',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'pytest': {
+	'requirements': meson.current_source_dir() / 'requirements.txt',
+    'tests': [
+      './client',
+      './server',
+      './test_internals.py',
+      './test_pq3.py',
+    ],
+    'env': pytest_env,
+    'test_kwargs': {'priority': 50}, # python tests are slow, start early
+  },
+}
diff --git a/src/test/python/pq3.py b/src/test/python/pq3.py
new file mode 100644
index 0000000000..ef809e288a
--- /dev/null
+++ b/src/test/python/pq3.py
@@ -0,0 +1,740 @@
+#
+# Copyright 2021 VMware, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import contextlib
+import getpass
+import io
+import os
+import platform
+import ssl
+import sys
+import textwrap
+
+from construct import *
+
+import tls
+
+
+def protocol(major, minor):
+    """
+    Returns the protocol version, in integer format, corresponding to the given
+    major and minor version numbers.
+    """
+    return (major << 16) | minor
+
+
+# Startup
+
+StringList = GreedyRange(NullTerminated(GreedyBytes))
+
+
+class KeyValueAdapter(Adapter):
+    """
+    Turns a key-value store into a null-terminated list of null-terminated
+    strings, as presented on the wire in the startup packet.
+    """
+
+    def _encode(self, obj, context, path):
+        if isinstance(obj, list):
+            return obj
+
+        l = []
+
+        for k, v in obj.items():
+            if isinstance(k, str):
+                k = k.encode("utf-8")
+            l.append(k)
+
+            if isinstance(v, str):
+                v = v.encode("utf-8")
+            l.append(v)
+
+        l.append(b"")
+        return l
+
+    def _decode(self, obj, context, path):
+        # TODO: turn a list back into a dict
+        return obj
+
+
+KeyValues = KeyValueAdapter(StringList)
+
+_startup_payload = Switch(
+    this.proto,
+    {
+        protocol(3, 0): KeyValues,
+    },
+    default=GreedyBytes,
+)
+
+
+def _default_protocol(this):
+    try:
+        if isinstance(this.payload, (list, dict)):
+            return protocol(3, 0)
+    except AttributeError:
+        pass  # no payload passed during build
+
+    return 0
+
+
+def _startup_payload_len(this):
+    """
+    The payload field has a fixed size based on the length of the packet. But
+    if the caller hasn't supplied an explicit length at build time, we have to
+    build the payload to figure out how long it is, which requires us to know
+    the length first... This function exists solely to break the cycle.
+    """
+    assert this._building, "_startup_payload_len() cannot be called during parsing"
+
+    try:
+        payload = this.payload
+    except AttributeError:
+        return 0  # no payload
+
+    if isinstance(payload, bytes):
+        # already serialized; just use the given length
+        return len(payload)
+
+    try:
+        proto = this.proto
+    except AttributeError:
+        proto = _default_protocol(this)
+
+    data = _startup_payload.build(payload, proto=proto)
+    return len(data)
+
+
+Startup = Struct(
+    "len" / Default(Int32sb, lambda this: _startup_payload_len(this) + 8),
+    "proto" / Default(Hex(Int32sb), _default_protocol),
+    "payload" / FixedSized(this.len - 8, Default(_startup_payload, b"")),
+)
+
+# Pq3
+
+
+# Adapted from construct.core.EnumIntegerString
+class EnumNamedByte:
+    def __init__(self, val, name):
+        self._val = val
+        self._name = name
+
+    def __int__(self):
+        return ord(self._val)
+
+    def __str__(self):
+        return "(enum) %s %r" % (self._name, self._val)
+
+    def __repr__(self):
+        return "EnumNamedByte(%r)" % self._val
+
+    def __eq__(self, other):
+        if isinstance(other, EnumNamedByte):
+            other = other._val
+        if not isinstance(other, bytes):
+            return NotImplemented
+
+        return self._val == other
+
+    def __hash__(self):
+        return hash(self._val)
+
+
+# Adapted from construct.core.Enum
+class ByteEnum(Adapter):
+    def __init__(self, **mapping):
+        super(ByteEnum, self).__init__(Byte)
+        self.namemapping = {k: EnumNamedByte(v, k) for k, v in mapping.items()}
+        self.decmapping = {v: EnumNamedByte(v, k) for k, v in mapping.items()}
+
+    def __getattr__(self, name):
+        if name in self.namemapping:
+            return self.decmapping[self.namemapping[name]]
+        raise AttributeError
+
+    def _decode(self, obj, context, path):
+        b = bytes([obj])
+        try:
+            return self.decmapping[b]
+        except KeyError:
+            return EnumNamedByte(b, "(unknown)")
+
+    def _encode(self, obj, context, path):
+        if isinstance(obj, int):
+            return obj
+        elif isinstance(obj, bytes):
+            return ord(obj)
+        return int(obj)
+
+
+types = ByteEnum(
+    ErrorResponse=b"E",
+    ReadyForQuery=b"Z",
+    Query=b"Q",
+    EmptyQueryResponse=b"I",
+    AuthnRequest=b"R",
+    PasswordMessage=b"p",
+    BackendKeyData=b"K",
+    CommandComplete=b"C",
+    ParameterStatus=b"S",
+    DataRow=b"D",
+    Terminate=b"X",
+)
+
+
+authn = Enum(
+    Int32ub,
+    OK=0,
+    SASL=10,
+    SASLContinue=11,
+    SASLFinal=12,
+)
+
+
+_authn_body = Switch(
+    this.type,
+    {
+        authn.OK: Terminated,
+        authn.SASL: StringList,
+    },
+    default=GreedyBytes,
+)
+
+
+def _data_len(this):
+    assert this._building, "_data_len() cannot be called during parsing"
+
+    if not hasattr(this, "data") or this.data is None:
+        return -1
+
+    return len(this.data)
+
+
+# The protocol reuses the PasswordMessage for several authentication response
+# types, and there's no good way to figure out which is which without keeping
+# state for the entire stream. So this is a separate Construct that can be
+# explicitly parsed/built by code that knows it's needed.
+SASLInitialResponse = Struct(
+    "name" / NullTerminated(GreedyBytes),
+    "len" / Default(Int32sb, lambda this: _data_len(this)),
+    "data"
+    / IfThenElse(
+        # Allow tests to explicitly pass an incorrect length during testing, by
+        # not enforcing a FixedSized during build. (The len calculation above
+        # defaults to the correct size.)
+        this._building,
+        Optional(GreedyBytes),
+        If(this.len != -1, Default(FixedSized(this.len, GreedyBytes), b"")),
+    ),
+    Terminated,  # make sure the entire response is consumed
+)
+
+
+_column = FocusedSeq(
+    "data",
+    "len" / Default(Int32sb, lambda this: _data_len(this)),
+    "data" / If(this.len != -1, FixedSized(this.len, GreedyBytes)),
+)
+
+
+_payload_map = {
+    types.ErrorResponse: Struct("fields" / StringList),
+    types.ReadyForQuery: Struct("status" / Bytes(1)),
+    types.Query: Struct("query" / NullTerminated(GreedyBytes)),
+    types.EmptyQueryResponse: Terminated,
+    types.AuthnRequest: Struct("type" / authn, "body" / Default(_authn_body, b"")),
+    types.BackendKeyData: Struct("pid" / Int32ub, "key" / Hex(Int32ub)),
+    types.CommandComplete: Struct("tag" / NullTerminated(GreedyBytes)),
+    types.ParameterStatus: Struct(
+        "name" / NullTerminated(GreedyBytes), "value" / NullTerminated(GreedyBytes)
+    ),
+    types.DataRow: Struct("columns" / Default(PrefixedArray(Int16sb, _column), b"")),
+    types.Terminate: Terminated,
+}
+
+
+_payload = FocusedSeq(
+    "_payload",
+    "_payload"
+    / Switch(
+        this._.type,
+        _payload_map,
+        default=GreedyBytes,
+    ),
+    Terminated,  # make sure every payload consumes the entire packet
+)
+
+
+def _payload_len(this):
+    """
+    See _startup_payload_len() for an explanation.
+    """
+    assert this._building, "_payload_len() cannot be called during parsing"
+
+    try:
+        payload = this.payload
+    except AttributeError:
+        return 0  # no payload
+
+    if isinstance(payload, bytes):
+        # already serialized; just use the given length
+        return len(payload)
+
+    data = _payload.build(payload, type=this.type)
+    return len(data)
+
+
+Pq3 = Struct(
+    "type" / types,
+    "len" / Default(Int32ub, lambda this: _payload_len(this) + 4),
+    "payload"
+    / IfThenElse(
+        # Allow tests to explicitly pass an incorrect length during testing, by
+        # not enforcing a FixedSized during build. (The len calculation above
+        # defaults to the correct size.)
+        this._building,
+        Optional(_payload),
+        FixedSized(this.len - 4, Default(_payload, b"")),
+    ),
+)
+
+
+# Environment
+
+
+def pghost():
+    return os.environ.get("PGHOST", default="localhost")
+
+
+def pgport():
+    return int(os.environ.get("PGPORT", default=5432))
+
+
+def pguser():
+    try:
+        return os.environ["PGUSER"]
+    except KeyError:
+        if platform.system() == "Windows":
+            # libpq defaults to GetUserName() on Windows.
+            return os.getlogin()
+        return getpass.getuser()
+
+
+def pgdatabase():
+    return os.environ.get("PGDATABASE", default="postgres")
+
+
+# Connections
+
+
+def _hexdump_translation_map():
+    """
+    For hexdumps. Translates any unprintable or non-ASCII bytes into '.'.
+    """
+    input = bytearray()
+
+    for i in range(128):
+        c = chr(i)
+
+        if not c.isprintable():
+            input += bytes([i])
+
+    input += bytes(range(128, 256))
+
+    return bytes.maketrans(input, b"." * len(input))
+
+
+class _DebugStream(object):
+    """
+    Wraps a file-like object and adds hexdumps of the read and write data. Call
+    end_packet() on a _DebugStream to write the accumulated hexdumps to the
+    output stream, along with the packet that was sent.
+    """
+
+    _translation_map = _hexdump_translation_map()
+
+    def __init__(self, stream, out=sys.stdout):
+        """
+        Creates a new _DebugStream wrapping the given stream (which must have
+        been created by wrap()). All attributes not provided by the _DebugStream
+        are delegated to the wrapped stream. out is the text stream to which
+        hexdumps are written.
+        """
+        self.raw = stream
+        self._out = out
+        self._rbuf = io.BytesIO()
+        self._wbuf = io.BytesIO()
+
+    def __getattr__(self, name):
+        return getattr(self.raw, name)
+
+    def __setattr__(self, name, value):
+        if name in ("raw", "_out", "_rbuf", "_wbuf"):
+            return object.__setattr__(self, name, value)
+
+        setattr(self.raw, name, value)
+
+    def read(self, *args, **kwargs):
+        buf = self.raw.read(*args, **kwargs)
+
+        self._rbuf.write(buf)
+        return buf
+
+    def write(self, b):
+        self._wbuf.write(b)
+        return self.raw.write(b)
+
+    def recv(self, *args):
+        buf = self.raw.recv(*args)
+
+        self._rbuf.write(buf)
+        return buf
+
+    def _flush(self, buf, prefix):
+        width = 16
+        hexwidth = width * 3 - 1
+
+        count = 0
+        buf.seek(0)
+
+        while True:
+            line = buf.read(16)
+
+            if not line:
+                if count:
+                    self._out.write("\n")  # separate the output block with a newline
+                return
+
+            self._out.write("%s %04X:\t" % (prefix, count))
+            self._out.write("%*s\t" % (-hexwidth, line.hex(" ")))
+            self._out.write(line.translate(self._translation_map).decode("ascii"))
+            self._out.write("\n")
+
+            count += 16
+
+    def print_debug(self, obj, *, prefix=""):
+        contents = ""
+        if obj is not None:
+            contents = str(obj)
+
+        for line in contents.splitlines():
+            self._out.write("%s%s\n" % (prefix, line))
+
+        self._out.write("\n")
+
+    def flush_debug(self, *, prefix=""):
+        self._flush(self._rbuf, prefix + "<")
+        self._rbuf = io.BytesIO()
+
+        self._flush(self._wbuf, prefix + ">")
+        self._wbuf = io.BytesIO()
+
+    def end_packet(self, pkt, *, read=False, prefix="", indent="  "):
+        """
+        Marks the end of a logical "packet" of data. A string representation of
+        pkt will be printed, and the debug buffers will be flushed with an
+        indent. All lines can be optionally prefixed.
+
+        If read is True, the packet representation is written after the debug
+        buffers; otherwise the default of False (meaning write) causes the
+        packet representation to be dumped first. This is meant to capture the
+        logical flow of layer translation.
+        """
+        write = not read
+
+        if write:
+            self.print_debug(pkt, prefix=prefix + "> ")
+
+        self.flush_debug(prefix=prefix + indent)
+
+        if read:
+            self.print_debug(pkt, prefix=prefix + "< ")
+
+
[email protected]
+def wrap(socket, *, debug_stream=None):
+    """
+    Transforms a raw socket into a connection that can be used for Construct
+    building and parsing. The return value is a context manager and can be used
+    in a with statement.
+    """
+    # It is critical that buffering be disabled here, so that we can still
+    # manipulate the raw socket without desyncing the stream.
+    with socket.makefile("rwb", buffering=0) as sfile:
+        # Expose the original socket's recv() on the SocketIO object we return.
+        def recv(self, *args):
+            return socket.recv(*args)
+
+        sfile.recv = recv.__get__(sfile)
+
+        conn = sfile
+        if debug_stream:
+            conn = _DebugStream(conn, debug_stream)
+
+        try:
+            yield conn
+        finally:
+            if debug_stream:
+                conn.flush_debug(prefix="? ")
+
+
+def _send(stream, cls, obj):
+    debugging = hasattr(stream, "flush_debug")
+    out = io.BytesIO()
+
+    # Ideally we would build directly to the passed stream, but because we need
+    # to reparse the generated output for the debugging case, build to an
+    # intermediate BytesIO and send it instead.
+    cls.build_stream(obj, out)
+    buf = out.getvalue()
+
+    stream.write(buf)
+    if debugging:
+        pkt = cls.parse(buf)
+        stream.end_packet(pkt)
+
+    stream.flush()
+
+
+def send(stream, packet_type, payload_data=None, **payloadkw):
+    """
+    Sends a packet on the given pq3 connection. type is the pq3.types member
+    that should be assigned to the packet. If payload_data is given, it will be
+    used as the packet payload; otherwise the key/value pairs in payloadkw will
+    be the payload contents.
+    """
+    data = payloadkw
+
+    if payload_data is not None:
+        if payloadkw:
+            raise ValueError(
+                "payload_data and payload keywords may not be used simultaneously"
+            )
+
+        data = payload_data
+
+    _send(stream, Pq3, dict(type=packet_type, payload=data))
+
+
+def send_startup(stream, proto=None, **kwargs):
+    """
+    Sends a startup packet on the given pq3 connection. In most cases you should
+    use the handshake functions instead, which will do this for you.
+
+    By default, a protocol version 3 packet will be sent. This can be overridden
+    with the proto parameter.
+    """
+    pkt = {}
+
+    if proto is not None:
+        pkt["proto"] = proto
+    if kwargs:
+        pkt["payload"] = kwargs
+
+    _send(stream, Startup, pkt)
+
+
+def recv1(stream, *, cls=Pq3):
+    """
+    Receives a single pq3 packet from the given stream and returns it.
+    """
+    resp = cls.parse_stream(stream)
+
+    debugging = hasattr(stream, "flush_debug")
+    if debugging:
+        stream.end_packet(resp, read=True)
+
+    return resp
+
+
+def handshake(stream, **kwargs):
+    """
+    Performs a libpq v3 startup handshake. kwargs should contain the key/value
+    parameters to send to the server in the startup packet.
+    """
+    # Send our startup parameters.
+    send_startup(stream, **kwargs)
+
+    # Receive and dump packets until the server indicates it's ready for our
+    # first query.
+    while True:
+        resp = recv1(stream)
+        if resp is None:
+            raise RuntimeError("server closed connection during handshake")
+
+        if resp.type == types.ReadyForQuery:
+            return
+        elif resp.type == types.ErrorResponse:
+            raise RuntimeError(
+                f"received error response from peer: {resp.payload.fields!r}"
+            )
+
+
+# TLS
+
+
+class _TLSStream(object):
+    """
+    A file-like object that performs TLS encryption/decryption on a wrapped
+    stream. Differs from ssl.SSLSocket in that we have full visibility and
+    control over the TLS layer.
+    """
+
+    def __init__(self, stream, context):
+        self._stream = stream
+        self._debugging = hasattr(stream, "flush_debug")
+
+        self._in = ssl.MemoryBIO()
+        self._out = ssl.MemoryBIO()
+        self._ssl = context.wrap_bio(self._in, self._out)
+
+    def handshake(self):
+        try:
+            self._pump(lambda: self._ssl.do_handshake())
+        finally:
+            self._flush_debug(prefix="? ")
+
+    def read(self, *args):
+        return self._pump(lambda: self._ssl.read(*args))
+
+    def write(self, *args):
+        return self._pump(lambda: self._ssl.write(*args))
+
+    def _decode(self, buf):
+        """
+        Attempts to decode a buffer of TLS data into a packet representation
+        that can be printed.
+
+        TODO: handle buffers (and record fragments) that don't align with packet
+        boundaries.
+        """
+        end = len(buf)
+        bio = io.BytesIO(buf)
+
+        ret = io.StringIO()
+
+        while bio.tell() < end:
+            record = tls.Plaintext.parse_stream(bio)
+
+            if ret.tell() > 0:
+                ret.write("\n")
+            ret.write("[Record] ")
+            ret.write(str(record))
+            ret.write("\n")
+
+            if record.type == tls.ContentType.handshake:
+                record_cls = tls.Handshake
+            else:
+                continue
+
+            innerlen = len(record.fragment)
+            inner = io.BytesIO(record.fragment)
+
+            while inner.tell() < innerlen:
+                msg = record_cls.parse_stream(inner)
+
+                indented = "[Message] " + str(msg)
+                indented = textwrap.indent(indented, "    ")
+
+                ret.write("\n")
+                ret.write(indented)
+                ret.write("\n")
+
+        return ret.getvalue()
+
+    def flush(self):
+        if not self._out.pending:
+            self._stream.flush()
+            return
+
+        buf = self._out.read()
+        self._stream.write(buf)
+
+        if self._debugging:
+            pkt = self._decode(buf)
+            self._stream.end_packet(pkt, prefix="  ")
+
+        self._stream.flush()
+
+    def _pump(self, operation):
+        while True:
+            try:
+                return operation()
+            except (ssl.SSLWantReadError, ssl.SSLWantWriteError) as e:
+                want = e
+            self._read_write(want)
+
+    def _recv(self, maxsize):
+        buf = self._stream.recv(4096)
+        if not buf:
+            self._in.write_eof()
+            return
+
+        self._in.write(buf)
+
+        if not self._debugging:
+            return
+
+        pkt = self._decode(buf)
+        self._stream.end_packet(pkt, read=True, prefix="  ")
+
+    def _read_write(self, want):
+        # XXX This needs work. So many corner cases yet to handle. For one,
+        # doing blocking writes in flush may lead to distributed deadlock if the
+        # peer is already blocking on its writes.
+
+        if isinstance(want, ssl.SSLWantWriteError):
+            assert self._out.pending, "SSL backend wants write without data"
+
+        self.flush()
+
+        if isinstance(want, ssl.SSLWantReadError):
+            self._recv(4096)
+
+    def _flush_debug(self, prefix):
+        if not self._debugging:
+            return
+
+        self._stream.flush_debug(prefix=prefix)
+
+
[email protected]
+def tls_handshake(stream, context):
+    """
+    Performs a TLS handshake over the given stream (which must have been created
+    via a call to wrap()), and returns a new stream which transparently tunnels
+    data over the TLS connection.
+
+    If the passed stream has debugging enabled, the returned stream will also
+    have debugging, using the same output IO.
+    """
+    debugging = hasattr(stream, "flush_debug")
+
+    # Send our startup parameters.
+    send_startup(stream, proto=protocol(1234, 5679))
+
+    # Look at the SSL response.
+    resp = stream.read(1)
+    if debugging:
+        stream.flush_debug(prefix="  ")
+
+    if resp == b"N":
+        raise RuntimeError("server does not support SSLRequest")
+    if resp != b"S":
+        raise RuntimeError(f"unexpected response of type {resp!r} during TLS startup")
+
+    tls = _TLSStream(stream, context)
+    tls.handshake()
+
+    if debugging:
+        tls = _DebugStream(tls, stream._out)
+
+    try:
+        yield tls
+        # TODO: teardown/unwrap the connection?
+    finally:
+        if debugging:
+            tls.flush_debug(prefix="? ")
diff --git a/src/test/python/pytest.ini b/src/test/python/pytest.ini
new file mode 100644
index 0000000000..ab7a6e7fb9
--- /dev/null
+++ b/src/test/python/pytest.ini
@@ -0,0 +1,4 @@
+[pytest]
+
+markers =
+    slow: mark test as slow
diff --git a/src/test/python/requirements.txt b/src/test/python/requirements.txt
new file mode 100644
index 0000000000..0dfcffb83e
--- /dev/null
+++ b/src/test/python/requirements.txt
@@ -0,0 +1,11 @@
+black
+# cryptography 35.x and later add many platform/toolchain restrictions, beware
+cryptography~=3.4.8
+# TODO: figure out why 2.10.70 broke things
+# (probably https://github.com/construct/construct/pull/1015)
+construct==2.10.69
+isort~=5.6
+# TODO: update to psycopg[c] 3.1
+psycopg2~=2.9.7
+pytest~=7.3
+pytest-asyncio~=0.21.0
diff --git a/src/test/python/server/__init__.py b/src/test/python/server/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/test/python/server/conftest.py b/src/test/python/server/conftest.py
new file mode 100644
index 0000000000..42af80c73e
--- /dev/null
+++ b/src/test/python/server/conftest.py
@@ -0,0 +1,141 @@
+#
+# Portions Copyright 2021 VMware, Inc.
+# Portions Copyright 2023 Timescale, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import collections
+import contextlib
+import os
+import shutil
+import socket
+import subprocess
+import sys
+
+import pytest
+
+import pq3
+
+BLOCKING_TIMEOUT = 2  # the number of seconds to wait for blocking calls
+
+
+def cleanup_prior_instance(datadir):
+    """
+    Clean up an existing data directory, but make sure it actually looks like a
+    data directory first. (Empty folders will remain untouched, since initdb can
+    populate them.)
+    """
+    required_entries = set(["base", "PG_VERSION", "postgresql.conf"])
+    empty = True
+
+    try:
+        with os.scandir(datadir) as entries:
+            for e in entries:
+                empty = False
+                required_entries.discard(e.name)
+
+    except FileNotFoundError:
+        return  # nothing to clean up
+
+    if empty:
+        return  # initdb can handle an empty datadir
+
+    if required_entries:
+        pytest.fail(
+            f"--temp-instance directory \"{datadir}\" is not empty and doesn't look like a data directory (missing {', '.join(required_entries)})"
+        )
+
+    # Okay, seems safe enough now.
+    shutil.rmtree(datadir)
+
+
[email protected](scope="session")
+def postgres_instance(pytestconfig, unused_tcp_port_factory):
+    """
+    If --temp-instance has been passed to pytest, this fixture runs a temporary
+    Postgres instance on an available port. Otherwise, the fixture will attempt
+    to contact a running Postgres server on (PGHOST, PGPORT); dependent tests
+    will be skipped if the connection fails.
+
+    Yields a (host, port) tuple for connecting to the server.
+    """
+    PGInstance = collections.namedtuple("PGInstance", ["addr", "temporary"])
+
+    datadir = pytestconfig.getoption("temp_instance")
+    if datadir:
+        # We were told to create a temporary instance. Use pg_ctl to set it up
+        # on an unused port.
+        cleanup_prior_instance(datadir)
+        subprocess.run(["pg_ctl", "-D", datadir, "init"], check=True)
+
+        # The CI looks for *.log files to upload, so the file name here isn't
+        # completely arbitrary.
+        log = os.path.join(datadir, "postmaster.log")
+        port = unused_tcp_port_factory()
+
+        subprocess.run(
+            [
+                "pg_ctl",
+                "-D",
+                datadir,
+                "-l",
+                log,
+                "-o",
+                " ".join(
+                    [
+                        f"-c port={port}",
+                        "-c listen_addresses=localhost",
+                        "-c log_connections=on",
+                        "-c session_preload_libraries=oauthtest",
+                        "-c oauth_validator_libraries=oauthtest",
+                    ]
+                ),
+                "start",
+            ],
+            check=True,
+        )
+
+        yield ("localhost", port)
+
+        subprocess.run(["pg_ctl", "-D", datadir, "stop"], check=True)
+
+    else:
+        # Try to contact an already running server; skip the suite if we can't
+        # find one.
+        addr = (pq3.pghost(), pq3.pgport())
+
+        try:
+            with socket.create_connection(addr, timeout=BLOCKING_TIMEOUT):
+                pass
+        except ConnectionError as e:
+            pytest.skip(f"unable to connect to Postgres server at {addr}: {e}")
+
+        yield addr
+
+
[email protected]
+def connect(postgres_instance):
+    """
+    A factory fixture that, when called, returns a socket connected to a
+    Postgres server, wrapped in a pq3 connection. Dependent tests will be
+    skipped if no server is available.
+    """
+    addr = postgres_instance
+
+    # Set up an ExitStack to handle safe cleanup of all of the moving pieces.
+    with contextlib.ExitStack() as stack:
+
+        def conn_factory():
+            sock = socket.create_connection(addr, timeout=BLOCKING_TIMEOUT)
+
+            # Have ExitStack close our socket.
+            stack.enter_context(sock)
+
+            # Wrap the connection in a pq3 layer and have ExitStack clean it up
+            # too.
+            wrap_ctx = pq3.wrap(sock, debug_stream=sys.stdout)
+            conn = stack.enter_context(wrap_ctx)
+
+            return conn
+
+        yield conn_factory
diff --git a/src/test/python/server/meson.build b/src/test/python/server/meson.build
new file mode 100644
index 0000000000..85534b9cc9
--- /dev/null
+++ b/src/test/python/server/meson.build
@@ -0,0 +1,18 @@
+# Copyright (c) 2024, PostgreSQL Global Development Group
+
+oauthtest_sources = files(
+  'oauthtest.c',
+)
+
+if host_system == 'windows'
+  oauthtest_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'oauthtest',
+    '--FILEDESC', 'passthrough module to validate OAuth tests',
+  ])
+endif
+
+oauthtest = shared_module('oauthtest',
+  oauthtest_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += oauthtest
diff --git a/src/test/python/server/oauthtest.c b/src/test/python/server/oauthtest.c
new file mode 100644
index 0000000000..415748b9a6
--- /dev/null
+++ b/src/test/python/server/oauthtest.c
@@ -0,0 +1,118 @@
+/*-------------------------------------------------------------------------
+ *
+ * oauthtest.c
+ *	  Test module for serverside OAuth token validation callbacks
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/test/python/server/oauthtest.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "fmgr.h"
+#include "libpq/oauth.h"
+#include "utils/guc.h"
+#include "utils/memutils.h"
+
+PG_MODULE_MAGIC;
+
+static void test_startup(ValidatorModuleState *state);
+static void test_shutdown(ValidatorModuleState *state);
+static ValidatorModuleResult *test_validate(ValidatorModuleState *state,
+											const char *token,
+											const char *role);
+
+static const OAuthValidatorCallbacks callbacks = {
+	.startup_cb = test_startup,
+	.shutdown_cb = test_shutdown,
+	.validate_cb = test_validate,
+};
+
+static char *expected_bearer = "";
+static bool set_authn_id = false;
+static char *authn_id = "";
+static bool reflect_role = false;
+
+void
+_PG_init(void)
+{
+	DefineCustomStringVariable("oauthtest.expected_bearer",
+							   "Expected Bearer token for future connections",
+							   NULL,
+							   &expected_bearer,
+							   "",
+							   PGC_SIGHUP,
+							   0,
+							   NULL, NULL, NULL);
+
+	DefineCustomBoolVariable("oauthtest.set_authn_id",
+							 "Whether to set an authenticated identity",
+							 NULL,
+							 &set_authn_id,
+							 false,
+							 PGC_SIGHUP,
+							 0,
+							 NULL, NULL, NULL);
+	DefineCustomStringVariable("oauthtest.authn_id",
+							   "Authenticated identity to use for future connections",
+							   NULL,
+							   &authn_id,
+							   "",
+							   PGC_SIGHUP,
+							   0,
+							   NULL, NULL, NULL);
+
+	DefineCustomBoolVariable("oauthtest.reflect_role",
+							 "Ignore the bearer token; use the requested role as the authn_id",
+							 NULL,
+							 &reflect_role,
+							 false,
+							 PGC_SIGHUP,
+							 0,
+							 NULL, NULL, NULL);
+
+	MarkGUCPrefixReserved("oauthtest");
+}
+
+const OAuthValidatorCallbacks *
+_PG_oauth_validator_module_init(void)
+{
+	return &callbacks;
+}
+
+static void
+test_startup(ValidatorModuleState *state)
+{
+}
+
+static void
+test_shutdown(ValidatorModuleState *state)
+{
+}
+
+static ValidatorModuleResult *
+test_validate(ValidatorModuleState *state, const char *token, const char *role)
+{
+	ValidatorModuleResult *res;
+
+	res = palloc0(sizeof(ValidatorModuleResult));	/* TODO: palloc context? */
+
+	if (reflect_role)
+	{
+		res->authorized = true;
+		res->authn_id = pstrdup(role);	/* TODO: constify? */
+	}
+	else
+	{
+		if (*expected_bearer && strcmp(token, expected_bearer) == 0)
+			res->authorized = true;
+		if (set_authn_id)
+			res->authn_id = authn_id;
+	}
+
+	return res;
+}
diff --git a/src/test/python/server/test_oauth.py b/src/test/python/server/test_oauth.py
new file mode 100644
index 0000000000..2839343ffa
--- /dev/null
+++ b/src/test/python/server/test_oauth.py
@@ -0,0 +1,1080 @@
+#
+# Copyright 2021 VMware, Inc.
+# Portions Copyright 2023 Timescale, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import base64
+import contextlib
+import json
+import os
+import pathlib
+import platform
+import secrets
+import shlex
+import shutil
+import socket
+import struct
+from multiprocessing import shared_memory
+
+import psycopg2
+import pytest
+from construct import Container
+from psycopg2 import sql
+
+import pq3
+
+from .conftest import BLOCKING_TIMEOUT
+
+MAX_SASL_MESSAGE_LENGTH = 65535
+
+INVALID_AUTHORIZATION_ERRCODE = b"28000"
+PROTOCOL_VIOLATION_ERRCODE = b"08P01"
+FEATURE_NOT_SUPPORTED_ERRCODE = b"0A000"
+
+SHARED_MEM_NAME = "oauth-pytest"
+MAX_UINT16 = 2**16 - 1
+
+
[email protected]
+def prepend_file(path, lines, *, suffix=".bak"):
+    """
+    A context manager that prepends a file on disk with the desired lines of
+    text. When the context manager is exited, the file will be restored to its
+    original contents.
+    """
+    # First make a backup of the original file.
+    bak = path + suffix
+    shutil.copy2(path, bak)
+
+    try:
+        # Write the new lines, followed by the original file content.
+        with open(path, "w") as new, open(bak, "r") as orig:
+            new.writelines(lines)
+            shutil.copyfileobj(orig, new)
+
+        # Return control to the calling code.
+        yield
+
+    finally:
+        # Put the backup back into place.
+        os.replace(bak, path)
+
+
[email protected](scope="module")
+def oauth_ctx(postgres_instance):
+    """
+    Creates a database and user that use the oauth auth method. The context
+    object contains the dbname and user attributes as strings to be used during
+    connection, as well as the issuer and scope that have been set in the HBA
+    configuration.
+
+    This fixture assumes that the standard PG* environment variables point to a
+    server running on a local machine, and that the PGUSER has rights to create
+    databases and roles.
+    """
+    id = secrets.token_hex(4)
+
+    class Context:
+        dbname = "oauth_test_" + id
+
+        user = "oauth_user_" + id
+        punct_user = "oauth_\"'? ;&!_user_" + id  # username w/ punctuation
+        map_user = "oauth_map_user_" + id
+        authz_user = "oauth_authz_user_" + id
+
+        issuer = "https://example.com/" + id
+        scope = "openid " + id
+
+    ctx = Context()
+    hba_lines = [
+        f'host {ctx.dbname} {ctx.map_user}   samehost oauth issuer="{ctx.issuer}" scope="{ctx.scope}" map=oauth\n',
+        f'host {ctx.dbname} {ctx.authz_user} samehost oauth issuer="{ctx.issuer}" scope="{ctx.scope}" delegate_ident_mapping=1\n',
+        f'host {ctx.dbname} all              samehost oauth issuer="{ctx.issuer}" scope="{ctx.scope}"\n',
+    ]
+    ident_lines = [r"oauth /^(.*)@example\.com$ \1"]
+
+    if platform.system() == "Windows":
+        # XXX why is 'samehost' not behaving as expected on Windows?
+        for l in list(hba_lines):
+            hba_lines.append(l.replace("samehost", "::1/128"))
+
+    host, port = postgres_instance
+    conn = psycopg2.connect(host=host, port=port)
+    conn.autocommit = True
+
+    with contextlib.closing(conn):
+        c = conn.cursor()
+
+        # Create our roles and database.
+        user = sql.Identifier(ctx.user)
+        punct_user = sql.Identifier(ctx.punct_user)
+        map_user = sql.Identifier(ctx.map_user)
+        authz_user = sql.Identifier(ctx.authz_user)
+        dbname = sql.Identifier(ctx.dbname)
+
+        c.execute(sql.SQL("CREATE ROLE {} LOGIN;").format(user))
+        c.execute(sql.SQL("CREATE ROLE {} LOGIN;").format(punct_user))
+        c.execute(sql.SQL("CREATE ROLE {} LOGIN;").format(map_user))
+        c.execute(sql.SQL("CREATE ROLE {} LOGIN;").format(authz_user))
+        c.execute(sql.SQL("CREATE DATABASE {};").format(dbname))
+
+        # Replace pg_hba and pg_ident.
+        c.execute("SHOW hba_file;")
+        hba = c.fetchone()[0]
+
+        c.execute("SHOW ident_file;")
+        ident = c.fetchone()[0]
+
+        with prepend_file(hba, hba_lines), prepend_file(ident, ident_lines):
+            c.execute("SELECT pg_reload_conf();")
+
+            # Use the new database and user.
+            yield ctx
+
+        # Put things back the way they were.
+        c.execute("SELECT pg_reload_conf();")
+
+        c.execute(sql.SQL("DROP DATABASE {};").format(dbname))
+        c.execute(sql.SQL("DROP ROLE {};").format(authz_user))
+        c.execute(sql.SQL("DROP ROLE {};").format(map_user))
+        c.execute(sql.SQL("DROP ROLE {};").format(punct_user))
+        c.execute(sql.SQL("DROP ROLE {};").format(user))
+
+
[email protected]()
+def conn(oauth_ctx, connect):
+    """
+    A convenience wrapper for connect(). The main purpose of this fixture is to
+    make sure oauth_ctx runs its setup code before the connection is made.
+    """
+    return connect()
+
+
+def bearer_token(*, size=16):
+    """
+    Generates a Bearer token using secrets.token_urlsafe(). The generated token
+    size in bytes may be specified; if unset, a small 16-byte token will be
+    generated.
+    """
+
+    if size % 4:
+        raise ValueError(f"requested token size {size} is not a multiple of 4")
+
+    token = secrets.token_urlsafe(size // 4 * 3)
+    assert len(token) == size
+
+    return token
+
+
+def begin_oauth_handshake(conn, oauth_ctx, *, user=None):
+    if user is None:
+        user = oauth_ctx.authz_user
+
+    pq3.send_startup(conn, user=user, database=oauth_ctx.dbname)
+
+    resp = pq3.recv1(conn)
+    assert resp.type == pq3.types.AuthnRequest
+
+    # The server should advertise exactly one mechanism.
+    assert resp.payload.type == pq3.authn.SASL
+    assert resp.payload.body == [b"OAUTHBEARER", b""]
+
+
+def send_initial_response(conn, *, auth=None, bearer=None):
+    """
+    Sends the OAUTHBEARER initial response on the connection, using the given
+    bearer token. Alternatively to a bearer token, the initial response's auth
+    field may be explicitly specified to test corner cases.
+    """
+    if bearer is not None and auth is not None:
+        raise ValueError("exactly one of the auth and bearer kwargs must be set")
+
+    if bearer is not None:
+        auth = b"Bearer " + bearer
+
+    if auth is None:
+        raise ValueError("exactly one of the auth and bearer kwargs must be set")
+
+    initial = pq3.SASLInitialResponse.build(
+        dict(
+            name=b"OAUTHBEARER",
+            data=b"n,,\x01auth=" + auth + b"\x01\x01",
+        )
+    )
+    pq3.send(conn, pq3.types.PasswordMessage, initial)
+
+
+def expect_handshake_success(conn):
+    """
+    Validates that the server responds with an AuthnOK message, and then drains
+    the connection until a ReadyForQuery message is received.
+    """
+    resp = pq3.recv1(conn)
+
+    assert resp.type == pq3.types.AuthnRequest
+    assert resp.payload.type == pq3.authn.OK
+    assert not resp.payload.body
+
+    receive_until(conn, pq3.types.ReadyForQuery)
+
+
+def expect_handshake_failure(conn, oauth_ctx):
+    """
+    Performs the OAUTHBEARER SASL failure "handshake" and validates the server's
+    side of the conversation, including the final ErrorResponse.
+    """
+
+    # We expect a discovery "challenge" back from the server before the authn
+    # failure message.
+    resp = pq3.recv1(conn)
+    assert resp.type == pq3.types.AuthnRequest
+
+    req = resp.payload
+    assert req.type == pq3.authn.SASLContinue
+
+    body = json.loads(req.body)
+    assert body["status"] == "invalid_token"
+    assert body["scope"] == oauth_ctx.scope
+
+    expected_config = oauth_ctx.issuer + "/.well-known/openid-configuration"
+    assert body["openid-configuration"] == expected_config
+
+    # Send the dummy response to complete the failed handshake.
+    pq3.send(conn, pq3.types.PasswordMessage, b"\x01")
+    resp = pq3.recv1(conn)
+
+    err = ExpectedError(INVALID_AUTHORIZATION_ERRCODE, "bearer authentication failed")
+    err.match(resp)
+
+
+def receive_until(conn, type):
+    """
+    receive_until pulls packets off the pq3 connection until a packet with the
+    desired type is found, or an error response is received.
+    """
+    while True:
+        pkt = pq3.recv1(conn)
+
+        if pkt.type == type:
+            return pkt
+        elif pkt.type == pq3.types.ErrorResponse:
+            raise RuntimeError(
+                f"received error response from peer: {pkt.payload.fields!r}"
+            )
+
+
[email protected]()
+def setup_validator(postgres_instance):
+    """
+    A per-test fixture that sets up the test validator with expected behavior.
+    The setting will be reverted during teardown.
+    """
+    host, port = postgres_instance
+    conn = psycopg2.connect(host=host, port=port)
+    conn.autocommit = True
+
+    with contextlib.closing(conn):
+        c = conn.cursor()
+        prev = dict()
+
+        def setter(**gucs):
+            for guc, val in gucs.items():
+                # Save the previous value.
+                c.execute(sql.SQL("SHOW oauthtest.{};").format(sql.Identifier(guc)))
+                prev[guc] = c.fetchone()[0]
+
+                c.execute(
+                    sql.SQL("ALTER SYSTEM SET oauthtest.{} TO %s;").format(
+                        sql.Identifier(guc)
+                    ),
+                    (val,),
+                )
+                c.execute("SELECT pg_reload_conf();")
+
+        yield setter
+
+        # Restore the previous values.
+        for guc, val in prev.items():
+            c.execute(
+                sql.SQL("ALTER SYSTEM SET oauthtest.{} TO %s;").format(
+                    sql.Identifier(guc)
+                ),
+                (val,),
+            )
+            c.execute("SELECT pg_reload_conf();")
+
+
[email protected]("token_len", [16, 1024, 4096])
[email protected](
+    "auth_prefix",
+    [
+        b"Bearer ",
+        b"bearer ",
+        b"Bearer    ",
+    ],
+)
+def test_oauth(setup_validator, connect, oauth_ctx, auth_prefix, token_len):
+    # Generate our bearer token with the desired length.
+    token = bearer_token(size=token_len)
+    setup_validator(expected_bearer=token)
+
+    conn = connect()
+    begin_oauth_handshake(conn, oauth_ctx)
+
+    auth = auth_prefix + token.encode("ascii")
+    send_initial_response(conn, auth=auth)
+    expect_handshake_success(conn)
+
+    # Make sure that the server has not set an authenticated ID.
+    pq3.send(conn, pq3.types.Query, query=b"SELECT system_user;")
+    resp = receive_until(conn, pq3.types.DataRow)
+
+    row = resp.payload
+    assert row.columns == [None]
+
+
[email protected](
+    "token_value",
+    [
+        "abcdzA==",
+        "123456M=",
+        "x-._~+/x",
+    ],
+)
+def test_oauth_bearer_corner_cases(setup_validator, connect, oauth_ctx, token_value):
+    setup_validator(expected_bearer=token_value)
+
+    conn = connect()
+    begin_oauth_handshake(conn, oauth_ctx)
+
+    send_initial_response(conn, bearer=token_value.encode("ascii"))
+
+    expect_handshake_success(conn)
+
+
[email protected](
+    "user,authn_id,should_succeed",
+    [
+        pytest.param(
+            lambda ctx: ctx.user,
+            lambda ctx: ctx.user,
+            True,
+            id="validator authn: succeeds when authn_id == username",
+        ),
+        pytest.param(
+            lambda ctx: ctx.user,
+            lambda ctx: None,
+            False,
+            id="validator authn: fails when authn_id is not set",
+        ),
+        pytest.param(
+            lambda ctx: ctx.user,
+            lambda ctx: "",
+            False,
+            id="validator authn: fails when authn_id is empty",
+        ),
+        pytest.param(
+            lambda ctx: ctx.user,
+            lambda ctx: ctx.authz_user,
+            False,
+            id="validator authn: fails when authn_id != username",
+        ),
+        pytest.param(
+            lambda ctx: ctx.map_user,
+            lambda ctx: ctx.map_user + "@example.com",
+            True,
+            id="validator with map: succeeds when authn_id matches map",
+        ),
+        pytest.param(
+            lambda ctx: ctx.map_user,
+            lambda ctx: None,
+            False,
+            id="validator with map: fails when authn_id is not set",
+        ),
+        pytest.param(
+            lambda ctx: ctx.map_user,
+            lambda ctx: ctx.map_user + "@example.net",
+            False,
+            id="validator with map: fails when authn_id doesn't match map",
+        ),
+        pytest.param(
+            lambda ctx: ctx.authz_user,
+            lambda ctx: None,
+            True,
+            id="validator authz: succeeds with no authn_id",
+        ),
+        pytest.param(
+            lambda ctx: ctx.authz_user,
+            lambda ctx: "",
+            True,
+            id="validator authz: succeeds with empty authn_id",
+        ),
+        pytest.param(
+            lambda ctx: ctx.authz_user,
+            lambda ctx: "postgres",
+            True,
+            id="validator authz: succeeds with basic username",
+        ),
+        pytest.param(
+            lambda ctx: ctx.authz_user,
+            lambda ctx: "[email protected]",
+            True,
+            id="validator authz: succeeds with email address",
+        ),
+    ],
+)
+def test_oauth_authn_id(
+    setup_validator, connect, oauth_ctx, user, authn_id, should_succeed
+):
+    token = bearer_token()
+    authn_id = authn_id(oauth_ctx)
+
+    # Set up the validator appropriately.
+    gucs = dict(expected_bearer=token)
+    if authn_id is not None:
+        gucs["set_authn_id"] = True
+        gucs["authn_id"] = authn_id
+    setup_validator(**gucs)
+
+    conn = connect()
+    username = user(oauth_ctx)
+    begin_oauth_handshake(conn, oauth_ctx, user=username)
+    send_initial_response(conn, bearer=token.encode("ascii"))
+
+    if not should_succeed:
+        expect_handshake_failure(conn, oauth_ctx)
+        return
+
+    expect_handshake_success(conn)
+
+    # Check the reported authn_id.
+    pq3.send(conn, pq3.types.Query, query=b"SELECT system_user;")
+    resp = receive_until(conn, pq3.types.DataRow)
+
+    expected = authn_id
+    if expected is not None:
+        expected = b"oauth:" + expected.encode("ascii")
+
+    row = resp.payload
+    assert row.columns == [expected]
+
+
+class ExpectedError(object):
+    def __init__(self, code, msg=None, detail=None):
+        self.code = code
+        self.msg = msg
+        self.detail = detail
+
+        # Protect against the footgun of an accidental empty string, which will
+        # "match" anything. If you don't want to match message or detail, just
+        # don't pass them.
+        if self.msg == "":
+            raise ValueError("msg must be non-empty or None")
+        if self.detail == "":
+            raise ValueError("detail must be non-empty or None")
+
+    def _getfield(self, resp, type):
+        """
+        Searches an ErrorResponse for a single field of the given type (e.g.
+        "M", "C", "D") and returns its value. Asserts if it doesn't find exactly
+        one field.
+        """
+        prefix = type.encode("ascii")
+        fields = [f for f in resp.payload.fields if f.startswith(prefix)]
+
+        assert len(fields) == 1
+        return fields[0][1:]  # strip off the type byte
+
+    def match(self, resp):
+        """
+        Checks that the given response matches the expected code, message, and
+        detail (if given). The error code must match exactly. The expected
+        message and detail must be contained within the actual strings.
+        """
+        assert resp.type == pq3.types.ErrorResponse
+
+        code = self._getfield(resp, "C")
+        assert code == self.code
+
+        if self.msg:
+            msg = self._getfield(resp, "M")
+            expected = self.msg.encode("utf-8")
+            assert expected in msg
+
+        if self.detail:
+            detail = self._getfield(resp, "D")
+            expected = self.detail.encode("utf-8")
+            assert expected in detail
+
+
+def test_oauth_rejected_bearer(conn, oauth_ctx):
+    begin_oauth_handshake(conn, oauth_ctx)
+
+    # Send a bearer token that doesn't match what the validator expects. It
+    # should fail the connection.
+    send_initial_response(conn, bearer=b"xxxxxx")
+
+    expect_handshake_failure(conn, oauth_ctx)
+
+
[email protected](
+    "bad_bearer",
+    [
+        b"Bearer    ",
+        b"Bearer a===b",
+        b"Bearer hello!",
+        b"Bearer trailingspace ",
+        b"Bearer trailingtab\t",
+        b"Bearer [email protected]",
+        b"Beare abcd",
+        b" Bearer leadingspace",
+        b'OAuth realm="Example"',
+        b"",
+    ],
+)
+def test_oauth_invalid_bearer(setup_validator, connect, oauth_ctx, bad_bearer):
+    # Tell the validator to accept any token. This ensures that the invalid
+    # bearer tokens are rejected before the validation step.
+    setup_validator(reflect_role=True)
+
+    conn = connect()
+    begin_oauth_handshake(conn, oauth_ctx)
+    send_initial_response(conn, auth=bad_bearer)
+
+    expect_handshake_failure(conn, oauth_ctx)
+
+
[email protected]
[email protected](
+    "resp_type,resp,err",
+    [
+        pytest.param(
+            None,
+            None,
+            None,
+            marks=pytest.mark.slow,
+            id="no response (expect timeout)",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            b"hello",
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "did not send a kvsep response",
+            ),
+            id="bad dummy response",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            b"\x01\x01",
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "did not send a kvsep response",
+            ),
+            id="multiple kvseps",
+        ),
+        pytest.param(
+            pq3.types.Query,
+            dict(query=b""),
+            ExpectedError(PROTOCOL_VIOLATION_ERRCODE, "expected SASL response"),
+            id="bad response message type",
+        ),
+    ],
+)
+def test_oauth_bad_response_to_error_challenge(conn, oauth_ctx, resp_type, resp, err):
+    begin_oauth_handshake(conn, oauth_ctx)
+
+    # Send an empty auth initial response, which will force an authn failure.
+    send_initial_response(conn, auth=b"")
+
+    # We expect a discovery "challenge" back from the server before the authn
+    # failure message.
+    pkt = pq3.recv1(conn)
+    assert pkt.type == pq3.types.AuthnRequest
+
+    req = pkt.payload
+    assert req.type == pq3.authn.SASLContinue
+
+    body = json.loads(req.body)
+    assert body["status"] == "invalid_token"
+
+    if resp_type is None:
+        # Do not send the dummy response. We should time out and not get a
+        # response from the server.
+        with pytest.raises(socket.timeout):
+            conn.read(1)
+
+        # Done with the test.
+        return
+
+    # Send the bad response.
+    pq3.send(conn, resp_type, resp)
+
+    # Make sure the server fails the connection correctly.
+    pkt = pq3.recv1(conn)
+    err.match(pkt)
+
+
[email protected](
+    "type,payload,err",
+    [
+        pytest.param(
+            pq3.types.ErrorResponse,
+            dict(fields=[b""]),
+            ExpectedError(PROTOCOL_VIOLATION_ERRCODE, "expected SASL response"),
+            id="error response in initial message",
+        ),
+        pytest.param(
+            None,
+            # Sending an actual 65k packet results in ECONNRESET on Windows, and
+            # it floods the tests' connection log uselessly, so just fake the
+            # length and send a smaller number of bytes.
+            dict(
+                type=pq3.types.PasswordMessage,
+                len=MAX_SASL_MESSAGE_LENGTH + 1,
+                payload=b"x" * 512,
+            ),
+            ExpectedError(
+                INVALID_AUTHORIZATION_ERRCODE, "bearer authentication failed"
+            ),
+            id="overlong initial response data",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"SCRAM-SHA-256")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE, "invalid SASL authentication mechanism"
+            ),
+            id="bad SASL mechanism selection",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", len=2, data=b"x")),
+            ExpectedError(PROTOCOL_VIOLATION_ERRCODE, "insufficient data"),
+            id="SASL data underflow",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", len=0, data=b"x")),
+            ExpectedError(PROTOCOL_VIOLATION_ERRCODE, "invalid message format"),
+            id="SASL data overflow",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "message is empty",
+            ),
+            id="empty",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(name=b"OAUTHBEARER", data=b"n,,\x01auth=\x01\x01\0")
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "length does not match input length",
+            ),
+            id="contains null byte",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"\x01")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "Unexpected channel-binding flag",  # XXX this is a bit strange
+            ),
+            id="initial error response",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(name=b"OAUTHBEARER", data=b"p=tls-server-end-point,,\x01")
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "server does not support channel binding",
+            ),
+            id="uses channel binding",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"x,,\x01")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "Unexpected channel-binding flag",
+            ),
+            id="invalid channel binding specifier",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"y")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "Comma expected",
+            ),
+            id="bad GS2 header: missing channel binding terminator",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"y,a")),
+            ExpectedError(
+                FEATURE_NOT_SUPPORTED_ERRCODE,
+                "client uses authorization identity",
+            ),
+            id="bad GS2 header: authzid in use",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"y,b,")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "Unexpected attribute",
+            ),
+            id="bad GS2 header: extra attribute",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"y,")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                'Unexpected attribute "0x00"',  # XXX this is a bit strange
+            ),
+            id="bad GS2 header: missing authzid terminator",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"y,,")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "Key-value separator expected",
+            ),
+            id="missing initial kvsep",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"y,,")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "Key-value separator expected",
+            ),
+            id="missing initial kvsep",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(name=b"OAUTHBEARER", data=b"y,,\x01\x01")
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "does not contain an auth value",
+            ),
+            id="missing auth value: empty key-value list",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(name=b"OAUTHBEARER", data=b"y,,\x01host=example.com\x01\x01")
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "does not contain an auth value",
+            ),
+            id="missing auth value: other keys present",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(name=b"OAUTHBEARER", data=b"y,,\x01host=example.com")
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "unterminated key/value pair",
+            ),
+            id="missing value terminator",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"y,,\x01")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "did not contain a final terminator",
+            ),
+            id="missing list terminator: empty list",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(name=b"OAUTHBEARER", data=b"y,,\x01auth=Bearer 0\x01")
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "did not contain a final terminator",
+            ),
+            id="missing list terminator: with auth value",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(name=b"OAUTHBEARER", data=b"y,,\x01auth=Bearer 0\x01\x01blah")
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "additional data after the final terminator",
+            ),
+            id="additional key after terminator",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(name=b"OAUTHBEARER", data=b"y,,\x01key\x01\x01")
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "key without a value",
+            ),
+            id="key without value",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(
+                    name=b"OAUTHBEARER",
+                    data=b"y,,\x01auth=Bearer 0\x01auth=Bearer 1\x01\x01",
+                )
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "contains multiple auth values",
+            ),
+            id="multiple auth values",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(
+                    name=b"OAUTHBEARER",
+                    data=b"y,,\x01=\x01\x01",
+                )
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "empty key name",
+            ),
+            id="empty key",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(
+                    name=b"OAUTHBEARER",
+                    data=b"y,,\x01my key= \x01\x01",
+                )
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "invalid key name",
+            ),
+            id="whitespace in key name",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(
+                    name=b"OAUTHBEARER",
+                    data=b"y,,\x01key=a\x05b\x01\x01",
+                )
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "invalid value",
+            ),
+            id="junk in value",
+        ),
+    ],
+)
+def test_oauth_bad_initial_response(conn, oauth_ctx, type, payload, err):
+    begin_oauth_handshake(conn, oauth_ctx)
+
+    # The server expects a SASL response; give it something else instead.
+    if type is not None:
+        # Build a new packet of the desired type.
+        if not isinstance(payload, dict):
+            payload = dict(payload_data=payload)
+        pq3.send(conn, type, **payload)
+    else:
+        # The test has a custom packet to send. (The only reason to do this is
+        # if the packet is corrupt or otherwise unbuildable/unparsable, so we
+        # don't use the standard pq3.send().)
+        conn.write(pq3.Pq3.build(payload))
+        conn.end_packet(Container(payload))
+
+    resp = pq3.recv1(conn)
+    err.match(resp)
+
+
+def test_oauth_empty_initial_response(setup_validator, connect, oauth_ctx):
+    token = bearer_token()
+    setup_validator(expected_bearer=token)
+
+    conn = connect()
+    begin_oauth_handshake(conn, oauth_ctx)
+
+    # Send an initial response without data.
+    initial = pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER"))
+    pq3.send(conn, pq3.types.PasswordMessage, initial)
+
+    # The server should respond with an empty challenge so we can send the data
+    # it wants.
+    pkt = pq3.recv1(conn)
+
+    assert pkt.type == pq3.types.AuthnRequest
+    assert pkt.payload.type == pq3.authn.SASLContinue
+    assert not pkt.payload.body
+
+    # Now send the initial data.
+    data = b"n,,\x01auth=Bearer " + token.encode("ascii") + b"\x01\x01"
+    pq3.send(conn, pq3.types.PasswordMessage, data)
+
+    # Server should now complete the handshake.
+    expect_handshake_success(conn)
+
+
+# TODO: see if there's a way to test this easily after the API switch
+def xtest_oauth_no_validator(setup_validator, oauth_ctx, connect):
+    # Clear out our validator command, then establish a new connection.
+    set_validator("")
+    conn = connect()
+
+    begin_oauth_handshake(conn, oauth_ctx)
+    send_initial_response(conn, bearer=bearer_token())
+
+    # The server should fail the connection.
+    expect_handshake_failure(conn, oauth_ctx)
+
+
[email protected](
+    "user",
+    [
+        pytest.param(
+            lambda ctx: ctx.user,
+            id="basic username",
+        ),
+        pytest.param(
+            lambda ctx: ctx.punct_user,
+            id="'unsafe' characters are passed through correctly",
+        ),
+    ],
+)
+def test_oauth_validator_role(setup_validator, oauth_ctx, connect, user):
+    username = user(oauth_ctx)
+
+    # Tell the validator to reflect the PGUSER as the authenticated identity.
+    setup_validator(reflect_role=True)
+    conn = connect()
+
+    # Log in. Note that reflection ignores the bearer token.
+    begin_oauth_handshake(conn, oauth_ctx, user=username)
+    send_initial_response(conn, bearer=b"dontcare")
+    expect_handshake_success(conn)
+
+    # Check the user identity.
+    pq3.send(conn, pq3.types.Query, query=b"SELECT system_user;")
+    resp = receive_until(conn, pq3.types.DataRow)
+
+    row = resp.payload
+    expected = b"oauth:" + username.encode("utf-8")
+    assert row.columns == [expected]
+
+
[email protected]
+def odd_oauth_ctx(postgres_instance, oauth_ctx):
+    """
+    Adds an HBA entry with messed up issuer/scope settings, to pin the server
+    behavior.
+
+    TODO: these should really be rejected in the HBA rather than passed through
+    by the server.
+    """
+    id = secrets.token_hex(4)
+
+    class Context:
+        user = oauth_ctx.user
+        dbname = oauth_ctx.dbname
+
+        # Both of these embedded double-quotes are invalid; they're prohibited
+        # in both URLs and OAuth scope identifiers.
+        issuer = oauth_ctx.issuer + '/"/'
+        scope = oauth_ctx.scope + ' quo"ted'
+
+    ctx = Context()
+    hba_issuer = ctx.issuer.replace('"', '""')
+    hba_scope = ctx.scope.replace('"', '""')
+    hba_lines = [
+        f'host {ctx.dbname} {ctx.user} samehost oauth issuer="{hba_issuer}" scope="{hba_scope}"\n',
+    ]
+
+    if platform.system() == "Windows":
+        # XXX why is 'samehost' not behaving as expected on Windows?
+        for l in list(hba_lines):
+            hba_lines.append(l.replace("samehost", "::1/128"))
+
+    host, port = postgres_instance
+    conn = psycopg2.connect(host=host, port=port)
+    conn.autocommit = True
+
+    with contextlib.closing(conn):
+        c = conn.cursor()
+
+        # Replace pg_hba. Note that it's already been replaced once by
+        # oauth_ctx, so use a different backup prefix in prepend_file().
+        c.execute("SHOW hba_file;")
+        hba = c.fetchone()[0]
+
+        with prepend_file(hba, hba_lines, suffix=".bak2"):
+            c.execute("SELECT pg_reload_conf();")
+
+            yield ctx
+
+        # Put things back the way they were.
+        c.execute("SELECT pg_reload_conf();")
+
+
+def test_odd_server_response(odd_oauth_ctx, connect):
+    """
+    Verifies that the server is correctly escaping the JSON in its failure
+    response.
+    """
+    conn = connect()
+    begin_oauth_handshake(conn, odd_oauth_ctx, user=odd_oauth_ctx.user)
+
+    # Send an empty auth initial response, which will force an authn failure.
+    send_initial_response(conn, auth=b"")
+
+    expect_handshake_failure(conn, odd_oauth_ctx)
diff --git a/src/test/python/server/test_server.py b/src/test/python/server/test_server.py
new file mode 100644
index 0000000000..02126dba79
--- /dev/null
+++ b/src/test/python/server/test_server.py
@@ -0,0 +1,21 @@
+#
+# Copyright 2021 VMware, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import pq3
+
+
+def test_handshake(connect):
+    """Basic sanity check."""
+    conn = connect()
+
+    pq3.handshake(conn, user=pq3.pguser(), database=pq3.pgdatabase())
+
+    pq3.send(conn, pq3.types.Query, query=b"")
+
+    resp = pq3.recv1(conn)
+    assert resp.type == pq3.types.EmptyQueryResponse
+
+    resp = pq3.recv1(conn)
+    assert resp.type == pq3.types.ReadyForQuery
diff --git a/src/test/python/test_internals.py b/src/test/python/test_internals.py
new file mode 100644
index 0000000000..dee4855fc0
--- /dev/null
+++ b/src/test/python/test_internals.py
@@ -0,0 +1,138 @@
+#
+# Copyright 2021 VMware, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import io
+
+from pq3 import _DebugStream
+
+
+def test_DebugStream_read():
+    under = io.BytesIO(b"abcdefghijklmnopqrstuvwxyz")
+    out = io.StringIO()
+
+    stream = _DebugStream(under, out)
+
+    res = stream.read(5)
+    assert res == b"abcde"
+
+    res = stream.read(16)
+    assert res == b"fghijklmnopqrstu"
+
+    stream.flush_debug()
+
+    res = stream.read()
+    assert res == b"vwxyz"
+
+    stream.flush_debug()
+
+    expected = (
+        "< 0000:\t61 62 63 64 65 66 67 68 69 6a 6b 6c 6d 6e 6f 70\tabcdefghijklmnop\n"
+        "< 0010:\t71 72 73 74 75                                 \tqrstu\n"
+        "\n"
+        "< 0000:\t76 77 78 79 7a                                 \tvwxyz\n"
+        "\n"
+    )
+    assert out.getvalue() == expected
+
+
+def test_DebugStream_write():
+    under = io.BytesIO()
+    out = io.StringIO()
+
+    stream = _DebugStream(under, out)
+
+    stream.write(b"\x00\x01\x02")
+    stream.flush()
+
+    assert under.getvalue() == b"\x00\x01\x02"
+
+    stream.write(b"\xc0\xc1\xc2")
+    stream.flush()
+
+    assert under.getvalue() == b"\x00\x01\x02\xc0\xc1\xc2"
+
+    stream.flush_debug()
+
+    expected = "> 0000:\t00 01 02 c0 c1 c2                              \t......\n\n"
+    assert out.getvalue() == expected
+
+
+def test_DebugStream_read_write():
+    under = io.BytesIO(b"abcdefghijklmnopqrstuvwxyz")
+    out = io.StringIO()
+    stream = _DebugStream(under, out)
+
+    res = stream.read(5)
+    assert res == b"abcde"
+
+    stream.write(b"xxxxx")
+    stream.flush()
+
+    assert under.getvalue() == b"abcdexxxxxklmnopqrstuvwxyz"
+
+    res = stream.read(5)
+    assert res == b"klmno"
+
+    stream.write(b"xxxxx")
+    stream.flush()
+
+    assert under.getvalue() == b"abcdexxxxxklmnoxxxxxuvwxyz"
+
+    stream.flush_debug()
+
+    expected = (
+        "< 0000:\t61 62 63 64 65 6b 6c 6d 6e 6f                  \tabcdeklmno\n"
+        "\n"
+        "> 0000:\t78 78 78 78 78 78 78 78 78 78                  \txxxxxxxxxx\n"
+        "\n"
+    )
+    assert out.getvalue() == expected
+
+
+def test_DebugStream_end_packet():
+    under = io.BytesIO(b"abcdefghijklmnopqrstuvwxyz")
+    out = io.StringIO()
+    stream = _DebugStream(under, out)
+
+    stream.read(5)
+    stream.end_packet("read description", read=True, indent=" ")
+
+    stream.write(b"xxxxx")
+    stream.flush()
+    stream.end_packet("write description", indent=" ")
+
+    stream.read(5)
+    stream.write(b"xxxxx")
+    stream.flush()
+    stream.end_packet("read/write combo for read", read=True, indent=" ")
+
+    stream.read(5)
+    stream.write(b"xxxxx")
+    stream.flush()
+    stream.end_packet("read/write combo for write", indent=" ")
+
+    expected = (
+        " < 0000:\t61 62 63 64 65                                 \tabcde\n"
+        "\n"
+        "< read description\n"
+        "\n"
+        "> write description\n"
+        "\n"
+        " > 0000:\t78 78 78 78 78                                 \txxxxx\n"
+        "\n"
+        " < 0000:\t6b 6c 6d 6e 6f                                 \tklmno\n"
+        "\n"
+        " > 0000:\t78 78 78 78 78                                 \txxxxx\n"
+        "\n"
+        "< read/write combo for read\n"
+        "\n"
+        "> read/write combo for write\n"
+        "\n"
+        " < 0000:\t75 76 77 78 79                                 \tuvwxy\n"
+        "\n"
+        " > 0000:\t78 78 78 78 78                                 \txxxxx\n"
+        "\n"
+    )
+    assert out.getvalue() == expected
diff --git a/src/test/python/test_pq3.py b/src/test/python/test_pq3.py
new file mode 100644
index 0000000000..7c6817de31
--- /dev/null
+++ b/src/test/python/test_pq3.py
@@ -0,0 +1,574 @@
+#
+# Copyright 2021 VMware, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import contextlib
+import getpass
+import io
+import os
+import platform
+import struct
+import sys
+
+import pytest
+from construct import Container, PaddingError, StreamError, TerminatedError
+
+import pq3
+
+
[email protected](
+    "raw,expected,extra",
+    [
+        pytest.param(
+            b"\x00\x00\x00\x10\x00\x04\x00\x00abcdefgh",
+            Container(len=16, proto=0x40000, payload=b"abcdefgh"),
+            b"",
+            id="8-byte payload",
+        ),
+        pytest.param(
+            b"\x00\x00\x00\x08\x00\x04\x00\x00",
+            Container(len=8, proto=0x40000, payload=b""),
+            b"",
+            id="no payload",
+        ),
+        pytest.param(
+            b"\x00\x00\x00\x09\x00\x04\x00\x00abcde",
+            Container(len=9, proto=0x40000, payload=b"a"),
+            b"bcde",
+            id="1-byte payload and extra padding",
+        ),
+        pytest.param(
+            b"\x00\x00\x00\x0B\x00\x03\x00\x00hi\x00",
+            Container(len=11, proto=pq3.protocol(3, 0), payload=[b"hi"]),
+            b"",
+            id="implied parameter list when using proto version 3.0",
+        ),
+    ],
+)
+def test_Startup_parse(raw, expected, extra):
+    with io.BytesIO(raw) as stream:
+        actual = pq3.Startup.parse_stream(stream)
+
+        assert actual == expected
+        assert stream.read() == extra
+
+
[email protected](
+    "packet,expected_bytes",
+    [
+        pytest.param(
+            dict(),
+            b"\x00\x00\x00\x08\x00\x00\x00\x00",
+            id="nothing set",
+        ),
+        pytest.param(
+            dict(len=10, proto=0x12345678),
+            b"\x00\x00\x00\x0A\x12\x34\x56\x78\x00\x00",
+            id="len and proto set explicitly",
+        ),
+        pytest.param(
+            dict(proto=0x12345678),
+            b"\x00\x00\x00\x08\x12\x34\x56\x78",
+            id="implied len with no payload",
+        ),
+        pytest.param(
+            dict(proto=0x12345678, payload=b"abcd"),
+            b"\x00\x00\x00\x0C\x12\x34\x56\x78abcd",
+            id="implied len with payload",
+        ),
+        pytest.param(
+            dict(payload=[b""]),
+            b"\x00\x00\x00\x09\x00\x03\x00\x00\x00",
+            id="implied proto version 3 when sending parameters",
+        ),
+        pytest.param(
+            dict(payload=[b"hi", b""]),
+            b"\x00\x00\x00\x0C\x00\x03\x00\x00hi\x00\x00",
+            id="implied proto version 3 and len when sending more than one parameter",
+        ),
+        pytest.param(
+            dict(payload=dict(user="jsmith", database="postgres")),
+            b"\x00\x00\x00\x27\x00\x03\x00\x00user\x00jsmith\x00database\x00postgres\x00\x00",
+            id="auto-serialization of dict parameters",
+        ),
+    ],
+)
+def test_Startup_build(packet, expected_bytes):
+    actual = pq3.Startup.build(packet)
+    assert actual == expected_bytes
+
+
[email protected](
+    "raw,expected,extra",
+    [
+        pytest.param(
+            b"*\x00\x00\x00\x08abcd",
+            dict(type=b"*", len=8, payload=b"abcd"),
+            b"",
+            id="4-byte payload",
+        ),
+        pytest.param(
+            b"*\x00\x00\x00\x04",
+            dict(type=b"*", len=4, payload=b""),
+            b"",
+            id="no payload",
+        ),
+        pytest.param(
+            b"*\x00\x00\x00\x05xabcd",
+            dict(type=b"*", len=5, payload=b"x"),
+            b"abcd",
+            id="1-byte payload with extra padding",
+        ),
+        pytest.param(
+            b"R\x00\x00\x00\x08\x00\x00\x00\x00",
+            dict(
+                type=pq3.types.AuthnRequest,
+                len=8,
+                payload=dict(type=pq3.authn.OK, body=None),
+            ),
+            b"",
+            id="AuthenticationOk",
+        ),
+        pytest.param(
+            b"R\x00\x00\x00\x12\x00\x00\x00\x0AEXTERNAL\x00\x00",
+            dict(
+                type=pq3.types.AuthnRequest,
+                len=18,
+                payload=dict(type=pq3.authn.SASL, body=[b"EXTERNAL", b""]),
+            ),
+            b"",
+            id="AuthenticationSASL",
+        ),
+        pytest.param(
+            b"R\x00\x00\x00\x0D\x00\x00\x00\x0B12345",
+            dict(
+                type=pq3.types.AuthnRequest,
+                len=13,
+                payload=dict(type=pq3.authn.SASLContinue, body=b"12345"),
+            ),
+            b"",
+            id="AuthenticationSASLContinue",
+        ),
+        pytest.param(
+            b"R\x00\x00\x00\x0D\x00\x00\x00\x0C12345",
+            dict(
+                type=pq3.types.AuthnRequest,
+                len=13,
+                payload=dict(type=pq3.authn.SASLFinal, body=b"12345"),
+            ),
+            b"",
+            id="AuthenticationSASLFinal",
+        ),
+        pytest.param(
+            b"p\x00\x00\x00\x0Bhunter2",
+            dict(
+                type=pq3.types.PasswordMessage,
+                len=11,
+                payload=b"hunter2",
+            ),
+            b"",
+            id="PasswordMessage",
+        ),
+        pytest.param(
+            b"K\x00\x00\x00\x0C\x00\x00\x00\x00\x12\x34\x56\x78",
+            dict(
+                type=pq3.types.BackendKeyData,
+                len=12,
+                payload=dict(pid=0, key=0x12345678),
+            ),
+            b"",
+            id="BackendKeyData",
+        ),
+        pytest.param(
+            b"C\x00\x00\x00\x08SET\x00",
+            dict(
+                type=pq3.types.CommandComplete,
+                len=8,
+                payload=dict(tag=b"SET"),
+            ),
+            b"",
+            id="CommandComplete",
+        ),
+        pytest.param(
+            b"E\x00\x00\x00\x11Mbad!\x00Mdog!\x00\x00",
+            dict(type=b"E", len=17, payload=dict(fields=[b"Mbad!", b"Mdog!", b""])),
+            b"",
+            id="ErrorResponse",
+        ),
+        pytest.param(
+            b"S\x00\x00\x00\x08a\x00b\x00",
+            dict(
+                type=pq3.types.ParameterStatus,
+                len=8,
+                payload=dict(name=b"a", value=b"b"),
+            ),
+            b"",
+            id="ParameterStatus",
+        ),
+        pytest.param(
+            b"Z\x00\x00\x00\x05x",
+            dict(type=b"Z", len=5, payload=dict(status=b"x")),
+            b"",
+            id="ReadyForQuery",
+        ),
+        pytest.param(
+            b"Q\x00\x00\x00\x06!\x00",
+            dict(type=pq3.types.Query, len=6, payload=dict(query=b"!")),
+            b"",
+            id="Query",
+        ),
+        pytest.param(
+            b"D\x00\x00\x00\x0B\x00\x01\x00\x00\x00\x01!",
+            dict(type=pq3.types.DataRow, len=11, payload=dict(columns=[b"!"])),
+            b"",
+            id="DataRow",
+        ),
+        pytest.param(
+            b"D\x00\x00\x00\x06\x00\x00extra",
+            dict(type=pq3.types.DataRow, len=6, payload=dict(columns=[])),
+            b"extra",
+            id="DataRow with extra data",
+        ),
+        pytest.param(
+            b"I\x00\x00\x00\x04",
+            dict(type=pq3.types.EmptyQueryResponse, len=4, payload=None),
+            b"",
+            id="EmptyQueryResponse",
+        ),
+        pytest.param(
+            b"I\x00\x00\x00\x04\xFF",
+            dict(type=b"I", len=4, payload=None),
+            b"\xFF",
+            id="EmptyQueryResponse with extra bytes",
+        ),
+        pytest.param(
+            b"X\x00\x00\x00\x04",
+            dict(type=pq3.types.Terminate, len=4, payload=None),
+            b"",
+            id="Terminate",
+        ),
+    ],
+)
+def test_Pq3_parse(raw, expected, extra):
+    with io.BytesIO(raw) as stream:
+        actual = pq3.Pq3.parse_stream(stream)
+
+        assert actual == expected
+        assert stream.read() == extra
+
+
[email protected](
+    "fields,expected",
+    [
+        pytest.param(
+            dict(type=b"*", len=5),
+            b"*\x00\x00\x00\x05",
+            id="type and len set explicitly",
+        ),
+        pytest.param(
+            dict(type=b"*"),
+            b"*\x00\x00\x00\x04",
+            id="implied len with no payload",
+        ),
+        pytest.param(
+            dict(type=b"*", payload=b"1234"),
+            b"*\x00\x00\x00\x081234",
+            id="implied len with payload",
+        ),
+        pytest.param(
+            dict(type=b"*", len=12, payload=b"1234"),
+            b"*\x00\x00\x00\x0C1234",
+            id="overridden len (payload underflow)",
+        ),
+        pytest.param(
+            dict(type=b"*", len=5, payload=b"1234"),
+            b"*\x00\x00\x00\x051234",
+            id="overridden len (payload overflow)",
+        ),
+        pytest.param(
+            dict(type=pq3.types.AuthnRequest, payload=dict(type=pq3.authn.OK)),
+            b"R\x00\x00\x00\x08\x00\x00\x00\x00",
+            id="implied len/type for AuthenticationOK",
+        ),
+        pytest.param(
+            dict(
+                type=pq3.types.AuthnRequest,
+                payload=dict(
+                    type=pq3.authn.SASL,
+                    body=[b"SCRAM-SHA-256-PLUS", b"SCRAM-SHA-256", b""],
+                ),
+            ),
+            b"R\x00\x00\x00\x2A\x00\x00\x00\x0ASCRAM-SHA-256-PLUS\x00SCRAM-SHA-256\x00\x00",
+            id="implied len/type for AuthenticationSASL",
+        ),
+        pytest.param(
+            dict(
+                type=pq3.types.AuthnRequest,
+                payload=dict(type=pq3.authn.SASLContinue, body=b"12345"),
+            ),
+            b"R\x00\x00\x00\x0D\x00\x00\x00\x0B12345",
+            id="implied len/type for AuthenticationSASLContinue",
+        ),
+        pytest.param(
+            dict(
+                type=pq3.types.AuthnRequest,
+                payload=dict(type=pq3.authn.SASLFinal, body=b"12345"),
+            ),
+            b"R\x00\x00\x00\x0D\x00\x00\x00\x0C12345",
+            id="implied len/type for AuthenticationSASLFinal",
+        ),
+        pytest.param(
+            dict(
+                type=pq3.types.PasswordMessage,
+                payload=b"hunter2",
+            ),
+            b"p\x00\x00\x00\x0Bhunter2",
+            id="implied len/type for PasswordMessage",
+        ),
+        pytest.param(
+            dict(type=pq3.types.BackendKeyData, payload=dict(pid=1, key=7)),
+            b"K\x00\x00\x00\x0C\x00\x00\x00\x01\x00\x00\x00\x07",
+            id="implied len/type for BackendKeyData",
+        ),
+        pytest.param(
+            dict(type=pq3.types.CommandComplete, payload=dict(tag=b"SET")),
+            b"C\x00\x00\x00\x08SET\x00",
+            id="implied len/type for CommandComplete",
+        ),
+        pytest.param(
+            dict(type=pq3.types.ErrorResponse, payload=dict(fields=[b"error", b""])),
+            b"E\x00\x00\x00\x0Berror\x00\x00",
+            id="implied len/type for ErrorResponse",
+        ),
+        pytest.param(
+            dict(type=pq3.types.ParameterStatus, payload=dict(name=b"a", value=b"b")),
+            b"S\x00\x00\x00\x08a\x00b\x00",
+            id="implied len/type for ParameterStatus",
+        ),
+        pytest.param(
+            dict(type=pq3.types.ReadyForQuery, payload=dict(status=b"I")),
+            b"Z\x00\x00\x00\x05I",
+            id="implied len/type for ReadyForQuery",
+        ),
+        pytest.param(
+            dict(type=pq3.types.Query, payload=dict(query=b"SELECT 1;")),
+            b"Q\x00\x00\x00\x0eSELECT 1;\x00",
+            id="implied len/type for Query",
+        ),
+        pytest.param(
+            dict(type=pq3.types.DataRow, payload=dict(columns=[b"abcd"])),
+            b"D\x00\x00\x00\x0E\x00\x01\x00\x00\x00\x04abcd",
+            id="implied len/type for DataRow",
+        ),
+        pytest.param(
+            dict(type=pq3.types.EmptyQueryResponse),
+            b"I\x00\x00\x00\x04",
+            id="implied len for EmptyQueryResponse",
+        ),
+        pytest.param(
+            dict(type=pq3.types.Terminate),
+            b"X\x00\x00\x00\x04",
+            id="implied len for Terminate",
+        ),
+    ],
+)
+def test_Pq3_build(fields, expected):
+    actual = pq3.Pq3.build(fields)
+    assert actual == expected
+
+
[email protected](
+    "raw,expected,extra",
+    [
+        pytest.param(
+            b"\x00\x00",
+            dict(columns=[]),
+            b"",
+            id="no columns",
+        ),
+        pytest.param(
+            b"\x00\x01\x00\x00\x00\x04abcd",
+            dict(columns=[b"abcd"]),
+            b"",
+            id="one column",
+        ),
+        pytest.param(
+            b"\x00\x02\x00\x00\x00\x04abcd\x00\x00\x00\x01x",
+            dict(columns=[b"abcd", b"x"]),
+            b"",
+            id="multiple columns",
+        ),
+        pytest.param(
+            b"\x00\x02\x00\x00\x00\x00\x00\x00\x00\x01x",
+            dict(columns=[b"", b"x"]),
+            b"",
+            id="empty column value",
+        ),
+        pytest.param(
+            b"\x00\x02\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF",
+            dict(columns=[None, None]),
+            b"",
+            id="null columns",
+        ),
+    ],
+)
+def test_DataRow_parse(raw, expected, extra):
+    pkt = b"D" + struct.pack("!i", len(raw) + 4) + raw
+    with io.BytesIO(pkt) as stream:
+        actual = pq3.Pq3.parse_stream(stream)
+
+        assert actual.type == pq3.types.DataRow
+        assert actual.payload == expected
+        assert stream.read() == extra
+
+
[email protected](
+    "fields,expected",
+    [
+        pytest.param(
+            dict(),
+            b"\x00\x00",
+            id="no columns",
+        ),
+        pytest.param(
+            dict(columns=[None, None]),
+            b"\x00\x02\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF",
+            id="null columns",
+        ),
+    ],
+)
+def test_DataRow_build(fields, expected):
+    actual = pq3.Pq3.build(dict(type=pq3.types.DataRow, payload=fields))
+
+    expected = b"D" + struct.pack("!i", len(expected) + 4) + expected
+    assert actual == expected
+
+
[email protected](
+    "raw,expected,exception",
+    [
+        pytest.param(
+            b"EXTERNAL\x00\xFF\xFF\xFF\xFF",
+            dict(name=b"EXTERNAL", len=-1, data=None),
+            None,
+            id="no initial response",
+        ),
+        pytest.param(
+            b"EXTERNAL\x00\x00\x00\x00\x02me",
+            dict(name=b"EXTERNAL", len=2, data=b"me"),
+            None,
+            id="initial response",
+        ),
+        pytest.param(
+            b"EXTERNAL\x00\x00\x00\x00\x02meextra",
+            None,
+            TerminatedError,
+            id="extra data",
+        ),
+        pytest.param(
+            b"EXTERNAL\x00\x00\x00\x00\xFFme",
+            None,
+            StreamError,
+            id="underflow",
+        ),
+    ],
+)
+def test_SASLInitialResponse_parse(raw, expected, exception):
+    ctx = contextlib.nullcontext()
+    if exception:
+        ctx = pytest.raises(exception)
+
+    with ctx:
+        actual = pq3.SASLInitialResponse.parse(raw)
+        assert actual == expected
+
+
[email protected](
+    "fields,expected",
+    [
+        pytest.param(
+            dict(name=b"EXTERNAL"),
+            b"EXTERNAL\x00\xFF\xFF\xFF\xFF",
+            id="no initial response",
+        ),
+        pytest.param(
+            dict(name=b"EXTERNAL", data=None),
+            b"EXTERNAL\x00\xFF\xFF\xFF\xFF",
+            id="no initial response (explicit None)",
+        ),
+        pytest.param(
+            dict(name=b"EXTERNAL", data=b""),
+            b"EXTERNAL\x00\x00\x00\x00\x00",
+            id="empty response",
+        ),
+        pytest.param(
+            dict(name=b"EXTERNAL", data=b"[email protected]"),
+            b"EXTERNAL\x00\x00\x00\x00\[email protected]",
+            id="initial response",
+        ),
+        pytest.param(
+            dict(name=b"EXTERNAL", len=2, data=b"[email protected]"),
+            b"EXTERNAL\x00\x00\x00\x00\[email protected]",
+            id="data overflow",
+        ),
+        pytest.param(
+            dict(name=b"EXTERNAL", len=14, data=b"me"),
+            b"EXTERNAL\x00\x00\x00\x00\x0Eme",
+            id="data underflow",
+        ),
+    ],
+)
+def test_SASLInitialResponse_build(fields, expected):
+    actual = pq3.SASLInitialResponse.build(fields)
+    assert actual == expected
+
+
[email protected](
+    "version,expected_bytes",
+    [
+        pytest.param((3, 0), b"\x00\x03\x00\x00", id="version 3"),
+        pytest.param((1234, 5679), b"\x04\xd2\x16\x2f", id="SSLRequest"),
+    ],
+)
+def test_protocol(version, expected_bytes):
+    # Make sure the integer returned by protocol is correctly serialized on the
+    # wire.
+    assert struct.pack("!i", pq3.protocol(*version)) == expected_bytes
+
+
[email protected](
+    "envvar,func,expected",
+    [
+        ("PGHOST", pq3.pghost, "localhost"),
+        ("PGPORT", pq3.pgport, 5432),
+        (
+            "PGUSER",
+            pq3.pguser,
+            os.getlogin() if platform.system() == "Windows" else getpass.getuser(),
+        ),
+        ("PGDATABASE", pq3.pgdatabase, "postgres"),
+    ],
+)
+def test_env_defaults(monkeypatch, envvar, func, expected):
+    monkeypatch.delenv(envvar, raising=False)
+
+    actual = func()
+    assert actual == expected
+
+
[email protected](
+    "envvars,func,expected",
+    [
+        (dict(PGHOST="otherhost"), pq3.pghost, "otherhost"),
+        (dict(PGPORT="6789"), pq3.pgport, 6789),
+        (dict(PGUSER="postgres"), pq3.pguser, "postgres"),
+        (dict(PGDATABASE="template1"), pq3.pgdatabase, "template1"),
+    ],
+)
+def test_env(monkeypatch, envvars, func, expected):
+    for k, v in envvars.items():
+        monkeypatch.setenv(k, v)
+
+    actual = func()
+    assert actual == expected
diff --git a/src/test/python/tls.py b/src/test/python/tls.py
new file mode 100644
index 0000000000..075c02c1ca
--- /dev/null
+++ b/src/test/python/tls.py
@@ -0,0 +1,195 @@
+#
+# Copyright 2021 VMware, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+from construct import *
+
+#
+# TLS 1.3
+#
+# Most of the types below are transcribed from RFC 8446:
+#
+#     https://tools.ietf.org/html/rfc8446
+#
+
+
+def _Vector(size_field, element):
+    return Prefixed(size_field, GreedyRange(element))
+
+
+# Alerts
+
+AlertLevel = Enum(
+    Byte,
+    warning=1,
+    fatal=2,
+)
+
+AlertDescription = Enum(
+    Byte,
+    close_notify=0,
+    unexpected_message=10,
+    bad_record_mac=20,
+    decryption_failed_RESERVED=21,
+    record_overflow=22,
+    decompression_failure=30,
+    handshake_failure=40,
+    no_certificate_RESERVED=41,
+    bad_certificate=42,
+    unsupported_certificate=43,
+    certificate_revoked=44,
+    certificate_expired=45,
+    certificate_unknown=46,
+    illegal_parameter=47,
+    unknown_ca=48,
+    access_denied=49,
+    decode_error=50,
+    decrypt_error=51,
+    export_restriction_RESERVED=60,
+    protocol_version=70,
+    insufficient_security=71,
+    internal_error=80,
+    user_canceled=90,
+    no_renegotiation=100,
+    unsupported_extension=110,
+)
+
+Alert = Struct(
+    "level" / AlertLevel,
+    "description" / AlertDescription,
+)
+
+
+# Extensions
+
+ExtensionType = Enum(
+    Int16ub,
+    server_name=0,
+    max_fragment_length=1,
+    status_request=5,
+    supported_groups=10,
+    signature_algorithms=13,
+    use_srtp=14,
+    heartbeat=15,
+    application_layer_protocol_negotiation=16,
+    signed_certificate_timestamp=18,
+    client_certificate_type=19,
+    server_certificate_type=20,
+    padding=21,
+    pre_shared_key=41,
+    early_data=42,
+    supported_versions=43,
+    cookie=44,
+    psk_key_exchange_modes=45,
+    certificate_authorities=47,
+    oid_filters=48,
+    post_handshake_auth=49,
+    signature_algorithms_cert=50,
+    key_share=51,
+)
+
+Extension = Struct(
+    "extension_type" / ExtensionType,
+    "extension_data" / Prefixed(Int16ub, GreedyBytes),
+)
+
+
+# ClientHello
+
+
+class _CipherSuiteAdapter(Adapter):
+    class _hextuple(tuple):
+        def __repr__(self):
+            return f"(0x{self[0]:02X}, 0x{self[1]:02X})"
+
+    def _encode(self, obj, context, path):
+        return bytes(obj)
+
+    def _decode(self, obj, context, path):
+        assert len(obj) == 2
+        return self._hextuple(obj)
+
+
+ProtocolVersion = Hex(Int16ub)
+
+Random = Hex(Bytes(32))
+
+CipherSuite = _CipherSuiteAdapter(Byte[2])
+
+ClientHello = Struct(
+    "legacy_version" / ProtocolVersion,
+    "random" / Random,
+    "legacy_session_id" / Prefixed(Byte, Hex(GreedyBytes)),
+    "cipher_suites" / _Vector(Int16ub, CipherSuite),
+    "legacy_compression_methods" / Prefixed(Byte, GreedyBytes),
+    "extensions" / _Vector(Int16ub, Extension),
+)
+
+# ServerHello
+
+ServerHello = Struct(
+    "legacy_version" / ProtocolVersion,
+    "random" / Random,
+    "legacy_session_id_echo" / Prefixed(Byte, Hex(GreedyBytes)),
+    "cipher_suite" / CipherSuite,
+    "legacy_compression_method" / Hex(Byte),
+    "extensions" / _Vector(Int16ub, Extension),
+)
+
+# Handshake
+
+HandshakeType = Enum(
+    Byte,
+    client_hello=1,
+    server_hello=2,
+    new_session_ticket=4,
+    end_of_early_data=5,
+    encrypted_extensions=8,
+    certificate=11,
+    certificate_request=13,
+    certificate_verify=15,
+    finished=20,
+    key_update=24,
+    message_hash=254,
+)
+
+Handshake = Struct(
+    "msg_type" / HandshakeType,
+    "length" / Int24ub,
+    "payload"
+    / Switch(
+        this.msg_type,
+        {
+            HandshakeType.client_hello: ClientHello,
+            HandshakeType.server_hello: ServerHello,
+            # HandshakeType.end_of_early_data: EndOfEarlyData,
+            # HandshakeType.encrypted_extensions: EncryptedExtensions,
+            # HandshakeType.certificate_request: CertificateRequest,
+            # HandshakeType.certificate: Certificate,
+            # HandshakeType.certificate_verify: CertificateVerify,
+            # HandshakeType.finished: Finished,
+            # HandshakeType.new_session_ticket: NewSessionTicket,
+            # HandshakeType.key_update: KeyUpdate,
+        },
+        default=FixedSized(this.length, GreedyBytes),
+    ),
+)
+
+# Records
+
+ContentType = Enum(
+    Byte,
+    invalid=0,
+    change_cipher_spec=20,
+    alert=21,
+    handshake=22,
+    application_data=23,
+)
+
+Plaintext = Struct(
+    "type" / ContentType,
+    "legacy_record_version" / ProtocolVersion,
+    "length" / Int16ub,
+    "fragment" / FixedSized(this.length, GreedyBytes),
+)
diff --git a/src/tools/make_venv b/src/tools/make_venv
new file mode 100755
index 0000000000..804307ee12
--- /dev/null
+++ b/src/tools/make_venv
@@ -0,0 +1,56 @@
+#!/usr/bin/env python3
+
+import argparse
+import subprocess
+import os
+import platform
+import sys
+
+parser = argparse.ArgumentParser()
+
+parser.add_argument('--requirements', help='path to pip requirements file', type=str)
+parser.add_argument('--privatedir', help='private directory for target', type=str)
+parser.add_argument('venv_path', help='desired venv location')
+
+args = parser.parse_args()
+
+# Decide whether or not to capture stdout into a log file. We only do this if
+# we've been given our own private directory.
+#
+# FIXME Unfortunately this interferes with debugging on Cirrus, because the
+# private directory isn't uploaded in the sanity check's artifacts. When we
+# don't capture the log file, it gets spammed to stdout during build... Is there
+# a way to push this into the meson-log somehow? For now, the capture
+# implementation is commented out.
+logfile = None
+
+if args.privatedir:
+    if not os.path.isdir(args.privatedir):
+        os.mkdir(args.privatedir)
+
+    # FIXME see above comment
+    # logpath = os.path.join(args.privatedir, 'stdout.txt')
+    # logfile = open(logpath, 'w')
+
+def run(*args):
+    kwargs = dict(check=True)
+    if logfile:
+        kwargs.update(stdout=logfile)
+
+    subprocess.run(args, **kwargs)
+
+# Create the virtualenv first.
+run(sys.executable, '-m', 'venv', args.venv_path)
+
+# Update pip next. This helps avoid old pip bugs; the version inside system
+# Pythons tends to be pretty out of date.
+bindir = 'Scripts' if platform.system() == 'Windows' else 'bin'
+python = os.path.join(args.venv_path, bindir, 'python3')
+run(python, '-m', 'pip', 'install', '-U', 'pip')
+
+# Finally, install the test's requirements. We need pytest and pytest-tap, no
+# matter what the test needs.
+pip = os.path.join(args.venv_path, bindir, 'pip')
+run(pip, 'install', 'pytest', 'pytest-tap')
+if args.requirements:
+    run(pip, 'install', '-r', args.requirements)
diff --git a/src/tools/testwrap b/src/tools/testwrap
index 8ae8fb79ba..ffdf760d79 100755
--- a/src/tools/testwrap
+++ b/src/tools/testwrap
@@ -14,6 +14,7 @@ parser.add_argument('--testgroup', help='test group', type=str)
 parser.add_argument('--testname', help='test name', type=str)
 parser.add_argument('--skip', help='skip test (with reason)', type=str)
 parser.add_argument('--pg-test-extra', help='extra tests', type=str)
+parser.add_argument('--skip-without-extra', help='skip if PG_TEST_EXTRA is missing this arg', type=str)
 parser.add_argument('test_command', nargs='*')
 
 args = parser.parse_args()
@@ -29,6 +30,12 @@ if args.skip is not None:
     print('1..0 # Skipped: ' + args.skip)
     sys.exit(0)
 
+if args.skip_without_extra is not None:
+    extras = os.environ.get("PG_TEST_EXTRA", args.pg_test_extra)
+    if extras is None or args.skip_without_extra not in extras.split():
+        print(f'1..0 # Skipped: PG_TEST_EXTRA does not contain "{args.skip_without_extra}"')
+        sys.exit(0)
+
 if os.path.exists(testdir) and os.path.isdir(testdir):
     shutil.rmtree(testdir)
 os.makedirs(testdir)
-- 
2.39.3 (Apple Git-146)



  [application/octet-stream] v45-0003-XXX-fix-libcurl-link-error.patch (1.2K, ../../[email protected]/3-v45-0003-XXX-fix-libcurl-link-error.patch)
  download | inline diff:
From aa842c3b82a1e7f23275c7faa724187eb201e153 Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Mon, 13 Jan 2025 12:31:59 -0800
Subject: [PATCH v45 3/4] XXX fix libcurl link error

The ftp/curl port appears to be missing a minimum version dependency on
libssh2, so the following starts showing up after upgrading to curl
8.11.1_1:

    libcurl.so.4: Undefined symbol "libssh2_session_callback_set2"

But 13.3 is EOL, so it's not clear if anyone would be interested in a
bug report, and a FreeBSD 14 Cirrus image is in progress. Hack past it
for now.
---
 .cirrus.tasks.yml | 1 +
 1 file changed, 1 insertion(+)

diff --git a/.cirrus.tasks.yml b/.cirrus.tasks.yml
index 8c518c317e..97bb38c72c 100644
--- a/.cirrus.tasks.yml
+++ b/.cirrus.tasks.yml
@@ -165,6 +165,7 @@ task:
     sysctl kern.corefile='/tmp/cores/%N.%P.core'
   setup_additional_packages_script: |
     pkg install -y curl
+    pkg upgrade -y libssh2 # XXX shouldn't be necessary. revisit w/ FreeBSD 14
 
   # NB: Intentionally build without -Dllvm. The freebsd image size is already
   # large enough to make VM startup slow, and even without llvm freebsd
-- 
2.39.3 (Apple Git-146)



  [application/octet-stream] v45-0002-Add-OAUTHBEARER-SASL-mechanism.patch (299.2K, ../../[email protected]/4-v45-0002-Add-OAUTHBEARER-SASL-mechanism.patch)
  download | inline diff:
From cf667a32c5565f5f916a920299ce1d623c1e6a1e Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Wed, 23 Oct 2024 09:37:33 -0700
Subject: [PATCH v45 2/4] Add OAUTHBEARER SASL mechanism

DO NOT USE THIS PROOF OF CONCEPT IN PRODUCTION.

Implement OAUTHBEARER (RFC 7628) and OAuth 2.0 Device Authorization
Grants (RFC 8628). This adds a new auth method, oauth, to pg_hba. When
speaking to a OAuth-enabled server, it looks a bit like this:

    $ psql 'host=example.org oauth_issuer=... oauth_client_id=...'
    Visit https://oauth.example.org/login and enter the code: FPQ2-M4BG

The OAuth issuer must support device authorization. No other OAuth flows
are currently implemented (but clients may provide their own flows).

The client implementation requires libcurl and its development headers.
Pass --with-libcurl/-Dlibcurl=enabled during configuration. The server
implementation does not require additional build-time dependencies, but
an external validator module must be supplied.

Thomas Munro wrote the kqueue() implementation for oauth-curl; thanks!

Several TODOs:
- perform several sanity checks on the OAuth issuer's responses
- improve error debuggability during the OAuth handshake
- fix libcurl initialization thread-safety
- harden the libcurl flow implementation
- fill in documentation stubs
- support protocol "variants" implemented by major providers
- implement more helpful handling of HBA misconfigurations
- use logdetail during auth failures
- ...and more.

Co-authored-by: Daniel Gustafsson <[email protected]>
---
 .cirrus.tasks.yml                             |   15 +-
 config/programs.m4                            |   42 +
 configure                                     |  279 ++
 configure.ac                                  |   41 +
 doc/src/sgml/client-auth.sgml                 |  252 ++
 doc/src/sgml/config.sgml                      |   26 +
 doc/src/sgml/filelist.sgml                    |    1 +
 doc/src/sgml/installation.sgml                |   27 +
 doc/src/sgml/libpq.sgml                       |  393 +++
 doc/src/sgml/oauth-validators.sgml            |  402 +++
 doc/src/sgml/postgres.sgml                    |    1 +
 doc/src/sgml/protocol.sgml                    |  133 +-
 doc/src/sgml/regress.sgml                     |   10 +
 meson.build                                   |   66 +
 meson_options.txt                             |    3 +
 src/Makefile.global.in                        |    1 +
 src/backend/libpq/Makefile                    |    1 +
 src/backend/libpq/auth-oauth.c                |  860 ++++++
 src/backend/libpq/auth.c                      |   10 +-
 src/backend/libpq/hba.c                       |   64 +-
 src/backend/libpq/meson.build                 |    1 +
 src/backend/libpq/pg_hba.conf.sample          |    4 +-
 src/backend/utils/adt/hbafuncs.c              |   19 +
 src/backend/utils/misc/guc_tables.c           |   12 +
 src/backend/utils/misc/postgresql.conf.sample |    3 +
 src/include/common/oauth-common.h             |   19 +
 src/include/libpq/auth.h                      |    1 +
 src/include/libpq/hba.h                       |    7 +-
 src/include/libpq/oauth.h                     |   54 +
 src/include/pg_config.h.in                    |    9 +
 src/interfaces/libpq/Makefile                 |   11 +-
 src/interfaces/libpq/exports.txt              |    3 +
 src/interfaces/libpq/fe-auth-oauth-curl.c     | 2635 +++++++++++++++++
 src/interfaces/libpq/fe-auth-oauth.c          | 1141 +++++++
 src/interfaces/libpq/fe-auth-oauth.h          |   45 +
 src/interfaces/libpq/fe-auth.c                |   36 +-
 src/interfaces/libpq/fe-auth.h                |    3 +
 src/interfaces/libpq/fe-connect.c             |   48 +-
 src/interfaces/libpq/libpq-fe.h               |   82 +
 src/interfaces/libpq/libpq-int.h              |   13 +-
 src/interfaces/libpq/meson.build              |    5 +
 src/makefiles/meson.build                     |    1 +
 src/test/authentication/t/001_password.pl     |    8 +-
 src/test/modules/Makefile                     |    1 +
 src/test/modules/meson.build                  |    1 +
 src/test/modules/oauth_validator/.gitignore   |    4 +
 src/test/modules/oauth_validator/Makefile     |   40 +
 src/test/modules/oauth_validator/README       |   13 +
 .../modules/oauth_validator/fail_validator.c  |   42 +
 src/test/modules/oauth_validator/meson.build  |   69 +
 .../oauth_validator/oauth_hook_client.c       |  264 ++
 .../modules/oauth_validator/t/001_server.pl   |  551 ++++
 .../modules/oauth_validator/t/002_client.pl   |  154 +
 .../modules/oauth_validator/t/OAuth/Server.pm |  140 +
 .../modules/oauth_validator/t/oauth_server.py |  391 +++
 src/test/modules/oauth_validator/validator.c  |  135 +
 src/test/perl/PostgreSQL/Test/Cluster.pm      |   20 +-
 src/tools/pgindent/pgindent                   |   14 +
 src/tools/pgindent/typedefs.list              |   11 +
 59 files changed, 8598 insertions(+), 39 deletions(-)
 create mode 100644 doc/src/sgml/oauth-validators.sgml
 create mode 100644 src/backend/libpq/auth-oauth.c
 create mode 100644 src/include/common/oauth-common.h
 create mode 100644 src/include/libpq/oauth.h
 create mode 100644 src/interfaces/libpq/fe-auth-oauth-curl.c
 create mode 100644 src/interfaces/libpq/fe-auth-oauth.c
 create mode 100644 src/interfaces/libpq/fe-auth-oauth.h
 create mode 100644 src/test/modules/oauth_validator/.gitignore
 create mode 100644 src/test/modules/oauth_validator/Makefile
 create mode 100644 src/test/modules/oauth_validator/README
 create mode 100644 src/test/modules/oauth_validator/fail_validator.c
 create mode 100644 src/test/modules/oauth_validator/meson.build
 create mode 100644 src/test/modules/oauth_validator/oauth_hook_client.c
 create mode 100644 src/test/modules/oauth_validator/t/001_server.pl
 create mode 100644 src/test/modules/oauth_validator/t/002_client.pl
 create mode 100644 src/test/modules/oauth_validator/t/OAuth/Server.pm
 create mode 100755 src/test/modules/oauth_validator/t/oauth_server.py
 create mode 100644 src/test/modules/oauth_validator/validator.c

diff --git a/.cirrus.tasks.yml b/.cirrus.tasks.yml
index 18e944ca89..8c518c317e 100644
--- a/.cirrus.tasks.yml
+++ b/.cirrus.tasks.yml
@@ -20,7 +20,7 @@ env:
   MTEST_ARGS: --print-errorlogs --no-rebuild -C build
   PGCTLTIMEOUT: 120 # avoids spurious failures during parallel tests
   TEMP_CONFIG: ${CIRRUS_WORKING_DIR}/src/tools/ci/pg_ci_base.conf
-  PG_TEST_EXTRA: kerberos ldap ssl libpq_encryption load_balance
+  PG_TEST_EXTRA: kerberos ldap ssl libpq_encryption load_balance oauth
 
 
 # What files to preserve in case tests fail
@@ -164,7 +164,7 @@ task:
     chown root:postgres /tmp/cores
     sysctl kern.corefile='/tmp/cores/%N.%P.core'
   setup_additional_packages_script: |
-    #pkg install -y ...
+    pkg install -y curl
 
   # NB: Intentionally build without -Dllvm. The freebsd image size is already
   # large enough to make VM startup slow, and even without llvm freebsd
@@ -219,6 +219,7 @@ LINUX_CONFIGURE_FEATURES: &LINUX_CONFIGURE_FEATURES >-
   --with-gssapi
   --with-icu
   --with-ldap
+  --with-libcurl
   --with-libxml
   --with-libxslt
   --with-llvm
@@ -312,8 +313,10 @@ task:
     EOF
 
   setup_additional_packages_script: |
-    #apt-get update
-    #DEBIAN_FRONTEND=noninteractive apt-get -y install ...
+    apt-get update
+    DEBIAN_FRONTEND=noninteractive apt-get -y install \
+      libcurl4-openssl-dev \
+      libcurl4-openssl-dev:i386 \
 
   matrix:
     - name: Linux - Debian Bookworm - Autoconf
@@ -689,8 +692,8 @@ task:
     folder: $CCACHE_DIR
 
   setup_additional_packages_script: |
-    #apt-get update
-    #DEBIAN_FRONTEND=noninteractive apt-get -y install ...
+    apt-get update
+    DEBIAN_FRONTEND=noninteractive apt-get -y install libcurl4-openssl-dev
 
   ###
   # Test that code can be built with gcc/clang without warnings
diff --git a/config/programs.m4 b/config/programs.m4
index 7b55c2664a..86a3750f9e 100644
--- a/config/programs.m4
+++ b/config/programs.m4
@@ -274,3 +274,45 @@ AC_DEFUN([PGAC_CHECK_STRIP],
   AC_SUBST(STRIP_STATIC_LIB)
   AC_SUBST(STRIP_SHARED_LIB)
 ])# PGAC_CHECK_STRIP
+
+
+
+# PGAC_CHECK_LIBCURL
+# ------------------
+# Check for required libraries and headers, and test to see whether the current
+# installation of libcurl is threadsafe.
+
+AC_DEFUN([PGAC_CHECK_LIBCURL],
+[
+  AC_CHECK_HEADER(curl/curl.h, [],
+				  [AC_MSG_ERROR([header file <curl/curl.h> is required for --with-libcurl])])
+  AC_CHECK_LIB(curl, curl_multi_init, [],
+			   [AC_MSG_ERROR([library 'curl' does not provide curl_multi_init])])
+
+  # Check to see whether the current platform supports threadsafe Curl
+  # initialization.
+  AC_CACHE_CHECK([for curl_global_init thread safety], [pgac_cv__libcurl_threadsafe_init],
+  [AC_RUN_IFELSE([AC_LANG_PROGRAM([
+#include <curl/curl.h>
+],[
+    curl_version_info_data *info;
+
+    if (curl_global_init(CURL_GLOBAL_ALL))
+        return -1;
+
+    info = curl_version_info(CURLVERSION_NOW);
+#ifdef CURL_VERSION_THREADSAFE
+    if (info->features & CURL_VERSION_THREADSAFE)
+        return 0;
+#endif
+
+    return 1;
+])],
+  [pgac_cv__libcurl_threadsafe_init=yes],
+  [pgac_cv__libcurl_threadsafe_init=no],
+  [pgac_cv__libcurl_threadsafe_init=unknown])])
+  if test x"$pgac_cv__libcurl_threadsafe_init" = xyes ; then
+    AC_DEFINE(HAVE_THREADSAFE_CURL_GLOBAL_INIT, 1,
+              [Define to 1 if curl_global_init() is guaranteed to be threadsafe.])
+  fi
+])# PGAC_CHECK_LIBCURL
diff --git a/configure b/configure
index ceeef9b091..115a91f8f4 100755
--- a/configure
+++ b/configure
@@ -708,6 +708,9 @@ XML2_LIBS
 XML2_CFLAGS
 XML2_CONFIG
 with_libxml
+LIBCURL_LIBS
+LIBCURL_CFLAGS
+with_libcurl
 with_uuid
 with_readline
 with_systemd
@@ -864,6 +867,7 @@ with_readline
 with_libedit_preferred
 with_uuid
 with_ossp_uuid
+with_libcurl
 with_libxml
 with_libxslt
 with_system_tzdata
@@ -894,6 +898,8 @@ PKG_CONFIG_PATH
 PKG_CONFIG_LIBDIR
 ICU_CFLAGS
 ICU_LIBS
+LIBCURL_CFLAGS
+LIBCURL_LIBS
 XML2_CONFIG
 XML2_CFLAGS
 XML2_LIBS
@@ -1574,6 +1580,7 @@ Optional Packages:
                           prefer BSD Libedit over GNU Readline
   --with-uuid=LIB         build contrib/uuid-ossp using LIB (bsd,e2fs,ossp)
   --with-ossp-uuid        obsolete spelling of --with-uuid=ossp
+  --with-libcurl          build with libcurl support
   --with-libxml           build with XML support
   --with-libxslt          use XSLT support when building contrib/xml2
   --with-system-tzdata=DIR
@@ -1607,6 +1614,10 @@ Some influential environment variables:
               path overriding pkg-config's built-in search path
   ICU_CFLAGS  C compiler flags for ICU, overriding pkg-config
   ICU_LIBS    linker flags for ICU, overriding pkg-config
+  LIBCURL_CFLAGS
+              C compiler flags for LIBCURL, overriding pkg-config
+  LIBCURL_LIBS
+              linker flags for LIBCURL, overriding pkg-config
   XML2_CONFIG path to xml2-config utility
   XML2_CFLAGS C compiler flags for XML2, overriding pkg-config
   XML2_LIBS   linker flags for XML2, overriding pkg-config
@@ -8762,6 +8773,157 @@ fi
 
 
 
+#
+# libcurl
+#
+{ $as_echo "$as_me:${as_lineno-$LINENO}: checking whether to build with libcurl support" >&5
+$as_echo_n "checking whether to build with libcurl support... " >&6; }
+
+
+
+# Check whether --with-libcurl was given.
+if test "${with_libcurl+set}" = set; then :
+  withval=$with_libcurl;
+  case $withval in
+    yes)
+
+$as_echo "#define USE_LIBCURL 1" >>confdefs.h
+
+      ;;
+    no)
+      :
+      ;;
+    *)
+      as_fn_error $? "no argument expected for --with-libcurl option" "$LINENO" 5
+      ;;
+  esac
+
+else
+  with_libcurl=no
+
+fi
+
+
+{ $as_echo "$as_me:${as_lineno-$LINENO}: result: $with_libcurl" >&5
+$as_echo "$with_libcurl" >&6; }
+
+
+if test "$with_libcurl" = yes ; then
+  # Check for libcurl 7.61.0 or higher (corresponding to RHEL8 and the ability
+  # to explicitly set TLS 1.3 ciphersuites).
+
+pkg_failed=no
+{ $as_echo "$as_me:${as_lineno-$LINENO}: checking for libcurl >= 7.61.0" >&5
+$as_echo_n "checking for libcurl >= 7.61.0... " >&6; }
+
+if test -n "$LIBCURL_CFLAGS"; then
+    pkg_cv_LIBCURL_CFLAGS="$LIBCURL_CFLAGS"
+ elif test -n "$PKG_CONFIG"; then
+    if test -n "$PKG_CONFIG" && \
+    { { $as_echo "$as_me:${as_lineno-$LINENO}: \$PKG_CONFIG --exists --print-errors \"libcurl >= 7.61.0\""; } >&5
+  ($PKG_CONFIG --exists --print-errors "libcurl >= 7.61.0") 2>&5
+  ac_status=$?
+  $as_echo "$as_me:${as_lineno-$LINENO}: \$? = $ac_status" >&5
+  test $ac_status = 0; }; then
+  pkg_cv_LIBCURL_CFLAGS=`$PKG_CONFIG --cflags "libcurl >= 7.61.0" 2>/dev/null`
+		      test "x$?" != "x0" && pkg_failed=yes
+else
+  pkg_failed=yes
+fi
+ else
+    pkg_failed=untried
+fi
+if test -n "$LIBCURL_LIBS"; then
+    pkg_cv_LIBCURL_LIBS="$LIBCURL_LIBS"
+ elif test -n "$PKG_CONFIG"; then
+    if test -n "$PKG_CONFIG" && \
+    { { $as_echo "$as_me:${as_lineno-$LINENO}: \$PKG_CONFIG --exists --print-errors \"libcurl >= 7.61.0\""; } >&5
+  ($PKG_CONFIG --exists --print-errors "libcurl >= 7.61.0") 2>&5
+  ac_status=$?
+  $as_echo "$as_me:${as_lineno-$LINENO}: \$? = $ac_status" >&5
+  test $ac_status = 0; }; then
+  pkg_cv_LIBCURL_LIBS=`$PKG_CONFIG --libs "libcurl >= 7.61.0" 2>/dev/null`
+		      test "x$?" != "x0" && pkg_failed=yes
+else
+  pkg_failed=yes
+fi
+ else
+    pkg_failed=untried
+fi
+
+
+
+if test $pkg_failed = yes; then
+        { $as_echo "$as_me:${as_lineno-$LINENO}: result: no" >&5
+$as_echo "no" >&6; }
+
+if $PKG_CONFIG --atleast-pkgconfig-version 0.20; then
+        _pkg_short_errors_supported=yes
+else
+        _pkg_short_errors_supported=no
+fi
+        if test $_pkg_short_errors_supported = yes; then
+	        LIBCURL_PKG_ERRORS=`$PKG_CONFIG --short-errors --print-errors --cflags --libs "libcurl >= 7.61.0" 2>&1`
+        else
+	        LIBCURL_PKG_ERRORS=`$PKG_CONFIG --print-errors --cflags --libs "libcurl >= 7.61.0" 2>&1`
+        fi
+	# Put the nasty error message in config.log where it belongs
+	echo "$LIBCURL_PKG_ERRORS" >&5
+
+	as_fn_error $? "Package requirements (libcurl >= 7.61.0) were not met:
+
+$LIBCURL_PKG_ERRORS
+
+Consider adjusting the PKG_CONFIG_PATH environment variable if you
+installed software in a non-standard prefix.
+
+Alternatively, you may set the environment variables LIBCURL_CFLAGS
+and LIBCURL_LIBS to avoid the need to call pkg-config.
+See the pkg-config man page for more details." "$LINENO" 5
+elif test $pkg_failed = untried; then
+        { $as_echo "$as_me:${as_lineno-$LINENO}: result: no" >&5
+$as_echo "no" >&6; }
+	{ { $as_echo "$as_me:${as_lineno-$LINENO}: error: in \`$ac_pwd':" >&5
+$as_echo "$as_me: error: in \`$ac_pwd':" >&2;}
+as_fn_error $? "The pkg-config script could not be found or is too old.  Make sure it
+is in your PATH or set the PKG_CONFIG environment variable to the full
+path to pkg-config.
+
+Alternatively, you may set the environment variables LIBCURL_CFLAGS
+and LIBCURL_LIBS to avoid the need to call pkg-config.
+See the pkg-config man page for more details.
+
+To get pkg-config, see <http://pkg-config.freedesktop.org/>.
+See \`config.log' for more details" "$LINENO" 5; }
+else
+	LIBCURL_CFLAGS=$pkg_cv_LIBCURL_CFLAGS
+	LIBCURL_LIBS=$pkg_cv_LIBCURL_LIBS
+        { $as_echo "$as_me:${as_lineno-$LINENO}: result: yes" >&5
+$as_echo "yes" >&6; }
+
+fi
+
+  # We only care about -I, -D, and -L switches;
+  # note that -lcurl will be added by PGAC_CHECK_LIBCURL below.
+  for pgac_option in $LIBCURL_CFLAGS; do
+    case $pgac_option in
+      -I*|-D*) CPPFLAGS="$CPPFLAGS $pgac_option";;
+    esac
+  done
+  for pgac_option in $LIBCURL_LIBS; do
+    case $pgac_option in
+      -L*) LDFLAGS="$LDFLAGS $pgac_option";;
+    esac
+  done
+
+  # OAuth requires python for testing
+  if test "$with_python" != yes; then
+    { $as_echo "$as_me:${as_lineno-$LINENO}: WARNING: *** OAuth support tests require --with-python to run" >&5
+$as_echo "$as_me: WARNING: *** OAuth support tests require --with-python to run" >&2;}
+  fi
+fi
+
+
 #
 # XML
 #
@@ -12216,6 +12378,123 @@ fi
 
 fi
 
+# XXX libcurl must link after libgssapi_krb5 on FreeBSD to avoid segfaults
+# during gss_acquire_cred(). This is possibly related to Curl's Heimdal
+# dependency on that platform?
+if test "$with_libcurl" = yes ; then
+
+  ac_fn_c_check_header_mongrel "$LINENO" "curl/curl.h" "ac_cv_header_curl_curl_h" "$ac_includes_default"
+if test "x$ac_cv_header_curl_curl_h" = xyes; then :
+
+else
+  as_fn_error $? "header file <curl/curl.h> is required for --with-libcurl" "$LINENO" 5
+fi
+
+
+  { $as_echo "$as_me:${as_lineno-$LINENO}: checking for curl_multi_init in -lcurl" >&5
+$as_echo_n "checking for curl_multi_init in -lcurl... " >&6; }
+if ${ac_cv_lib_curl_curl_multi_init+:} false; then :
+  $as_echo_n "(cached) " >&6
+else
+  ac_check_lib_save_LIBS=$LIBS
+LIBS="-lcurl  $LIBS"
+cat confdefs.h - <<_ACEOF >conftest.$ac_ext
+/* end confdefs.h.  */
+
+/* Override any GCC internal prototype to avoid an error.
+   Use char because int might match the return type of a GCC
+   builtin and then its argument prototype would still apply.  */
+#ifdef __cplusplus
+extern "C"
+#endif
+char curl_multi_init ();
+int
+main ()
+{
+return curl_multi_init ();
+  ;
+  return 0;
+}
+_ACEOF
+if ac_fn_c_try_link "$LINENO"; then :
+  ac_cv_lib_curl_curl_multi_init=yes
+else
+  ac_cv_lib_curl_curl_multi_init=no
+fi
+rm -f core conftest.err conftest.$ac_objext \
+    conftest$ac_exeext conftest.$ac_ext
+LIBS=$ac_check_lib_save_LIBS
+fi
+{ $as_echo "$as_me:${as_lineno-$LINENO}: result: $ac_cv_lib_curl_curl_multi_init" >&5
+$as_echo "$ac_cv_lib_curl_curl_multi_init" >&6; }
+if test "x$ac_cv_lib_curl_curl_multi_init" = xyes; then :
+  cat >>confdefs.h <<_ACEOF
+#define HAVE_LIBCURL 1
+_ACEOF
+
+  LIBS="-lcurl $LIBS"
+
+else
+  as_fn_error $? "library 'curl' does not provide curl_multi_init" "$LINENO" 5
+fi
+
+
+  # Check to see whether the current platform supports threadsafe Curl
+  # initialization.
+  { $as_echo "$as_me:${as_lineno-$LINENO}: checking for curl_global_init thread safety" >&5
+$as_echo_n "checking for curl_global_init thread safety... " >&6; }
+if ${pgac_cv__libcurl_threadsafe_init+:} false; then :
+  $as_echo_n "(cached) " >&6
+else
+  if test "$cross_compiling" = yes; then :
+  pgac_cv__libcurl_threadsafe_init=unknown
+else
+  cat confdefs.h - <<_ACEOF >conftest.$ac_ext
+/* end confdefs.h.  */
+
+#include <curl/curl.h>
+
+int
+main ()
+{
+
+    curl_version_info_data *info;
+
+    if (curl_global_init(CURL_GLOBAL_ALL))
+        return -1;
+
+    info = curl_version_info(CURLVERSION_NOW);
+#ifdef CURL_VERSION_THREADSAFE
+    if (info->features & CURL_VERSION_THREADSAFE)
+        return 0;
+#endif
+
+    return 1;
+
+  ;
+  return 0;
+}
+_ACEOF
+if ac_fn_c_try_run "$LINENO"; then :
+  pgac_cv__libcurl_threadsafe_init=yes
+else
+  pgac_cv__libcurl_threadsafe_init=no
+fi
+rm -f core *.core core.conftest.* gmon.out bb.out conftest$ac_exeext \
+  conftest.$ac_objext conftest.beam conftest.$ac_ext
+fi
+
+fi
+{ $as_echo "$as_me:${as_lineno-$LINENO}: result: $pgac_cv__libcurl_threadsafe_init" >&5
+$as_echo "$pgac_cv__libcurl_threadsafe_init" >&6; }
+  if test x"$pgac_cv__libcurl_threadsafe_init" = xyes ; then
+
+$as_echo "#define HAVE_THREADSAFE_CURL_GLOBAL_INIT 1" >>confdefs.h
+
+  fi
+
+fi
+
 if test "$with_gssapi" = yes ; then
   if test "$PORTNAME" != "win32"; then
     { $as_echo "$as_me:${as_lineno-$LINENO}: checking for library containing gss_store_cred_into" >&5
diff --git a/configure.ac b/configure.ac
index d713360f34..e8f1a7db9d 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1007,6 +1007,40 @@ fi
 AC_SUBST(with_uuid)
 
 
+#
+# libcurl
+#
+AC_MSG_CHECKING([whether to build with libcurl support])
+PGAC_ARG_BOOL(with, libcurl, no, [build with libcurl support],
+              [AC_DEFINE([USE_LIBCURL], 1, [Define to 1 to build with libcurl support. (--with-libcurl)])])
+AC_MSG_RESULT([$with_libcurl])
+AC_SUBST(with_libcurl)
+
+if test "$with_libcurl" = yes ; then
+  # Check for libcurl 7.61.0 or higher (corresponding to RHEL8 and the ability
+  # to explicitly set TLS 1.3 ciphersuites).
+  PKG_CHECK_MODULES(LIBCURL, [libcurl >= 7.61.0])
+
+  # We only care about -I, -D, and -L switches;
+  # note that -lcurl will be added by PGAC_CHECK_LIBCURL below.
+  for pgac_option in $LIBCURL_CFLAGS; do
+    case $pgac_option in
+      -I*|-D*) CPPFLAGS="$CPPFLAGS $pgac_option";;
+    esac
+  done
+  for pgac_option in $LIBCURL_LIBS; do
+    case $pgac_option in
+      -L*) LDFLAGS="$LDFLAGS $pgac_option";;
+    esac
+  done
+
+  # OAuth requires python for testing
+  if test "$with_python" != yes; then
+    AC_MSG_WARN([*** OAuth support tests require --with-python to run])
+  fi
+fi
+
+
 #
 # XML
 #
@@ -1294,6 +1328,13 @@ failure.  It is possible the compiler isn't looking in the proper directory.
 Use --without-zlib to disable zlib support.])])
 fi
 
+# XXX libcurl must link after libgssapi_krb5 on FreeBSD to avoid segfaults
+# during gss_acquire_cred(). This is possibly related to Curl's Heimdal
+# dependency on that platform?
+if test "$with_libcurl" = yes ; then
+  PGAC_CHECK_LIBCURL
+fi
+
 if test "$with_gssapi" = yes ; then
   if test "$PORTNAME" != "win32"; then
     AC_SEARCH_LIBS(gss_store_cred_into, [gssapi_krb5 gss 'gssapi -lkrb5 -lcrypto'], [],
diff --git a/doc/src/sgml/client-auth.sgml b/doc/src/sgml/client-auth.sgml
index 782b49c85a..f84085dbac 100644
--- a/doc/src/sgml/client-auth.sgml
+++ b/doc/src/sgml/client-auth.sgml
@@ -656,6 +656,16 @@ include_dir         <replaceable>directory</replaceable>
          </para>
         </listitem>
        </varlistentry>
+
+       <varlistentry>
+        <term><literal>oauth</literal></term>
+        <listitem>
+         <para>
+          Authorize and optionally authenticate using a third-party OAuth 2.0
+          identity provider. See <xref linkend="auth-oauth"/> for details.
+         </para>
+        </listitem>
+       </varlistentry>
       </variablelist>
 
       </para>
@@ -1143,6 +1153,12 @@ omicron         bryanh                  guest1
       only on OpenBSD).
      </para>
     </listitem>
+    <listitem>
+     <para>
+      <link linkend="auth-oauth">OAuth authorization/authentication</link>,
+      which relies on an external OAuth 2.0 identity provider.
+     </para>
+    </listitem>
    </itemizedlist>
   </para>
 
@@ -2329,6 +2345,242 @@ host ... radius radiusservers="server1,server2" radiussecrets="""secret one"",""
    </note>
   </sect1>
 
+  <sect1 id="auth-oauth">
+   <title>OAuth Authorization/Authentication</title>
+
+   <indexterm zone="auth-oauth">
+    <primary>OAuth Authorization/Authentication</primary>
+   </indexterm>
+
+   <para>
+    OAuth 2.0 is an industry-standard framework, defined in
+    <ulink url="https://datatracker.ietf.org/doc/html/rfc6749">RFC 6749</ulink>,
+    to enable third-party applications to obtain limited access to a protected
+    resource.
+
+    OAuth client support has to be enabled when <productname>PostgreSQL</productname>
+    is built, see <xref linkend="installation"/> for more information.
+   </para>
+
+   <para>
+    This documentation uses the following terminology when discussing the OAuth
+    ecosystem:
+
+    <variablelist>
+
+     <varlistentry>
+      <term>Resource Owner (or End User)</term>
+      <listitem>
+       <para>
+        The user or system who owns protected resources and can grant access to
+        them. This documentation also uses the term <emphasis>end user</emphasis>
+        when the resource owner is a person. When you use
+        <application>psql</application> to connect to the database using OAuth,
+        you are the resource owner/end user.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term>Client</term>
+      <listitem>
+       <para>
+        The system which accesses the protected resources using access
+        tokens. Applications using libpq, such as <application>psql</application>,
+        are the OAuth clients when connecting to a
+        <productname>PostgreSQL</productname> cluster.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term>Resource Server</term>
+      <listitem>
+       <para>
+        The system which hosts the protected resources which are
+        accessed by the client. The <productname>PostgreSQL</productname>
+        cluster being connected to is the resource server.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term>Provider</term>
+      <listitem>
+       <para>
+        The organization, product vendor, or other entity which develops and/or
+        administers the OAuth servers and clients for a given application.
+        Different providers typically choose different implementation details
+        for their OAuth systems; a client of one provider is not generally
+        guaranteed to have access to the servers of another.
+       </para>
+       <para>
+        This use of the term "provider" is not standard, but it seems to be in
+        wide use colloquially. (It should not be confused with OpenID's similar
+        term "Identity Provider". While the implementation of OAuth in
+        <productname>PostgreSQL</productname> is intended to be interoperable
+        and compatible with OpenID Connect/OIDC, it is not itself an OIDC client
+        and does not require its use.)
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term>Authorization Server</term>
+      <listitem>
+       <para>
+        The system which receives requests from, and issues access tokens to,
+        the client after the authenticated resource owner has given approval.
+        <productname>PostgreSQL</productname> does not provide an authorization
+        server; it's obtained from the OAuth provider.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term id="auth-oauth-issuer">Issuer</term>
+      <listitem>
+       <para>
+        An identifier for an authorization server, printed as an
+        <literal>https://</literal> URL, which provides a trusted "namespace"
+        for OAuth clients and applications. The issuer identifier allows a
+        single authorization server to talk to the clients of mutually
+        untrusting entities, as long as they maintain separate issuers.
+       </para>
+      </listitem>
+     </varlistentry>
+
+    </variablelist>
+
+    <note>
+     <para>
+      For small deployments, there may not be a meaningful distinction between
+      the "provider", "authorization server", and "issuer". However, for more
+      complicated setups, there may be a one-to-many (or many-to-many)
+      relationship: a provider may rent out multiple issuer identifiers to
+      separate tenants, then provide multiple authorization servers, possibly
+      with different supported feature sets, to interact with their clients.
+     </para>
+    </note>
+   </para>
+
+   <para>
+    <productname>PostgreSQL</productname> supports bearer tokens, defined in
+    <ulink url="https://datatracker.ietf.org/doc/html/rfc6750">RFC 6750</ulink>,
+    which are a type of access token used with OAuth 2.0 where the token is an
+    opaque string.  The format of the access token is implementation specific
+    and is chosen by each authorization server.
+   </para>
+
+   <para>
+    The following configuration options are supported for OAuth:
+    <variablelist>
+     <varlistentry>
+      <term><literal>issuer</literal></term>
+      <listitem>
+       <para>
+        An HTTPS URL which is either the exact
+        <link linkend="auth-oauth-issuer">issuer identifier</link> of the
+        authorization server, as defined by its discovery document, or a
+        well-known URI that points directly to that discovery document. This
+        parameter is required.
+       </para>
+       <para>
+        When an OAuth client connects to the server, a URL for the discovery
+        document will be constructed using the issuer identifier. By default,
+        this URL uses the conventions of OpenID Connect Discovery: the path
+        <literal>/.well-known/openid-configuration</literal> will be appended
+        to the end of the issuer identifier. Alternatively, if the
+        <literal>issuer</literal> contains a <literal>/.well-known/</literal>
+        path segment, that URL will be provided to the client as-is.
+       </para>
+       <warning>
+        <para>
+         The OAuth client in libpq requires the server's issuer setting to
+         exactly match the issuer identifier which is provided in the discovery
+         document, which must in turn match the client's
+         <xref linkend="libpq-connect-oauth-issuer"/> setting. No variations in
+         case or format are permitted.
+        </para>
+       </warning>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term><literal>scope</literal></term>
+      <listitem>
+       <para>
+        A space-separated list of the OAuth scopes needed for the server to
+        both authorize the client and authenticate the user.  Appropriate values
+        are determined by the authorization server and the OAuth validation
+        module used (see <xref linkend="oauth-validators" /> for more
+        information on validators).  This parameter is required.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term><literal>validator</literal></term>
+      <listitem>
+       <para>
+        The library to use for validating bearer tokens. If given, the name must
+        exactly match one of the libraries listed in
+        <xref linkend="guc-oauth-validator-libraries" />.  This parameter is
+        optional unless <literal>oauth_validator_libraries</literal> contains
+        more than one library, in which case it is required.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term><literal>map</literal></term>
+      <listitem>
+       <para>
+        Allows for mapping between OAuth identity provider and database user
+        names.  See <xref linkend="auth-username-maps"/> for details.  If a
+        map is not specified, the user name associated with the token (as
+        determined by the OAuth validator) must exactly match the role name
+        being requested.  This parameter is optional.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term id="auth-oauth-delegate-ident-mapping" xreflabel="delegate_ident_mapping">
+       <literal>delegate_ident_mapping</literal>
+      </term>
+      <listitem>
+       <para>
+        An advanced option which is not intended for common use.
+       </para>
+       <para>
+        When set to <literal>1</literal>, standard user mapping with
+        <filename>pg_ident.conf</filename> is skipped, and the OAuth validator
+        takes full responsibility for mapping end user identities to database
+        roles.  If the validator authorizes the token, the server trusts that
+        the user is allowed to connect under the requested role, and the
+        connection is allowed to proceed regardless of the authentication
+        status of the user.
+       </para>
+       <para>
+        This parameter is incompatible with <literal>map</literal>.
+       </para>
+       <warning>
+        <para>
+         <literal>delegate_ident_mapping</literal> provides additional
+         flexibility in the design of the authentication system, but it also
+         requires careful implementation of the OAuth validator, which must
+         determine whether the provided token carries sufficient end-user
+         privileges in addition to the <link linkend="oauth-validators">standard
+         checks</link> required of all validators.  Use with caution.
+        </para>
+       </warning>
+      </listitem>
+     </varlistentry>
+    </variablelist>
+   </para>
+  </sect1>
+
   <sect1 id="client-authentication-problems">
    <title>Authentication Problems</title>
 
diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml
index a782f10998..d7bac61a7f 100644
--- a/doc/src/sgml/config.sgml
+++ b/doc/src/sgml/config.sgml
@@ -1209,6 +1209,32 @@ include_dir 'conf.d'
        </para>
       </listitem>
      </varlistentry>
+
+     <varlistentry id="guc-oauth-validator-libraries" xreflabel="oauth_validator_libraries">
+      <term><varname>oauth_validator_libraries</varname> (<type>string</type>)
+      <indexterm>
+       <primary><varname>oauth_validator_libraries</varname> configuration parameter</primary>
+      </indexterm>
+      </term>
+      <listitem>
+       <para>
+        The library/libraries to use for validating OAuth connection tokens. If
+        only one validator library is provided, it will be used by default for
+        any OAuth connections; otherwise, all
+        <link linkend="auth-oauth"><literal>oauth</literal> HBA entries</link>
+        must explicitly set a <literal>validator</literal> chosen from this
+        list. If set to an empty string (the default), OAuth connections will be
+        refused. This parameter can only be set in the
+        <filename>postgresql.conf</filename> file.
+       </para>
+       <para>
+        Validator modules must be implemented/obtained separately;
+        <productname>PostgreSQL</productname> does not ship with any default
+        implementations. For more information on implementing OAuth validators,
+        see <xref linkend="oauth-validators" />.
+       </para>
+      </listitem>
+     </varlistentry>
      </variablelist>
      </sect2>
 
diff --git a/doc/src/sgml/filelist.sgml b/doc/src/sgml/filelist.sgml
index 66e6dccd4c..25fb99cee6 100644
--- a/doc/src/sgml/filelist.sgml
+++ b/doc/src/sgml/filelist.sgml
@@ -111,6 +111,7 @@
 <!ENTITY generic-wal SYSTEM "generic-wal.sgml">
 <!ENTITY custom-rmgr SYSTEM "custom-rmgr.sgml">
 <!ENTITY backup-manifest SYSTEM "backup-manifest.sgml">
+<!ENTITY oauth-validators SYSTEM "oauth-validators.sgml">
 
 <!-- contrib information -->
 <!ENTITY contrib         SYSTEM "contrib.sgml">
diff --git a/doc/src/sgml/installation.sgml b/doc/src/sgml/installation.sgml
index 3f0a7e9c06..96e433179b 100644
--- a/doc/src/sgml/installation.sgml
+++ b/doc/src/sgml/installation.sgml
@@ -1143,6 +1143,19 @@ build-postgresql:
        </listitem>
       </varlistentry>
 
+      <varlistentry id="configure-option-with-libcurl">
+       <term><option>--with-libcurl</option></term>
+       <listitem>
+        <para>
+         Build with libcurl support for OAuth 2.0 client flows.
+         This requires the <productname>curl</productname> package to be
+         installed.  Building with this will check for the required header files
+         and libraries to make sure that your <productname>curl</productname>
+         installation is sufficient before proceeding.
+        </para>
+       </listitem>
+      </varlistentry>
+
       <varlistentry id="configure-option-with-libxml">
        <term><option>--with-libxml</option></term>
        <listitem>
@@ -2584,6 +2597,20 @@ ninja install
       </listitem>
      </varlistentry>
 
+     <varlistentry id="configure-with-libcurl-meson">
+      <term><option>-Dlibcurl={ auto | enabled | disabled }</option></term>
+      <listitem>
+       <para>
+        Build with libcurl support for OAuth 2.0 client flows.
+        This requires the <productname>curl</productname> package to be
+        installed.  Building with this will check for the required header files
+        and libraries to make sure that your <productname>curl</productname>
+        installation is sufficient before proceeding. The default for this
+        option is auto.
+       </para>
+      </listitem>
+     </varlistentry>
+
      <varlistentry id="configure-with-libxml-meson">
       <term><option>-Dlibxml={ auto | enabled | disabled }</option></term>
       <listitem>
diff --git a/doc/src/sgml/libpq.sgml b/doc/src/sgml/libpq.sgml
index e04acf1c20..9a69ffbc5b 100644
--- a/doc/src/sgml/libpq.sgml
+++ b/doc/src/sgml/libpq.sgml
@@ -1385,6 +1385,15 @@ postgresql://%2Fvar%2Flib%2Fpostgresql/dbname
           </listitem>
          </varlistentry>
 
+         <varlistentry>
+          <term><literal>oauth</literal></term>
+          <listitem>
+           <para>
+            The server must request an OAuth bearer token from the client.
+           </para>
+          </listitem>
+         </varlistentry>
+
          <varlistentry>
           <term><literal>none</literal></term>
           <listitem>
@@ -2373,6 +2382,106 @@ postgresql://%2Fvar%2Flib%2Fpostgresql/dbname
        </para>
       </listitem>
      </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-issuer" xreflabel="oauth_issuer">
+      <term><literal>oauth_issuer</literal></term>
+      <listitem>
+       <para>
+        The HTTPS URL of a trusted issuer to contact if the server requests an
+        OAuth token for the connection. This parameter is required for all OAuth
+        connections; it should exactly match the <literal>issuer</literal>
+        setting in <link linkend="auth-oauth">the server's HBA configuration.</link>
+       </para>
+       <para>
+        As part of the standard authentication handshake, <application>libpq</application>
+        will ask the server for a <emphasis>discovery document:</emphasis> a URL
+        providing a set of OAuth configuration parameters. The server must
+        provide a URL that is directly constructed from the components of the
+        <literal>oauth_issuer</literal>, and this value must exactly match the
+        issuer identifier that is declared in the discovery document itself, or
+        the connection will fail. This is required to prevent a class of "mix-up
+        attacks" on OAuth clients.
+       </para>
+       <para>
+        You may also explicitly set <literal>oauth_issuer</literal> to the
+        <literal>/.well-known/</literal> URI used for OAuth discovery. In this
+        case, if the server asks for a different URL, the connection will fail,
+        but a <link linkend="libpq-oauth-authdata-hooks">custom OAuth flow</link>
+        may be able to speed up the standard handshake by using previously
+        cached tokens. (In this case, it is recommended that
+        <xref linkend="libpq-connect-oauth-scope"/> be set as well, since the
+        client will not have a chance to ask the server for a correct scope
+        setting, and the default scopes for a token may not be sufficient to
+        connect.) <application>libpq</application> currently supports the
+        following well-known endpoints:
+        <itemizedlist spacing="compact">
+         <listitem><para><literal>/.well-known/openid-configuration</literal></para></listitem>
+         <listitem><para><literal>/.well-known/oauth-authorization-server</literal></para></listitem>
+        </itemizedlist>
+       </para>
+       <warning>
+        <para>
+         Issuers are highly privileged during the OAuth connection handshake. As
+         a rule of thumb, if you would not trust the operator of a URL to handle
+         access to your servers, or to impersonate you directly, that URL should
+         not be trusted as an <literal>oauth_issuer</literal>.
+        </para>
+       </warning>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-client-id" xreflabel="oauth_client_id">
+      <term><literal>oauth_client_id</literal></term>
+      <listitem>
+       <para>
+        An OAuth 2.0 client identifier, as issued by the authorization server.
+        If the <productname>PostgreSQL</productname> server
+        <link linkend="auth-oauth">requests an OAuth token</link> for the
+        connection (and if no <link linkend="libpq-oauth-authdata-hooks">custom
+        OAuth hook</link> is installed to provide one), then this parameter must
+        be set; otherwise, the connection will fail.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-client-secret" xreflabel="oauth_client_secret">
+      <term><literal>oauth_client_secret</literal></term>
+      <listitem>
+       <para>
+        The client password, if any, to use when contacting the OAuth
+        authorization server. Whether this parameter is required or not is
+        determined by the OAuth provider; "public" clients generally do not use
+        a secret, whereas "confidential" clients generally do.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-scope" xreflabel="oauth_scope">
+      <term><literal>oauth_scope</literal></term>
+      <listitem>
+       <para>
+        The scope of the access request sent to the authorization server,
+        specified as a (possibly empty) space-separated list of OAuth scope
+        identifiers. This parameter is optional and intended for advanced usage.
+       </para>
+       <para>
+        Usually the client will obtain appropriate scope settings from the
+        <productname>PostgreSQL</productname> server. If this parameter is used,
+        the server's requested scope list will be ignored. This can prevent a
+        less-trusted server from requesting inappropriate access scopes from the
+        end user. However, if the client's scope setting does not contain the
+        server's required scopes, the server is likely to reject the issued
+        token, and the connection will fail.
+       </para>
+       <para>
+        The meaning of an empty scope list is provider-dependent. An OAuth
+        authorization server may choose to issue a token with "default scope",
+        whatever that happens to be, or it may reject the token request
+        entirely.
+       </para>
+      </listitem>
+     </varlistentry>
+
     </variablelist>
    </para>
   </sect2>
@@ -10020,6 +10129,278 @@ void PQinitSSL(int do_ssl);
 
  </sect1>
 
+ <sect1 id="libpq-oauth">
+  <title>OAuth Support</title>
+
+  <para>
+   TODO
+  </para>
+
+  <sect2 id="libpq-oauth-authdata-hooks">
+   <title>Authdata Hooks</title>
+
+   <para>
+    The behavior of the OAuth flow may be modified or replaced by a client using
+    the following hook API:
+
+    <variablelist>
+     <varlistentry id="libpq-PQsetAuthDataHook">
+      <term><function>PQsetAuthDataHook</function><indexterm><primary>PQsetAuthDataHook</primary></indexterm></term>
+
+      <listitem>
+       <para>
+        Sets the <symbol>PGauthDataHook</symbol>, overriding
+        <application>libpq</application>'s handling of one or more aspects of
+        its OAuth client flow.
+<synopsis>
+void PQsetAuthDataHook(PQauthDataHook_type hook);
+</synopsis>
+        If <replaceable>hook</replaceable> is <literal>NULL</literal>, the
+        default handler will be reinstalled. Otherwise, the application passes
+        a pointer to a callback function with the signature:
+<programlisting>
+int hook_fn(PGauthData type, PGconn *conn, void *data);
+</programlisting>
+        which <application>libpq</application> will call when when action is
+        required of the application. <replaceable>type</replaceable> describes
+        the request being made, <replaceable>conn</replaceable> is the
+        connection handle being authenticated, and <replaceable>data</replaceable>
+        points to request-specific metadata. The contents of this pointer are
+        determined by <replaceable>type</replaceable>; see
+        <xref linkend="libpq-oauth-authdata-hooks-types"/> for the supported
+        list.
+       </para>
+       <para>
+        Hooks can be chained together to allow cooperative and/or fallback
+        behavior. In general, a hook implementation should examine the incoming
+        <replaceable>type</replaceable> (and, potentially, the request metadata
+        and/or the settings for the particular <replaceable>conn</replaceable>
+        in use) to decide whether or not to handle a specific piece of authdata.
+        If not, it should delegate to the previous hook in the chain
+        (retrievable via <function>PQgetAuthDataHook</function>).
+       </para>
+       <para>
+        Success is indicated by returning an integer greater than zero.
+        Returning a negative integer signals an error condition and abandons the
+        connection attempt. (A zero value is reserved for the default
+        implementation.)
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="libpq-PQgetAuthDataHook">
+      <term><function>PQgetAuthDataHook</function><indexterm><primary>PQgetAuthDataHook</primary></indexterm></term>
+
+      <listitem>
+       <para>
+        Retrieves the current value of <symbol>PGauthDataHook</symbol>.
+<synopsis>
+PQauthDataHook_type PQgetAuthDataHook(void);
+</synopsis>
+        At initialization time (before the first call to
+        <function>PQsetAuthDataHook</function>), this function will return
+        <symbol>PQdefaultAuthDataHook</symbol>.
+       </para>
+      </listitem>
+     </varlistentry>
+    </variablelist>
+   </para>
+
+   <sect3 id="libpq-oauth-authdata-hooks-types">
+    <title>Hook Types</title>
+    <para>
+     The following <symbol>PGauthData</symbol> types and their corresponding
+     <replaceable>data</replaceable> structures are defined:
+
+     <variablelist>
+      <varlistentry id="libpq-oauth-authdata-prompt-oauth-device">
+       <term>
+        <symbol>PQAUTHDATA_PROMPT_OAUTH_DEVICE</symbol>
+        <indexterm><primary>PQAUTHDATA_PROMPT_OAUTH_DEVICE</primary></indexterm>
+       </term>
+       <listitem>
+        <para>
+         Replaces the default user prompt during the builtin device
+         authorization client flow. <replaceable>data</replaceable> points to
+         an instance of <symbol>PGpromptOAuthDevice</symbol>:
+<synopsis>
+typedef struct _PGpromptOAuthDevice
+{
+    const char *verification_uri;   /* verification URI to visit */
+    const char *user_code;          /* user code to enter */
+} PGpromptOAuthDevice;
+</synopsis>
+        </para>
+        <para>
+         The OAuth Device Authorization flow included in <application>libpq</application>
+         requires the end user to visit a URL with a browser, then enter a code
+         which permits <application>libpq</application> to connect to the server
+         on their behalf. The default prompt simply prints the
+         <literal>verification_uri</literal> and <literal>user_code</literal>
+         on standard error. Replacement implementations may display this
+         information using any preferred method, for example with a GUI.
+        </para>
+        <para>
+         This callback is only invoked during the builtin device
+         authorization flow. If the application installs a
+         <link linkend="libpq-oauth-authdata-oauth-bearer-token">custom OAuth
+         flow</link>, this authdata type will not be used.
+        </para>
+       </listitem>
+      </varlistentry>
+
+      <varlistentry id="libpq-oauth-authdata-oauth-bearer-token">
+       <term>
+        <symbol>PQAUTHDATA_OAUTH_BEARER_TOKEN</symbol>
+        <indexterm><primary>PQAUTHDATA_OAUTH_BEARER_TOKEN</primary></indexterm>
+       </term>
+       <listitem>
+        <para>
+         Replaces the entire OAuth flow with a custom implementation. The hook
+         should either directly return a Bearer token for the current
+         user/issuer/scope combination, if one is available without blocking, or
+         else set up an asynchronous callback to retrieve one.
+        </para>
+        <para>
+         <replaceable>data</replaceable> points to an instance
+         of <symbol>PGoauthBearerRequest</symbol>, which should be filled in
+         by the implementation:
+<synopsis>
+typedef struct _PGoauthBearerRequest
+{
+    /* Hook inputs (constant across all calls) */
+    const char *const openid_configuration; /* OIDC discovery URL */
+    const char *const scope;                /* required scope(s), or NULL */
+
+    /* Hook outputs */
+
+    /* Callback implementing a custom asynchronous OAuth flow. */
+    PostgresPollingStatusType (*async) (PGconn *conn,
+                                        struct _PGoauthBearerRequest *request,
+                                        SOCKTYPE *altsock);
+
+    /* Callback to clean up custom allocations. */
+    void        (*cleanup) (PGconn *conn, struct _PGoauthBearerRequest *request);
+
+    char       *token;   /* acquired Bearer token */
+    void       *user;    /* hook-defined allocated data */
+} PGoauthBearerRequest;
+</synopsis>
+        </para>
+        <para>
+         Two pieces of information are provided to the hook by
+         <application>libpq</application>:
+         <replaceable>openid_configuration</replaceable> contains the URL of an
+         OAuth discovery document describing the authorization server's
+         supported flows, and <replaceable>scope</replaceable> contains a
+         (possibly empty) space-separated list of OAuth scopes which are
+         required to access the server. Either or both may be
+         <literal>NULL</literal> to indicate that the information was not
+         discoverable. (In this case, implementations may be able to establish
+         the requirements using some other preconfigured knowledge, or they may
+         choose to fail.)
+        </para>
+        <para>
+         The final output of the hook is <replaceable>token</replaceable>, which
+         must point to a valid Bearer token for use on the connection. (This
+         token should be issued by the
+         <xref linkend="libpq-connect-oauth-issuer"/> and hold the requested
+         scopes, or the connection will be rejected by the server's validator
+         module.) The allocated token string must remain valid until
+         <application>libpq</application> is finished connecting; the hook
+         should set a <replaceable>cleanup</replaceable> callback which will be
+         called when <application>libpq</application> no longer requires it.
+        </para>
+        <para>
+         If an implementation cannot immediately produce a
+         <replaceable>token</replaceable> during the initial call to the hook,
+         it should set the <replaceable>async</replaceable> callback to handle
+         nonblocking communication with the authorization server.
+         <footnote>
+          <para>
+           Performing blocking operations during the
+           <symbol>PQAUTHDATA_OAUTH_BEARER_TOKEN</symbol> hook callback will
+           interfere with nonblocking connection APIs such as
+           <function>PQconnectPoll</function> and prevent concurrent connections
+           from making progress. Applications which only ever use the
+           synchronous connection primitives, such as
+           <function>PQconnectdb</function>, may synchronously retrieve a token
+           during the hook instead of implementing the
+           <replaceable>async</replaceable> callback, but they will necessarily
+           be limited to one connection at a time.
+          </para>
+         </footnote>
+         This will be called to begin the flow immediately upon return from the
+         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
+         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>
+         after setting <replaceable>token</replaceable> when the flow is
+         complete, or <symbol>PGRES_POLLING_FAILED</symbol> to indicate failure.
+        </para>
+        <para>
+         Implementations may wish to store additional data for bookkeeping
+         across calls to the <replaceable>async</replaceable> and
+         <replaceable>cleanup</replaceable> callbacks. The
+         <replaceable>user</replaceable> pointer is provided for this purpose;
+         <application>libpq</application> will not touch its contents and the
+         application may use it at its convenience. (Remember to free any
+         allocations during token cleanup.)
+        </para>
+       </listitem>
+      </varlistentry>
+     </variablelist>
+    </para>
+   </sect3>
+  </sect2>
+
+  <sect2 id="libpq-oauth-debugging">
+   <title>Debugging and Developer Settings</title>
+
+   <para>
+    A "dangerous debugging mode" may be enabled by setting the environment
+    variable <envar>PGOAUTHDEBUG=UNSAFE</envar>. This functionality is provided
+    for ease of local development and testing only. It does several things that
+    you will not want a production system to do:
+
+    <itemizedlist spacing="compact">
+     <listitem>
+      <para>
+       permits the use of unencrypted HTTP during the OAuth provider exchange
+      </para>
+     </listitem>
+     <listitem>
+      <para>
+       allows the system's trusted CA list to be completely replaced using the
+       <envar>PGOAUTHCAFILE</envar> environment variable
+      </para>
+     </listitem>
+     <listitem>
+      <para>
+       sprays HTTP traffic (containing several critical secrets) to standard
+       error during the OAuth flow
+      </para>
+     </listitem>
+     <listitem>
+      <para>
+       permits the use of zero-second retry intervals, which can cause the
+       client to busy-loop and pointlessly consume CPU
+      </para>
+     </listitem>
+    </itemizedlist>
+   </para>
+   <warning>
+    <para>
+     Do not share the output of the OAuth flow traffic with third parties. It
+     contains secrets that can be used to attack your clients and servers.
+    </para>
+   </warning>
+  </sect2>
+ </sect1>
+
 
  <sect1 id="libpq-threading">
   <title>Behavior in Threaded Programs</title>
@@ -10092,6 +10473,18 @@ int PQisthreadsafe();
    <application>libpq</application> source code for a way to do cooperative
    locking between <application>libpq</application> and your application.
   </para>
+
+  <para>
+   Similarly, if you are using Curl inside your application,
+   <emphasis>and</emphasis> you do not already
+   <ulink url="https://curl.se/libcurl/c/curl_global_init.html">initialize
+   libcurl globally</ulink> before starting new threads, you will need to
+   cooperatively lock (again via <function>PQregisterThreadLock</function>)
+   around any code that may initialize libcurl. This restriction is lifted for
+   more recent versions of Curl that are built to support threadsafe
+   initialization; those builds can be identified by the advertisement of a
+   <literal>threadsafe</literal> feature in their version metadata.
+  </para>
  </sect1>
 
 
diff --git a/doc/src/sgml/oauth-validators.sgml b/doc/src/sgml/oauth-validators.sgml
new file mode 100644
index 0000000000..d0bca9196d
--- /dev/null
+++ b/doc/src/sgml/oauth-validators.sgml
@@ -0,0 +1,402 @@
+<!-- doc/src/sgml/oauth-validators.sgml -->
+
+<chapter id="oauth-validators">
+ <title>OAuth Validator Modules</title>
+ <indexterm zone="oauth-validators">
+  <primary>OAuth Validators</primary>
+ </indexterm>
+ <para>
+  <productname>PostgreSQL</productname> provides infrastructure for creating
+  custom modules to perform server-side validation of OAuth bearer tokens.
+  Because OAuth implementations vary so wildly, and bearer token validation is
+  heavily dependent on the issuing party, the server cannot check the token
+  itself; validator modules provide the glue between the server and the OAuth
+  provider in use.
+ </para>
+ <para>
+  OAuth validator modules must at least consist of an initialization function
+  (see <xref linkend="oauth-validator-init"/>) and the required callback for
+  performing validation (see <xref linkend="oauth-validator-callback-validate"/>).
+ </para>
+ <warning>
+  <para>
+   Since a misbehaving validator might let unauthorized users into the database,
+   correct implementation is critical. See
+   <xref linkend="oauth-validator-design"/> for design considerations.
+  </para>
+ </warning>
+
+ <sect1 id="oauth-validator-design">
+  <title>Safely Designing a Validator Module</title>
+  <warning>
+   <para>
+    Read and understand the entirety of this section before implementing a
+    validator module. A malfunctioning validator is potentially worse than no
+    authentication at all, both because of the false sense of security it
+    provides, and because it may contribute to attacks against other pieces of
+    an OAuth ecosystem.
+   </para>
+  </warning>
+
+  <sect2 id="oauth-validator-design-responsibilities">
+   <title>Validator Responsibilities</title>
+   <para>
+    TODO
+   </para>
+   <variablelist>
+    <varlistentry>
+     <term>Validate the Token</term>
+     <listitem>
+      <para>
+       The validator must first ensure that the presented token is in fact a
+       valid Bearer token for use in client authentication. The correct way to
+       do this depends on the provider, but it generally involves either
+       cryptographic operations to prove that the token was created by a trusted
+       party (offline validation), or the presentation of the token to that
+       trusted party so that it can perform validation for you (online
+       validation).
+      </para>
+      <para>
+       Online validation, usually implemented via
+       <ulink url="https://datatracker.ietf.org/doc/html/rfc7662">OAuth Token
+       Introspection</ulink>, requires fewer steps of a validator module and
+       allows central revocation of a token in the event that it is stolen
+       or misissued. However, it does require the module to make at least one
+       network call per authentication attempt (all of which must complete
+       within the configured <xref linkend="guc-authentication-timeout"/>).
+       Additionally, your provider may not provide introspection endpoints for
+       use by external resource servers.
+      </para>
+      <para>
+       Offline validation is much more involved, typically requiring a validator
+       to maintain a list of trusted signing keys for a provider and then
+       check the token's cryptographic signature along with its contents.
+       Implementations must follow the provider's instructions to the letter,
+       including any verification of issuer ("where is this token from?"),
+       audience ("who is this token for?"), and validity period ("when can this
+       token be used?"). Since there is no communication between the module and
+       the provider, tokens cannot be centrally revoked using this method;
+       offline validator implementations may wish to place restrictions on the
+       maximum length of a token's validity period.
+      </para>
+      <para>
+       If the token cannot be validated, the module should immediately fail.
+       Further authentication/authorization is pointless if the bearer token
+       wasn't issued by a trusted party.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Authorize the Client</term>
+     <listitem>
+      <para>
+       Next the validator must ensure that the end user has given the client
+       permission to access the server on their behalf. This generally involves
+       checking the scopes that have been assigned to the token, to make sure
+       that they cover database access for the current HBA parameters.
+      </para>
+      <para>
+       The purpose of this step is to prevent an OAuth client from obtaining a
+       token under false pretenses. If the validator requires all tokens to
+       carry scopes that cover database access, the provider should then loudly
+       prompt the user to grant that access during the flow. This gives them the
+       opportunity to reject the request if the client isn't supposed to be
+       using their credentials to connect to databases.
+      </para>
+      <para>
+       While it is possible to establish client authorization without explicit
+       scopes by using out-of-band knowledge of the deployed architecture, doing
+       so removes the user from the loop, which prevents them from catching
+       deployment mistakes and allows any such mistakes to be exploited
+       silently. Access to the database must be tightly restricted to only
+       trusted clients
+       <footnote>
+        <para>
+         That is, "trusted" in the sense that the OAuth client and the
+         <productname>PostgreSQL</productname> server are controlled by the same
+         entity. Notably, the Device Authorization client flow supported by
+         libpq does not usually meet this bar, since it's designed for use by
+         public/untrusted clients.
+        </para>
+       </footnote>
+       if users are not prompted for additional scopes.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Authenticate the End User</term>
+     <listitem>
+      <para>
+       Finally, the validator should determine a user identifier for the token,
+       either by asking the provider for this information or by extracting it
+       from the token itself, and return that identifier to the server (which
+       will then make a final authorization decision using the HBA
+       configuration). This identifier will be available within the session via
+       <link linkend="functions-info-session-table"><function>system_user</function></link>
+       and recorded in the server logs if <xref linkend="guc-log-connections"/>
+       is enabled.
+      </para>
+      <para>
+       Different providers may record a variety of different authentication
+       information for an end user, typically referred to as
+       <emphasis>claims</emphasis>. Providers usually document which of these
+       claims are trustworthy enough to use for authorization decisions and
+       which are not. (For instance, it would probably not be wise to use an
+       end user's full name as the identifier for authentication, since many
+       providers allow users to change their display names arbitrarily.)
+       Ultimately, the choice of which claim (or combination of claims) to use
+       comes down to the provider implementation and application requirements.
+      </para>
+      <para>
+       Note that anonymous/pseudonymous login is possible as well, by enabling
+       usermap delegation; see
+       <xref linkend="oauth-validator-design-usermap-delegation"/>.
+      </para>
+     </listitem>
+    </varlistentry>
+   </variablelist>
+  </sect2>
+
+  <sect2 id="oauth-validator-design-guidelines">
+   <title>General Coding Guidelines</title>
+   <para>
+    Developers should keep the following in mind when implementing token
+    validation:
+   </para>
+   <variablelist>
+    <varlistentry>
+     <term>Token Confidentiality</term>
+     <listitem>
+      <para>
+       Modules should not write tokens, or pieces of tokens, into the server
+       log. This is true even if the module considers the token invalid; an
+       attacker who confuses a client into communicating with the wrong provider
+       should not be able to retrieve that (otherwise valid) token from the
+       disk.
+      </para>
+      <para>
+       Implementations that send tokens over the network (for example, to
+       perform online token validation with a provider) must authenticate the
+       peer and ensure that strong transport security is in use.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Logging</term>
+     <listitem>
+      <para>
+       Modules may use the same <link linkend="error-message-reporting">logging
+       facilities</link> as standard extensions; however, the rules for emitting
+       log entries to the client are subtly different during the authentication
+       phase of the connection. Generally speaking, modules should log
+       verification problems at the <symbol>COMMERROR</symbol> level and return
+       normally, instead of using <symbol>ERROR</symbol>/<symbol>FATAL</symbol>
+       to unwind the stack, to avoid leaking information to unauthenticated
+       clients.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Interruptibility</term>
+     <listitem>
+      <para>
+       Modules must remain interruptible by signals so that the server can
+       correctly handle authentication timeouts and shutdown signals from
+       <application>pg_ctl</application>. For example, a module receiving
+       <symbol>EINTR</symbol>/<symbol>EAGAIN</symbol> from a blocking call
+       should call <function>CHECK_FOR_INTERRUPTS()</function> before retrying.
+       The same should be done during any long-running loops. Failure to follow
+       this guidance may result in unresponsive backend sessions.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Testing</term>
+     <listitem>
+      <para>
+       The breadth of testing an OAuth system is well beyond the scope of this
+       documentation, but at minimum, negative testing should be considered
+       mandatory. It's trivial to design a module that lets authorized users in;
+       the whole point of the system is to keep unauthorized users out.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Documentation</term>
+     <listitem>
+      <para>
+       Validator implementations should document the contents and format of the
+       authenticated ID that is reported to the server for each end user, since
+       DBAs may need to use this information to construct pg_ident maps. (For
+       instance, is it an email address? an organizational ID number? a UUID?)
+       They should also document whether or not it is safe to use the module in
+       <symbol>delegate_ident_mapping=1</symbol> mode, and what additional
+       configuration is required in order to do so.
+      </para>
+     </listitem>
+    </varlistentry>
+   </variablelist>
+  </sect2>
+
+  <sect2 id="oauth-validator-design-usermap-delegation">
+   <title>Authorizing Users (Usermap Delegation)</title>
+   <para>
+    The standard deliverable of a validation module is the user identifier,
+    which the server will then compare to any configured
+    <link linkend="auth-username-maps"><filename>pg_ident.conf</filename>
+    mappings</link> and determine whether the end user is authorized to connect.
+    However, OAuth is itself an authorization framework, and tokens may carry
+    information about user privileges. For example, a token may be associated
+    with the organizational groups that a user belongs to, or list the roles
+    that a user may assume, and duplicating that knowledge into local usermaps
+    for every server may not be desirable.
+   </para>
+   <para>
+    To bypass username mapping entirely, and have the validator module assume
+    the additional responsibility of authorizing user connections, the HBA may
+    be configured with <xref linkend="auth-oauth-delegate-ident-mapping"/>.
+    The module may then use token scopes or an equivalent method to decide
+    whether the user is allowed to connect under their desired role. The user
+    identifier will still be recorded by the server, but it plays no part in
+    determining whether to continue the connection.
+   </para>
+   <para>
+    Using this scheme, authentication itself is optional. As long as the module
+    reports that the connection is authorized, login will continue even if there
+    is no recorded user identifier at all. This makes it possible to implement
+    anonymous or pseudonymous access to the database, where the third-party
+    provider performs all necessary authentication but does not provide any
+    user-identifying information to the server. (Some providers may create an
+    anonymized ID number that can be recorded instead, for later auditing.)
+   </para>
+   <para>
+    Usermap delegation provides the most architectural flexibility, but it turns
+    the validator module into a single point of failure for connection
+    authorization. Use with caution.
+   </para>
+  </sect2>
+ </sect1>
+
+ <sect1 id="oauth-validator-init">
+  <title>Initialization Functions</title>
+  <indexterm zone="oauth-validator-init">
+   <primary>_PG_oauth_validator_module_init</primary>
+  </indexterm>
+  <para>
+   An OAuth validator module is loaded by dynamically loading one of the shared
+   libraries listed in <xref linkend="guc-oauth-validator-libraries"/>.
+   The normal library search path is used to locate the library. To
+   provide the validator callbacks and to indicate that the library is an OAuth
+   validator module a function named
+   <function>_PG_oauth_validator_module_init</function> must be provided. The
+   return value of the function must be a pointer to a struct of type
+   <structname>OAuthValidatorCallbacks</structname>, which contains pointers to
+   the module's token validation functions. The returned
+   pointer must be of server lifetime, which is typically achieved by defining
+   it as a <literal>static const</literal> variable in global scope.
+<programlisting>
+typedef struct OAuthValidatorCallbacks
+{
+    ValidatorStartupCB startup_cb;
+    ValidatorShutdownCB shutdown_cb;
+    ValidatorValidateCB validate_cb;
+} OAuthValidatorCallbacks;
+
+typedef const OAuthValidatorCallbacks *(*OAuthValidatorModuleInit) (void);
+</programlisting>
+
+   Only the <function>validate_cb</function> callback is required, the others
+   are optional.
+  </para>
+ </sect1>
+
+ <sect1 id="oauth-validator-callbacks">
+  <title>OAuth Validator Callbacks</title>
+  <para>
+   OAuth validator modules implement their functionality by defining a set of
+   callbacks. The server will call them as required to process the
+   authentication request from the user.
+  </para>
+
+  <sect2 id="oauth-validator-callback-startup">
+   <title>Startup Callback</title>
+   <para>
+    The <function>startup_cb</function> callback is executed directly after
+    loading the module. This callback can be used to set up local state and
+    perform additional initialization if required. If the validator module
+    has state it can use <structfield>state->private_data</structfield> to
+    store it.
+
+<programlisting>
+typedef void (*ValidatorStartupCB) (ValidatorModuleState *state);
+</programlisting>
+   </para>
+  </sect2>
+
+  <sect2 id="oauth-validator-callback-validate">
+   <title>Validate Callback</title>
+   <para>
+    The <function>validate_cb</function> callback is executed during the OAuth
+    exchange when a user attempts to authenticate using OAuth.  Any state set in
+    previous calls will be available in <structfield>state->private_data</structfield>.
+
+<programlisting>
+typedef ValidatorModuleResult *(*ValidatorValidateCB) (ValidatorModuleState *state, const char *token, const char *role);
+</programlisting>
+
+    <replaceable>token</replaceable> will contain the bearer token to validate.
+    The server has ensured that the token is well-formed syntactically, but no
+    other validation has been performed.  <replaceable>role</replaceable> will
+    contain the role the user has requested to log in as.  The callback must
+    return a palloc'd <literal>ValidatorModuleResult</literal> struct, which is
+    defined as below:
+
+<programlisting>
+typedef struct ValidatorModuleResult
+{
+    bool        authorized;
+    char       *authn_id;
+} ValidatorModuleResult;
+</programlisting>
+
+    The connection will only proceed if the module sets
+    <structfield>authorized</structfield> to <literal>true</literal>.  To
+    authenticate the user, the authenticated user name (as determined using the
+    token) shall be palloc'd and returned in the <structfield>authn_id</structfield>
+    field.  Alternatively, <structfield>authn_id</structfield> may be set to
+    NULL if the token is valid but the associated user identity cannot be
+    determined.
+   </para>
+   <para>
+    The caller assumes ownership of the returned memory allocation, the
+    validator module should not in any way access the memory after it has been
+    returned.  A validator may instead return NULL to signal an internal
+    error.
+   </para>
+   <para>
+    The behavior after <function>validate_cb</function> returns depends on the
+    specific HBA setup.  Normally, the <structfield>authn_id</structfield> user
+    name must exactly match the role that the user is logging in as.  (This
+    behavior may be modified with a usermap.)  But when authenticating against
+    an HBA rule with <literal>trust_validator_authz</literal> turned on, the
+    server will not perform any checks on the value of
+    <structfield>authn_id</structfield> at all; in this case it is up to the
+    validator to ensure that the token carries enough privileges for the user to
+    log in under the indicated <replaceable>role</replaceable>.
+   </para>
+  </sect2>
+
+  <sect2 id="oauth-validator-callback-shutdown">
+   <title>Shutdown Callback</title>
+   <para>
+    The <function>shutdown_cb</function> callback is executed when the backend
+    process associated with the connection exits. If the validator module has
+    any state, this callback should free it to avoid resource leaks.
+<programlisting>
+typedef void (*ValidatorShutdownCB) (ValidatorModuleState *state);
+</programlisting>
+   </para>
+  </sect2>
+
+ </sect1>
+</chapter>
diff --git a/doc/src/sgml/postgres.sgml b/doc/src/sgml/postgres.sgml
index 7be25c5850..af476c82fc 100644
--- a/doc/src/sgml/postgres.sgml
+++ b/doc/src/sgml/postgres.sgml
@@ -229,6 +229,7 @@ break is not needed in a wider output rendering.
   &logicaldecoding;
   &replication-origins;
   &archive-modules;
+  &oauth-validators;
 
  </part>
 
diff --git a/doc/src/sgml/protocol.sgml b/doc/src/sgml/protocol.sgml
index fb5dec1172..3bd9e68e6c 100644
--- a/doc/src/sgml/protocol.sgml
+++ b/doc/src/sgml/protocol.sgml
@@ -1688,11 +1688,11 @@ SELCT 1/0;<!-- this typo is intentional -->
 
   <para>
    <firstterm>SASL</firstterm> is a framework for authentication in connection-oriented
-   protocols. At the moment, <productname>PostgreSQL</productname> implements two SASL
-   authentication mechanisms, SCRAM-SHA-256 and SCRAM-SHA-256-PLUS. More
-   might be added in the future. The below steps illustrate how SASL
-   authentication is performed in general, while the next subsection gives
-   more details on SCRAM-SHA-256 and SCRAM-SHA-256-PLUS.
+   protocols. At the moment, <productname>PostgreSQL</productname> implements three
+   SASL authentication mechanisms: SCRAM-SHA-256, SCRAM-SHA-256-PLUS, and
+   OAUTHBEARER. More might be added in the future. The below steps illustrate how SASL
+   authentication is performed in general, while the next subsections give
+   more details on particular mechanisms.
   </para>
 
   <procedure>
@@ -1727,7 +1727,7 @@ SELCT 1/0;<!-- this typo is intentional -->
    <step id="sasl-auth-end">
     <para>
      Finally, when the authentication exchange is completed successfully, the
-     server sends an AuthenticationSASLFinal message, followed
+     server sends an optional AuthenticationSASLFinal message, followed
      immediately by an AuthenticationOk message. The AuthenticationSASLFinal
      contains additional server-to-client data, whose content is particular to the
      selected authentication mechanism. If the authentication mechanism doesn't
@@ -1746,9 +1746,9 @@ SELCT 1/0;<!-- this typo is intentional -->
    <title>SCRAM-SHA-256 Authentication</title>
 
    <para>
-    The implemented SASL mechanisms at the moment
-    are <literal>SCRAM-SHA-256</literal> and its variant with channel
-    binding <literal>SCRAM-SHA-256-PLUS</literal>. They are described in
+    <literal>SCRAM-SHA-256</literal>, and its variant with channel
+    binding <literal>SCRAM-SHA-256-PLUS</literal>, are password-based
+    authentication mechanisms. They are described in
     detail in <ulink url="https://datatracker.ietf.org/doc/html/rfc7677">RFC 7677</ulink>
     and <ulink url="https://datatracker.ietf.org/doc/html/rfc5802">RFC 5802</ulink>.
    </para>
@@ -1850,6 +1850,121 @@ SELCT 1/0;<!-- this typo is intentional -->
     </step>
    </procedure>
   </sect2>
+
+  <sect2 id="sasl-oauthbearer">
+   <title>OAUTHBEARER Authentication</title>
+
+   <para>
+    <literal>OAUTHBEARER</literal> is a token-based mechanism for federated
+    authentication. It is described in detail in
+    <ulink url="https://datatracker.ietf.org/doc/html/rfc7628">RFC 7628</ulink>.
+   </para>
+
+   <para>
+    A typical exchange differs depending on whether or not the client already
+    has a bearer token cached for the current user. If it does not, the exchange
+    will take place over two connections: the first "discovery" connection to
+    obtain OAuth metadata from the server, and the second connection to send
+    the token after the client has obtained it. (libpq does not currently
+    implement a caching method as part of its builtin flow, so it uses the
+    two-connection exchange.)
+   </para>
+
+   <para>
+    This mechanism is client-initiated, like SCRAM. The client initial response
+    consists of the standard "GS2" header used by SCRAM, followed by a list of
+    <literal>key=value</literal> pairs. The only key currently supported by
+    the server is <literal>auth</literal>, which contains the bearer token.
+    <literal>OAUTHBEARER</literal> additionally specifies three optional
+    components of the client initial response (the <literal>authzid</literal> of
+    the GS2 header, and the <structfield>host</structfield> and
+    <structfield>port</structfield> keys) which are currently ignored by the
+    server.
+   </para>
+
+   <para>
+    <literal>OAUTHBEARER</literal> does not support channel binding, and there
+    is no "OAUTHBEARER-PLUS" mechanism. This mechanism does not make use of
+    server data during a successful authentication, so the
+    AuthenticationSASLFinal message is not used in the exchange.
+   </para>
+
+   <procedure>
+    <title>Example</title>
+    <step>
+     <para>
+      During the first exchange, the server sends an AuthenticationSASL message
+      with the <literal>OAUTHBEARER</literal> mechanism advertised.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      The client responds by sending a SASLInitialResponse message which
+      indicates the <literal>OAUTHBEARER</literal> mechanism. Assuming the
+      client does not already have a valid bearer token for the current user,
+      the <structfield>auth</structfield> field is empty, indicating a discovery
+      connection.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      Server sends an AuthenticationSASLContinue message containing an error
+      <literal>status</literal> alongside a well-known URI and scopes that the
+      client should use to conduct an OAuth flow.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      Client sends a SASLResponse message containing the empty set (a single
+      <literal>0x01</literal> byte) to finish its half of the discovery
+      exchange.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      Server sends an ErrorMessage to fail the first exchange.
+     </para>
+     <para>
+      At this point, the client conducts one of many possible OAuth flows to
+      obtain a bearer token, using any metadata that it has been configured with
+      in addition to that provided by the server. (This description is left
+      deliberately vague; <literal>OAUTHBEARER</literal> does not specify or
+      mandate any particular method for obtaining a token.)
+     </para>
+     <para>
+      Once it has a token, the client reconnects to the server for the final
+      exchange:
+     </para>
+    </step>
+
+    <step>
+     <para>
+      The server once again sends an AuthenticationSASL message with the
+      <literal>OAUTHBEARER</literal> mechanism advertised.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      The client responds by sending a SASLInitialResponse message, but this
+      time the <structfield>auth</structfield> field in the message contains the
+      bearer token that was obtained during the client flow.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      The server validates the token according to the instructions of the
+      token provider. If the client is authorized to connect, it sends an
+      AuthenticationOk message to end the SASL exchange.
+     </para>
+    </step>
+   </procedure>
+  </sect2>
  </sect1>
 
  <sect1 id="protocol-replication">
diff --git a/doc/src/sgml/regress.sgml b/doc/src/sgml/regress.sgml
index 7c474559bd..0e5e8e8f30 100644
--- a/doc/src/sgml/regress.sgml
+++ b/doc/src/sgml/regress.sgml
@@ -347,6 +347,16 @@ make check-world PG_TEST_EXTRA='kerberos ldap ssl load_balance libpq_encryption'
       </para>
      </listitem>
     </varlistentry>
+
+    <varlistentry>
+     <term><literal>oauth</literal></term>
+     <listitem>
+      <para>
+       Runs the test suite under <filename>src/test/modules/oauth_validator</filename>.
+       This opens TCP/IP listen sockets for a test-server running HTTPS.
+      </para>
+     </listitem>
+    </varlistentry>
    </variablelist>
 
    Tests for features that are not supported by the current build
diff --git a/meson.build b/meson.build
index 8e128f4982..3b35f1f0c9 100644
--- a/meson.build
+++ b/meson.build
@@ -854,6 +854,67 @@ endif
 
 
 
+###############################################################
+# Library: libcurl
+###############################################################
+
+libcurlopt = get_option('libcurl')
+if not libcurlopt.disabled()
+  # Check for libcurl 7.61.0 or higher (corresponding to RHEL8 and the ability
+  # to explicitly set TLS 1.3 ciphersuites).
+  libcurl = dependency('libcurl', version: '>= 7.61.0', required: libcurlopt)
+  if libcurl.found()
+    cdata.set('USE_LIBCURL', 1)
+
+    # Check to see whether the current platform supports threadsafe Curl
+    # initialization.
+    libcurl_threadsafe_init = false
+
+    if not meson.is_cross_build()
+      r = cc.run('''
+        #include <curl/curl.h>
+
+        int main(void)
+        {
+            curl_version_info_data *info;
+
+            if (curl_global_init(CURL_GLOBAL_ALL))
+                return -1;
+
+            info = curl_version_info(CURLVERSION_NOW);
+        #ifdef CURL_VERSION_THREADSAFE
+            if (info->features & CURL_VERSION_THREADSAFE)
+                return 0;
+        #endif
+
+            return 1;
+        }''',
+        name: 'test for curl_global_init thread safety',
+        dependencies: libcurl,
+      )
+
+      assert(r.compiled())
+      if r.returncode() == 0
+        libcurl_threadsafe_init = true
+        message('curl_global_init is threadsafe')
+      elif r.returncode() == 1
+        message('curl_global_init is not threadsafe')
+      else
+        message('curl_global_init failed; assuming not threadsafe')
+      endif
+    endif
+
+    if libcurl_threadsafe_init
+      cdata.set('HAVE_THREADSAFE_CURL_GLOBAL_INIT', 1)
+    endif
+  endif
+
+else
+  libcurl = not_found_dep
+endif
+
+
+
 ###############################################################
 # Library: libxml
 ###############################################################
@@ -3034,6 +3095,10 @@ libpq_deps += [
 
   gssapi,
   ldap_r,
+  # XXX libcurl must link after libgssapi_krb5 on FreeBSD to avoid segfaults
+  # during gss_acquire_cred(). This is possibly related to Curl's Heimdal
+  # dependency on that platform?
+  libcurl,
   libintl,
   ssl,
 ]
@@ -3704,6 +3769,7 @@ if meson.version().version_compare('>=0.57')
       'gss': gssapi,
       'icu': icu,
       'ldap': ldap,
+      'libcurl': libcurl,
       'libxml': libxml,
       'libxslt': libxslt,
       'llvm': llvm,
diff --git a/meson_options.txt b/meson_options.txt
index d9c7ddccbc..702c451714 100644
--- a/meson_options.txt
+++ b/meson_options.txt
@@ -100,6 +100,9 @@ option('icu', type: 'feature', value: 'auto',
 option('ldap', type: 'feature', value: 'auto',
   description: 'LDAP support')
 
+option('libcurl', type : 'feature', value: 'auto',
+  description: 'libcurl support')
+
 option('libedit_preferred', type: 'boolean', value: false,
   description: 'Prefer BSD Libedit over GNU Readline')
 
diff --git a/src/Makefile.global.in b/src/Makefile.global.in
index bbe11e75bf..3b620bac5a 100644
--- a/src/Makefile.global.in
+++ b/src/Makefile.global.in
@@ -190,6 +190,7 @@ with_systemd	= @with_systemd@
 with_gssapi	= @with_gssapi@
 with_krb_srvnam	= @with_krb_srvnam@
 with_ldap	= @with_ldap@
+with_libcurl	= @with_libcurl@
 with_libxml	= @with_libxml@
 with_libxslt	= @with_libxslt@
 with_llvm	= @with_llvm@
diff --git a/src/backend/libpq/Makefile b/src/backend/libpq/Makefile
index 6d385fd6a4..98eb2a8242 100644
--- a/src/backend/libpq/Makefile
+++ b/src/backend/libpq/Makefile
@@ -15,6 +15,7 @@ include $(top_builddir)/src/Makefile.global
 # be-fsstubs is here for historical reasons, probably belongs elsewhere
 
 OBJS = \
+	auth-oauth.o \
 	auth-sasl.o \
 	auth-scram.o \
 	auth.o \
diff --git a/src/backend/libpq/auth-oauth.c b/src/backend/libpq/auth-oauth.c
new file mode 100644
index 0000000000..6155d63a11
--- /dev/null
+++ b/src/backend/libpq/auth-oauth.c
@@ -0,0 +1,860 @@
+/*-------------------------------------------------------------------------
+ *
+ * auth-oauth.c
+ *	  Server-side implementation of the SASL OAUTHBEARER mechanism.
+ *
+ * See the following RFC for more details:
+ * - RFC 7628: https://tools.ietf.org/html/rfc7628
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/backend/libpq/auth-oauth.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include <unistd.h>
+#include <fcntl.h>
+
+#include "common/oauth-common.h"
+#include "fmgr.h"
+#include "lib/stringinfo.h"
+#include "libpq/auth.h"
+#include "libpq/hba.h"
+#include "libpq/oauth.h"
+#include "libpq/sasl.h"
+#include "storage/fd.h"
+#include "storage/ipc.h"
+#include "utils/json.h"
+#include "utils/varlena.h"
+
+/* GUC */
+char	   *oauth_validator_libraries_string = NULL;
+
+static void oauth_get_mechanisms(Port *port, StringInfo buf);
+static void *oauth_init(Port *port, const char *selected_mech, const char *shadow_pass);
+static int	oauth_exchange(void *opaq, const char *input, int inputlen,
+						   char **output, int *outputlen, const char **logdetail);
+
+static void load_validator_library(const char *libname);
+static void shutdown_validator_library(int code, Datum arg);
+
+static ValidatorModuleState *validator_module_state;
+static const OAuthValidatorCallbacks *ValidatorCallbacks;
+
+/* Mechanism declaration */
+const pg_be_sasl_mech pg_be_oauth_mech = {
+	.get_mechanisms = oauth_get_mechanisms,
+	.init = oauth_init,
+	.exchange = oauth_exchange,
+
+	.max_message_length = PG_MAX_AUTH_TOKEN_LENGTH,
+};
+
+/* Valid states for the oauth_exchange() machine. */
+enum oauth_state
+{
+	OAUTH_STATE_INIT = 0,
+	OAUTH_STATE_ERROR,
+	OAUTH_STATE_FINISHED,
+};
+
+/* Mechanism callback state. */
+struct oauth_ctx
+{
+	enum oauth_state state;
+	Port	   *port;
+	const char *issuer;
+	const char *scope;
+};
+
+static char *sanitize_char(char c);
+static char *parse_kvpairs_for_auth(char **input);
+static void generate_error_response(struct oauth_ctx *ctx, char **output, int *outputlen);
+static bool validate(Port *port, const char *auth);
+
+/* Constants seen in an OAUTHBEARER client initial response. */
+#define KVSEP 0x01				/* separator byte for key/value pairs */
+#define AUTH_KEY "auth"			/* key containing the Authorization header */
+#define BEARER_SCHEME "Bearer " /* required header scheme (case-insensitive!) */
+
+/*
+ * Retrieves the OAUTHBEARER mechanism list (currently a single item).
+ *
+ * For a full description of the API, see libpq/sasl.h.
+ */
+static void
+oauth_get_mechanisms(Port *port, StringInfo buf)
+{
+	/* Only OAUTHBEARER is supported. */
+	appendStringInfoString(buf, OAUTHBEARER_NAME);
+	appendStringInfoChar(buf, '\0');
+}
+
+/*
+ * Initializes mechanism state and loads the configured validator module.
+ *
+ * For a full description of the API, see libpq/sasl.h.
+ */
+static void *
+oauth_init(Port *port, const char *selected_mech, const char *shadow_pass)
+{
+	struct oauth_ctx *ctx;
+
+	if (strcmp(selected_mech, OAUTHBEARER_NAME) != 0)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("client selected an invalid SASL authentication mechanism"));
+
+	ctx = palloc0(sizeof(*ctx));
+
+	ctx->state = OAUTH_STATE_INIT;
+	ctx->port = port;
+
+	Assert(port->hba);
+	ctx->issuer = port->hba->oauth_issuer;
+	ctx->scope = port->hba->oauth_scope;
+
+	load_validator_library(port->hba->oauth_validator);
+
+	return ctx;
+}
+
+/*
+ * Implements the OAUTHBEARER SASL exchange (RFC 7628, Sec. 3.2). This pulls
+ * apart the client initial response and validates the Bearer token. It also
+ * handles the dummy error response for a failed handshake, as described in
+ * Sec. 3.2.3.
+ *
+ * For a full description of the API, see libpq/sasl.h.
+ */
+static int
+oauth_exchange(void *opaq, const char *input, int inputlen,
+			   char **output, int *outputlen, const char **logdetail)
+{
+	char	   *input_copy;
+	char	   *p;
+	char		cbind_flag;
+	char	   *auth;
+	int			status;
+
+	struct oauth_ctx *ctx = opaq;
+
+	*output = NULL;
+	*outputlen = -1;
+
+	/*
+	 * If the client didn't include an "Initial Client Response" in the
+	 * SASLInitialResponse message, send an empty challenge, to which the
+	 * client will respond with the same data that usually comes in the
+	 * Initial Client Response.
+	 */
+	if (input == NULL)
+	{
+		Assert(ctx->state == OAUTH_STATE_INIT);
+
+		*output = pstrdup("");
+		*outputlen = 0;
+		return PG_SASL_EXCHANGE_CONTINUE;
+	}
+
+	/*
+	 * Check that the input length agrees with the string length of the input.
+	 */
+	if (inputlen == 0)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("The message is empty."));
+	if (inputlen != strlen(input))
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message length does not match input length."));
+
+	switch (ctx->state)
+	{
+		case OAUTH_STATE_INIT:
+			/* Handle this case below. */
+			break;
+
+		case OAUTH_STATE_ERROR:
+
+			/*
+			 * Only one response is valid for the client during authentication
+			 * failure: a single kvsep.
+			 */
+			if (inputlen != 1 || *input != KVSEP)
+				ereport(ERROR,
+						errcode(ERRCODE_PROTOCOL_VIOLATION),
+						errmsg("malformed OAUTHBEARER message"),
+						errdetail("Client did not send a kvsep response."));
+
+			/* The (failed) handshake is now complete. */
+			ctx->state = OAUTH_STATE_FINISHED;
+			return PG_SASL_EXCHANGE_FAILURE;
+
+		default:
+			elog(ERROR, "invalid OAUTHBEARER exchange state");
+			return PG_SASL_EXCHANGE_FAILURE;
+	}
+
+	/* Handle the client's initial message. */
+	p = input_copy = pstrdup(input);
+
+	/*
+	 * OAUTHBEARER does not currently define a channel binding (so there is no
+	 * OAUTHBEARER-PLUS, and we do not accept a 'p' specifier). We accept a
+	 * 'y' specifier purely for the remote chance that a future specification
+	 * could define one; then future clients can still interoperate with this
+	 * server implementation. 'n' is the expected case.
+	 */
+	cbind_flag = *p;
+	switch (cbind_flag)
+	{
+		case 'p':
+			ereport(ERROR,
+					errcode(ERRCODE_PROTOCOL_VIOLATION),
+					errmsg("malformed OAUTHBEARER message"),
+					errdetail("The server does not support channel binding for OAuth, but the client message includes channel binding data."));
+			break;
+
+		case 'y':				/* fall through */
+		case 'n':
+			p++;
+			if (*p != ',')
+				ereport(ERROR,
+						errcode(ERRCODE_PROTOCOL_VIOLATION),
+						errmsg("malformed OAUTHBEARER message"),
+						errdetail("Comma expected, but found character \"%s\".",
+								  sanitize_char(*p)));
+			p++;
+			break;
+
+		default:
+			ereport(ERROR,
+					errcode(ERRCODE_PROTOCOL_VIOLATION),
+					errmsg("malformed OAUTHBEARER message"),
+					errdetail("Unexpected channel-binding flag \"%s\".",
+							  sanitize_char(cbind_flag)));
+	}
+
+	/*
+	 * Forbid optional authzid (authorization identity).  We don't support it.
+	 */
+	if (*p == 'a')
+		ereport(ERROR,
+				errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				errmsg("client uses authorization identity, but it is not supported"));
+	if (*p != ',')
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Unexpected attribute \"%s\" in client-first-message.",
+						  sanitize_char(*p)));
+	p++;
+
+	/* All remaining fields are separated by the RFC's kvsep (\x01). */
+	if (*p != KVSEP)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Key-value separator expected, but found character \"%s\".",
+						  sanitize_char(*p)));
+	p++;
+
+	auth = parse_kvpairs_for_auth(&p);
+	if (!auth)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message does not contain an auth value."));
+
+	/* We should be at the end of our message. */
+	if (*p)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message contains additional data after the final terminator."));
+
+	if (!validate(ctx->port, auth))
+	{
+		generate_error_response(ctx, output, outputlen);
+
+		ctx->state = OAUTH_STATE_ERROR;
+		status = PG_SASL_EXCHANGE_CONTINUE;
+	}
+	else
+	{
+		ctx->state = OAUTH_STATE_FINISHED;
+		status = PG_SASL_EXCHANGE_SUCCESS;
+	}
+
+	/* Don't let extra copies of the bearer token hang around. */
+	explicit_bzero(input_copy, inputlen);
+
+	return status;
+}
+
+/*
+ * Convert an arbitrary byte to printable form.  For error messages.
+ *
+ * If it's a printable ASCII character, print it as a single character.
+ * otherwise, print it in hex.
+ *
+ * The returned pointer points to a static buffer.
+ */
+static char *
+sanitize_char(char c)
+{
+	static char buf[5];
+
+	if (c >= 0x21 && c <= 0x7E)
+		snprintf(buf, sizeof(buf), "'%c'", c);
+	else
+		snprintf(buf, sizeof(buf), "0x%02x", (unsigned char) c);
+	return buf;
+}
+
+/*
+ * Performs syntactic validation of a key and value from the initial client
+ * response. (Semantic validation of interesting values must be performed
+ * later.)
+ */
+static void
+validate_kvpair(const char *key, const char *val)
+{
+	/*-----
+	 * From Sec 3.1:
+	 *     key            = 1*(ALPHA)
+	 */
+	static const char *key_allowed_set =
+		"abcdefghijklmnopqrstuvwxyz"
+		"ABCDEFGHIJKLMNOPQRSTUVWXYZ";
+
+	size_t		span;
+
+	if (!key[0])
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message contains an empty key name."));
+
+	span = strspn(key, key_allowed_set);
+	if (key[span] != '\0')
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message contains an invalid key name."));
+
+	/*-----
+	 * From Sec 3.1:
+	 *     value          = *(VCHAR / SP / HTAB / CR / LF )
+	 *
+	 * The VCHAR (visible character) class is large; a loop is more
+	 * straightforward than strspn().
+	 */
+	for (; *val; ++val)
+	{
+		if (0x21 <= *val && *val <= 0x7E)
+			continue;			/* VCHAR */
+
+		switch (*val)
+		{
+			case ' ':
+			case '\t':
+			case '\r':
+			case '\n':
+				continue;		/* SP, HTAB, CR, LF */
+
+			default:
+				ereport(ERROR,
+						errcode(ERRCODE_PROTOCOL_VIOLATION),
+						errmsg("malformed OAUTHBEARER message"),
+						errdetail("Message contains an invalid value."));
+		}
+	}
+}
+
+/*
+ * Consumes all kvpairs in an OAUTHBEARER exchange message. If the "auth" key is
+ * found, its value is returned.
+ */
+static char *
+parse_kvpairs_for_auth(char **input)
+{
+	char	   *pos = *input;
+	char	   *auth = NULL;
+
+	/*----
+	 * The relevant ABNF, from Sec. 3.1:
+	 *
+	 *     kvsep          = %x01
+	 *     key            = 1*(ALPHA)
+	 *     value          = *(VCHAR / SP / HTAB / CR / LF )
+	 *     kvpair         = key "=" value kvsep
+	 *   ;;gs2-header     = See RFC 5801
+	 *     client-resp    = (gs2-header kvsep *kvpair kvsep) / kvsep
+	 *
+	 * By the time we reach this code, the gs2-header and initial kvsep have
+	 * already been validated. We start at the beginning of the first kvpair.
+	 */
+
+	while (*pos)
+	{
+		char	   *end;
+		char	   *sep;
+		char	   *key;
+		char	   *value;
+
+		/*
+		 * Find the end of this kvpair. Note that input is null-terminated by
+		 * the SASL code, so the strchr() is bounded.
+		 */
+		end = strchr(pos, KVSEP);
+		if (!end)
+			ereport(ERROR,
+					errcode(ERRCODE_PROTOCOL_VIOLATION),
+					errmsg("malformed OAUTHBEARER message"),
+					errdetail("Message contains an unterminated key/value pair."));
+		*end = '\0';
+
+		if (pos == end)
+		{
+			/* Empty kvpair, signifying the end of the list. */
+			*input = pos + 1;
+			return auth;
+		}
+
+		/*
+		 * Find the end of the key name.
+		 */
+		sep = strchr(pos, '=');
+		if (!sep)
+			ereport(ERROR,
+					errcode(ERRCODE_PROTOCOL_VIOLATION),
+					errmsg("malformed OAUTHBEARER message"),
+					errdetail("Message contains a key without a value."));
+		*sep = '\0';
+
+		/* Both key and value are now safely terminated. */
+		key = pos;
+		value = sep + 1;
+		validate_kvpair(key, value);
+
+		if (strcmp(key, AUTH_KEY) == 0)
+		{
+			if (auth)
+				ereport(ERROR,
+						errcode(ERRCODE_PROTOCOL_VIOLATION),
+						errmsg("malformed OAUTHBEARER message"),
+						errdetail("Message contains multiple auth values."));
+
+			auth = value;
+		}
+		else
+		{
+			/*
+			 * The RFC also defines the host and port keys, but they are not
+			 * required for OAUTHBEARER and we do not use them. Also, per Sec.
+			 * 3.1, any key/value pairs we don't recognize must be ignored.
+			 */
+		}
+
+		/* Move to the next pair. */
+		pos = end + 1;
+	}
+
+	ereport(ERROR,
+			errcode(ERRCODE_PROTOCOL_VIOLATION),
+			errmsg("malformed OAUTHBEARER message"),
+			errdetail("Message did not contain a final terminator."));
+
+	pg_unreachable();
+	return NULL;
+}
+
+/*
+ * Builds the JSON response for failed authentication (RFC 7628, Sec. 3.2.2).
+ * This contains the required scopes for entry and a pointer to the OAuth/OpenID
+ * discovery document, which the client may use to conduct its OAuth flow.
+ */
+static void
+generate_error_response(struct oauth_ctx *ctx, char **output, int *outputlen)
+{
+	StringInfoData buf;
+	StringInfoData issuer;
+
+	/*
+	 * The admin needs to set an issuer and scope for OAuth to work. There's
+	 * not really a way to hide this from the user, either, because we can't
+	 * choose a "default" issuer, so be honest in the failure message.
+	 *
+	 * TODO: see if there's a better place to fail, earlier than this.
+	 */
+	if (!ctx->issuer || !ctx->scope)
+		ereport(FATAL,
+				errcode(ERRCODE_INTERNAL_ERROR),
+				errmsg("OAuth is not properly configured for this user"),
+				errdetail_log("The issuer and scope parameters must be set in pg_hba.conf."));
+
+	/*
+	 * Build a default .well-known URI based on our issuer, unless the HBA has
+	 * already provided one.
+	 */
+	initStringInfo(&issuer);
+	appendStringInfoString(&issuer, ctx->issuer);
+	if (strstr(ctx->issuer, "/.well-known/") == NULL)
+		appendStringInfoString(&issuer, "/.well-known/openid-configuration");
+
+	initStringInfo(&buf);
+
+	/*
+	 * Escaping the string here is belt-and-suspenders defensive programming
+	 * since escapable characters aren't valid in either the issuer URI or the
+	 * scope list, but the HBA doesn't enforce that yet.
+	 */
+	appendStringInfoString(&buf, "{ \"status\": \"invalid_token\", ");
+
+	appendStringInfoString(&buf, "\"openid-configuration\": ");
+	escape_json(&buf, issuer.data);
+	pfree(issuer.data);
+
+	appendStringInfoString(&buf, ", \"scope\": ");
+	escape_json(&buf, ctx->scope);
+
+	appendStringInfoString(&buf, " }");
+
+	*output = buf.data;
+	*outputlen = buf.len;
+}
+
+/*-----
+ * Validates the provided Authorization header and returns the token from
+ * within it. NULL is returned on validation failure.
+ *
+ * Only Bearer tokens are accepted. The ABNF is defined in RFC 6750, Sec.
+ * 2.1:
+ *
+ *      b64token    = 1*( ALPHA / DIGIT /
+ *                        "-" / "." / "_" / "~" / "+" / "/" ) *"="
+ *      credentials = "Bearer" 1*SP b64token
+ *
+ * The "credentials" construction is what we receive in our auth value.
+ *
+ * Since that spec is subordinate to HTTP (i.e. the HTTP Authorization
+ * header format; RFC 9110 Sec. 11), the "Bearer" scheme string must be
+ * compared case-insensitively. (This is not mentioned in RFC 6750, but the
+ * OAUTHBEARER spec points it out: RFC 7628 Sec. 4.)
+ *
+ * Invalid formats are technically a protocol violation, but we shouldn't
+ * reflect any information about the sensitive Bearer token back to the
+ * client; log at COMMERROR instead.
+ */
+static const char *
+validate_token_format(const char *header)
+{
+	size_t		span;
+	const char *token;
+	static const char *const b64token_allowed_set =
+		"abcdefghijklmnopqrstuvwxyz"
+		"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+		"0123456789-._~+/";
+
+	/* Missing auth headers should be handled by the caller. */
+	Assert(header);
+
+	if (header[0] == '\0')
+	{
+		/*
+		 * A completely empty auth header represents a query for
+		 * authentication parameters. The client expects it to fail; there's
+		 * no need to make any extra noise in the logs.
+		 *
+		 * TODO: should we find a way to return STATUS_EOF at the top level,
+		 * to suppress the authentication error entirely?
+		 */
+		return NULL;
+	}
+
+	if (pg_strncasecmp(header, BEARER_SCHEME, strlen(BEARER_SCHEME)))
+	{
+		ereport(COMMERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAuth bearer token"),
+				errdetail_log("Client response indicated a non-Bearer authentication scheme."));
+		return NULL;
+	}
+
+	/* Pull the bearer token out of the auth value. */
+	token = header + strlen(BEARER_SCHEME);
+
+	/* Swallow any additional spaces. */
+	while (*token == ' ')
+		token++;
+
+	/* Tokens must not be empty. */
+	if (!*token)
+	{
+		ereport(COMMERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAuth bearer token"),
+				errdetail_log("Bearer token is empty."));
+		return NULL;
+	}
+
+	/*
+	 * Make sure the token contains only allowed characters. Tokens may end
+	 * with any number of '=' characters.
+	 */
+	span = strspn(token, b64token_allowed_set);
+	while (token[span] == '=')
+		span++;
+
+	if (token[span] != '\0')
+	{
+		/*
+		 * This error message could be more helpful by printing the
+		 * problematic character(s), but that'd be a bit like printing a piece
+		 * of someone's password into the logs.
+		 */
+		ereport(COMMERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAuth bearer token"),
+				errdetail_log("Bearer token is not in the correct format."));
+		return NULL;
+	}
+
+	return token;
+}
+
+/*
+ * Checks that the "auth" kvpair in the client response contains a syntactically
+ * valid Bearer token, then passes it along to the loaded validator module for
+ * authorization. Returns true if validation succeeds.
+ */
+static bool
+validate(Port *port, const char *auth)
+{
+	int			map_status;
+	ValidatorModuleResult *ret;
+	const char *token;
+	bool		status;
+
+	/* Ensure that we have a correct token to validate */
+	if (!(token = validate_token_format(auth)))
+		return false;
+
+	/*
+	 * Ensure that we have a validation library loaded, this should always be
+	 * the case and an error here is indicative of a bug.
+	 */
+	if (!ValidatorCallbacks || !ValidatorCallbacks->validate_cb)
+		ereport(FATAL,
+				errcode(ERRCODE_INTERNAL_ERROR),
+				errmsg("validation of OAuth token requested without a validator loaded"));
+
+	/* Call the validation function from the validator module */
+	ret = ValidatorCallbacks->validate_cb(validator_module_state,
+										  token, port->user_name);
+	if (ret == NULL)
+	{
+		ereport(LOG, errmsg("internal error in OAuth validator module"));
+		return false;
+	}
+
+	/*
+	 * Log any authentication results even if the token isn't authorized; it
+	 * might be useful for auditing or troubleshooting.
+	 */
+	if (ret->authn_id)
+		set_authn_id(port, ret->authn_id);
+
+	if (!ret->authorized)
+	{
+		ereport(LOG,
+				errmsg("OAuth bearer authentication failed for user \"%s\"",
+					   port->user_name),
+				errdetail_log("Validator failed to authorize the provided token."));
+
+		status = false;
+		goto cleanup;
+	}
+
+	if (port->hba->oauth_skip_usermap)
+	{
+		/*
+		 * If the validator is our authorization authority, we're done.
+		 * Authentication may or may not have been performed depending on the
+		 * validator implementation; all that matters is that the validator
+		 * says the user can log in with the target role.
+		 */
+		status = true;
+		goto cleanup;
+	}
+
+	/* Make sure the validator authenticated the user. */
+	if (ret->authn_id == NULL || ret->authn_id[0] == '\0')
+	{
+		ereport(LOG,
+				errmsg("OAuth bearer authentication failed for user \"%s\"",
+					   port->user_name),
+				errdetail_log("Validator provided no identity."));
+
+		status = false;
+		goto cleanup;
+	}
+
+	/* Finally, check the user map. */
+	map_status = check_usermap(port->hba->usermap, port->user_name,
+							   MyClientConnectionInfo.authn_id, false);
+	status = (map_status == STATUS_OK);
+
+cleanup:
+
+	/*
+	 * Clear and free the validation result from the validator module once
+	 * we're done with it.
+	 */
+	if (ret->authn_id != NULL)
+		pfree(ret->authn_id);
+	pfree(ret);
+
+	return status;
+}
+
+/*
+ * load_validator_library
+ *
+ * Load the configured validator library in order to perform token validation.
+ * There is no built-in fallback since validation is implementation specific. If
+ * no validator library is configured, or if it fails to load, then error out
+ * since token validation won't be possible.
+ */
+static void
+load_validator_library(const char *libname)
+{
+	OAuthValidatorModuleInit validator_init;
+
+	Assert(libname && *libname);
+
+	validator_init = (OAuthValidatorModuleInit)
+		load_external_function(libname, "_PG_oauth_validator_module_init",
+							   false, NULL);
+
+	/*
+	 * The validator init function is required since it will set the callbacks
+	 * for the validator library.
+	 */
+	if (validator_init == NULL)
+		ereport(ERROR,
+				errmsg("%s module \"%s\" must define the symbol %s",
+					   "OAuth validator", libname, "_PG_oauth_validator_module_init"));
+
+	ValidatorCallbacks = (*validator_init) ();
+	Assert(ValidatorCallbacks);
+
+	/* Allocate memory for validator library private state data */
+	validator_module_state = (ValidatorModuleState *) palloc0(sizeof(ValidatorModuleState));
+	if (ValidatorCallbacks->startup_cb != NULL)
+		ValidatorCallbacks->startup_cb(validator_module_state);
+
+	before_shmem_exit(shutdown_validator_library, 0);
+}
+
+/*
+ * Call the validator module's shutdown callback, if one is provided. This is
+ * invoked via before_shmem_exit().
+ */
+static void
+shutdown_validator_library(int code, Datum arg)
+{
+	if (ValidatorCallbacks->shutdown_cb != NULL)
+		ValidatorCallbacks->shutdown_cb(validator_module_state);
+}
+
+/*
+ * Ensure an OAuth validator named in the HBA is permitted by the configuration.
+ *
+ * If the validator is currently unset and exactly one library is declared in
+ * oauth_validator_libraries, then that library will be used as the validator.
+ * Otherwise the name must be present in the list of oauth_validator_libraries.
+ */
+bool
+check_oauth_validator(HbaLine *hbaline, int elevel, char **err_msg)
+{
+	int			line_num = hbaline->linenumber;
+	char	   *file_name = hbaline->sourcefile;
+	char	   *rawstring;
+	List	   *elemlist = NIL;
+
+	*err_msg = NULL;
+
+	if (oauth_validator_libraries_string[0] == '\0')
+	{
+		ereport(elevel,
+				errcode(ERRCODE_CONFIG_FILE_ERROR),
+				errmsg("oauth_validator_libraries must be set for authentication method %s",
+					   "oauth"),
+				errcontext("line %d of configuration file \"%s\"",
+						   line_num, file_name));
+		*err_msg = psprintf("oauth_validator_libraries must be set for authentication method %s",
+							"oauth");
+		return false;
+	}
+
+	/* SplitDirectoriesString needs a modifiable copy */
+	rawstring = pstrdup(oauth_validator_libraries_string);
+
+	if (!SplitDirectoriesString(rawstring, ',', &elemlist))
+	{
+		/* syntax error in list */
+		ereport(elevel,
+				errcode(ERRCODE_CONFIG_FILE_ERROR),
+				errmsg("invalid list syntax in parameter \"%s\"",
+					   "oauth_validator_libraries"));
+		*err_msg = psprintf("invalid list syntax in parameter \"%s\"",
+							"oauth_validator_libraries");
+		goto done;
+	}
+
+	if (!hbaline->oauth_validator)
+	{
+		if (elemlist->length == 1)
+		{
+			hbaline->oauth_validator = pstrdup(linitial(elemlist));
+			goto done;
+		}
+
+		ereport(elevel,
+				errcode(ERRCODE_CONFIG_FILE_ERROR),
+				errmsg("authentication method \"oauth\" requires argument \"validator\" to be set when oauth_validator_libraries contains multiple options"),
+				errcontext("line %d of configuration file \"%s\"",
+						   line_num, file_name));
+		*err_msg = "authentication method \"oauth\" requires argument \"validator\" to be set when oauth_validator_libraries contains multiple options";
+		goto done;
+	}
+
+	foreach_ptr(char, allowed, elemlist)
+	{
+		if (strcmp(allowed, hbaline->oauth_validator) == 0)
+			goto done;
+	}
+
+	ereport(elevel,
+			errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+			errmsg("validator \"%s\" is not permitted by %s",
+				   hbaline->oauth_validator, "oauth_validator_libraries"),
+			errcontext("line %d of configuration file \"%s\"",
+					   line_num, file_name));
+	*err_msg = psprintf("validator \"%s\" is not permitted by %s",
+						hbaline->oauth_validator, "oauth_validator_libraries");
+
+done:
+	list_free_deep(elemlist);
+	pfree(rawstring);
+
+	return (*err_msg == NULL);
+}
diff --git a/src/backend/libpq/auth.c b/src/backend/libpq/auth.c
index d6ef32cc82..0f65014e64 100644
--- a/src/backend/libpq/auth.c
+++ b/src/backend/libpq/auth.c
@@ -29,6 +29,7 @@
 #include "libpq/auth.h"
 #include "libpq/crypt.h"
 #include "libpq/libpq.h"
+#include "libpq/oauth.h"
 #include "libpq/pqformat.h"
 #include "libpq/sasl.h"
 #include "libpq/scram.h"
@@ -45,7 +46,6 @@
  */
 static void auth_failed(Port *port, int status, const char *logdetail);
 static char *recv_password_packet(Port *port);
-static void set_authn_id(Port *port, const char *id);
 
 
 /*----------------------------------------------------------------
@@ -289,6 +289,9 @@ auth_failed(Port *port, int status, const char *logdetail)
 		case uaRADIUS:
 			errstr = gettext_noop("RADIUS authentication failed for user \"%s\"");
 			break;
+		case uaOAuth:
+			errstr = gettext_noop("OAuth bearer authentication failed for user \"%s\"");
+			break;
 		default:
 			errstr = gettext_noop("authentication failed for user \"%s\": invalid authentication method");
 			break;
@@ -324,7 +327,7 @@ auth_failed(Port *port, int status, const char *logdetail)
  * lifetime of MyClientConnectionInfo, so it is safe to pass a string that is
  * managed by an external library.
  */
-static void
+void
 set_authn_id(Port *port, const char *id)
 {
 	Assert(id);
@@ -611,6 +614,9 @@ ClientAuthentication(Port *port)
 		case uaTrust:
 			status = STATUS_OK;
 			break;
+		case uaOAuth:
+			status = CheckSASLAuth(&pg_be_oauth_mech, port, NULL, NULL);
+			break;
 	}
 
 	if ((status == STATUS_OK && port->hba->clientcert == clientCertFull)
diff --git a/src/backend/libpq/hba.c b/src/backend/libpq/hba.c
index 510c9ffc6d..332fad2783 100644
--- a/src/backend/libpq/hba.c
+++ b/src/backend/libpq/hba.c
@@ -32,6 +32,7 @@
 #include "libpq/hba.h"
 #include "libpq/ifaddr.h"
 #include "libpq/libpq-be.h"
+#include "libpq/oauth.h"
 #include "postmaster/postmaster.h"
 #include "regex/regex.h"
 #include "replication/walsender.h"
@@ -114,7 +115,8 @@ static const char *const UserAuthName[] =
 	"ldap",
 	"cert",
 	"radius",
-	"peer"
+	"peer",
+	"oauth",
 };
 
 /*
@@ -1747,6 +1749,8 @@ parse_hba_line(TokenizedAuthLine *tok_line, int elevel)
 #endif
 	else if (strcmp(token->string, "radius") == 0)
 		parsedline->auth_method = uaRADIUS;
+	else if (strcmp(token->string, "oauth") == 0)
+		parsedline->auth_method = uaOAuth;
 	else
 	{
 		ereport(elevel,
@@ -2039,6 +2043,36 @@ parse_hba_line(TokenizedAuthLine *tok_line, int elevel)
 		parsedline->clientcert = clientCertFull;
 	}
 
+	/*
+	 * Enforce proper configuration of OAuth authentication.
+	 */
+	if (parsedline->auth_method == uaOAuth)
+	{
+		MANDATORY_AUTH_ARG(parsedline->oauth_scope, "scope", "oauth");
+		MANDATORY_AUTH_ARG(parsedline->oauth_issuer, "issuer", "oauth");
+
+		/* Ensure a validator library is set and permitted by the config. */
+		if (!check_oauth_validator(parsedline, elevel, err_msg))
+			return NULL;
+
+		/*
+		 * Supplying a usermap combined with the option to skip usermapping is
+		 * nonsensical and indicates a configuration error.
+		 */
+		if (parsedline->oauth_skip_usermap && parsedline->usermap != NULL)
+		{
+			ereport(elevel,
+					errcode(ERRCODE_CONFIG_FILE_ERROR),
+			/* translator: strings are replaced with hba options */
+					errmsg("%s cannot be used in combination with %s",
+						   "map", "delegate_ident_mapping"),
+					errcontext("line %d of configuration file \"%s\"",
+							   line_num, file_name));
+			*err_msg = "map cannot be used in combination with delegate_ident_mapping";
+			return NULL;
+		}
+	}
+
 	return parsedline;
 }
 
@@ -2066,8 +2100,9 @@ parse_hba_auth_opt(char *name, char *val, HbaLine *hbaline,
 			hbaline->auth_method != uaPeer &&
 			hbaline->auth_method != uaGSS &&
 			hbaline->auth_method != uaSSPI &&
-			hbaline->auth_method != uaCert)
-			INVALID_AUTH_OPTION("map", gettext_noop("ident, peer, gssapi, sspi, and cert"));
+			hbaline->auth_method != uaCert &&
+			hbaline->auth_method != uaOAuth)
+			INVALID_AUTH_OPTION("map", gettext_noop("ident, peer, gssapi, sspi, cert, and oauth"));
 		hbaline->usermap = pstrdup(val);
 	}
 	else if (strcmp(name, "clientcert") == 0)
@@ -2450,6 +2485,29 @@ parse_hba_auth_opt(char *name, char *val, HbaLine *hbaline,
 		hbaline->radiusidentifiers = parsed_identifiers;
 		hbaline->radiusidentifiers_s = pstrdup(val);
 	}
+	else if (strcmp(name, "issuer") == 0)
+	{
+		REQUIRE_AUTH_OPTION(uaOAuth, "issuer", "oauth");
+		hbaline->oauth_issuer = pstrdup(val);
+	}
+	else if (strcmp(name, "scope") == 0)
+	{
+		REQUIRE_AUTH_OPTION(uaOAuth, "scope", "oauth");
+		hbaline->oauth_scope = pstrdup(val);
+	}
+	else if (strcmp(name, "validator") == 0)
+	{
+		REQUIRE_AUTH_OPTION(uaOAuth, "validator", "oauth");
+		hbaline->oauth_validator = pstrdup(val);
+	}
+	else if (strcmp(name, "delegate_ident_mapping") == 0)
+	{
+		REQUIRE_AUTH_OPTION(uaOAuth, "delegate_ident_mapping", "oauth");
+		if (strcmp(val, "1") == 0)
+			hbaline->oauth_skip_usermap = true;
+		else
+			hbaline->oauth_skip_usermap = false;
+	}
 	else
 	{
 		ereport(elevel,
diff --git a/src/backend/libpq/meson.build b/src/backend/libpq/meson.build
index 0f0421037e..31aa2faae1 100644
--- a/src/backend/libpq/meson.build
+++ b/src/backend/libpq/meson.build
@@ -1,6 +1,7 @@
 # Copyright (c) 2022-2025, PostgreSQL Global Development Group
 
 backend_sources += files(
+  'auth-oauth.c',
   'auth-sasl.c',
   'auth-scram.c',
   'auth.c',
diff --git a/src/backend/libpq/pg_hba.conf.sample b/src/backend/libpq/pg_hba.conf.sample
index bad13497a3..b64c8dea97 100644
--- a/src/backend/libpq/pg_hba.conf.sample
+++ b/src/backend/libpq/pg_hba.conf.sample
@@ -53,8 +53,8 @@
 # directly connected to.
 #
 # METHOD can be "trust", "reject", "md5", "password", "scram-sha-256",
-# "gss", "sspi", "ident", "peer", "pam", "ldap", "radius" or "cert".
-# Note that "password" sends passwords in clear text; "md5" or
+# "gss", "sspi", "ident", "peer", "pam", "oauth", "ldap", "radius" or
+# "cert".  Note that "password" sends passwords in clear text; "md5" or
 # "scram-sha-256" are preferred since they send encrypted passwords.
 #
 # OPTIONS are a set of options for the authentication in the format
diff --git a/src/backend/utils/adt/hbafuncs.c b/src/backend/utils/adt/hbafuncs.c
index 03c38e8c45..b62c3d944c 100644
--- a/src/backend/utils/adt/hbafuncs.c
+++ b/src/backend/utils/adt/hbafuncs.c
@@ -152,6 +152,25 @@ get_hba_options(HbaLine *hba)
 				CStringGetTextDatum(psprintf("radiusports=%s", hba->radiusports_s));
 	}
 
+	if (hba->auth_method == uaOAuth)
+	{
+		if (hba->oauth_issuer)
+			options[noptions++] =
+				CStringGetTextDatum(psprintf("issuer=%s", hba->oauth_issuer));
+
+		if (hba->oauth_scope)
+			options[noptions++] =
+				CStringGetTextDatum(psprintf("scope=%s", hba->oauth_scope));
+
+		if (hba->oauth_validator)
+			options[noptions++] =
+				CStringGetTextDatum(psprintf("validator=%s", hba->oauth_validator));
+
+		if (hba->oauth_skip_usermap)
+			options[noptions++] =
+				CStringGetTextDatum(psprintf("delegate_ident_mapping=true"));
+	}
+
 	/* If you add more options, consider increasing MAX_HBA_OPTIONS. */
 	Assert(noptions <= MAX_HBA_OPTIONS);
 
diff --git a/src/backend/utils/misc/guc_tables.c b/src/backend/utils/misc/guc_tables.c
index 38cb9e970d..db582d2d62 100644
--- a/src/backend/utils/misc/guc_tables.c
+++ b/src/backend/utils/misc/guc_tables.c
@@ -49,6 +49,7 @@
 #include "jit/jit.h"
 #include "libpq/auth.h"
 #include "libpq/libpq.h"
+#include "libpq/oauth.h"
 #include "libpq/scram.h"
 #include "nodes/queryjumble.h"
 #include "optimizer/cost.h"
@@ -4823,6 +4824,17 @@ struct config_string ConfigureNamesString[] =
 		check_restrict_nonsystem_relation_kind, assign_restrict_nonsystem_relation_kind, NULL
 	},
 
+	{
+		{"oauth_validator_libraries", PGC_SIGHUP, CONN_AUTH_AUTH,
+			gettext_noop("Lists libraries that may be called to validate OAuth v2 bearer tokens."),
+			NULL,
+			GUC_LIST_INPUT | GUC_LIST_QUOTE | GUC_SUPERUSER_ONLY
+		},
+		&oauth_validator_libraries_string,
+		"",
+		NULL, NULL, NULL
+	},
+
 	/* End-of-list marker */
 	{
 		{NULL, 0, 0, NULL, NULL}, NULL, NULL, NULL, NULL, NULL
diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample
index 079efa1baa..678de38a1c 100644
--- a/src/backend/utils/misc/postgresql.conf.sample
+++ b/src/backend/utils/misc/postgresql.conf.sample
@@ -121,6 +121,9 @@
 #ssl_passphrase_command = ''
 #ssl_passphrase_command_supports_reload = off
 
+# OAuth
+#oauth_validator_libraries = ''	# comma-separated list of trusted validator modules
+
 
 #------------------------------------------------------------------------------
 # RESOURCE USAGE (except WAL)
diff --git a/src/include/common/oauth-common.h b/src/include/common/oauth-common.h
new file mode 100644
index 0000000000..8fe5626778
--- /dev/null
+++ b/src/include/common/oauth-common.h
@@ -0,0 +1,19 @@
+/*-------------------------------------------------------------------------
+ *
+ * oauth-common.h
+ *		Declarations for helper functions used for OAuth/OIDC authentication
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/include/common/oauth-common.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef OAUTH_COMMON_H
+#define OAUTH_COMMON_H
+
+/* Name of SASL mechanism per IANA */
+#define OAUTHBEARER_NAME "OAUTHBEARER"
+
+#endif							/* OAUTH_COMMON_H */
diff --git a/src/include/libpq/auth.h b/src/include/libpq/auth.h
index 902c5f6de3..25b5742068 100644
--- a/src/include/libpq/auth.h
+++ b/src/include/libpq/auth.h
@@ -39,6 +39,7 @@ extern PGDLLIMPORT bool pg_gss_accept_delegation;
 extern void ClientAuthentication(Port *port);
 extern void sendAuthRequest(Port *port, AuthRequest areq, const char *extradata,
 							int extralen);
+extern void set_authn_id(Port *port, const char *id);
 
 /* Hook for plugins to get control in ClientAuthentication() */
 typedef void (*ClientAuthentication_hook_type) (Port *, int);
diff --git a/src/include/libpq/hba.h b/src/include/libpq/hba.h
index b20d0051f7..3657f182db 100644
--- a/src/include/libpq/hba.h
+++ b/src/include/libpq/hba.h
@@ -39,7 +39,8 @@ typedef enum UserAuth
 	uaCert,
 	uaRADIUS,
 	uaPeer,
-#define USER_AUTH_LAST uaPeer	/* Must be last value of this enum */
+	uaOAuth,
+#define USER_AUTH_LAST uaOAuth	/* Must be last value of this enum */
 } UserAuth;
 
 /*
@@ -135,6 +136,10 @@ typedef struct HbaLine
 	char	   *radiusidentifiers_s;
 	List	   *radiusports;
 	char	   *radiusports_s;
+	char	   *oauth_issuer;
+	char	   *oauth_scope;
+	char	   *oauth_validator;
+	bool		oauth_skip_usermap;
 } HbaLine;
 
 typedef struct IdentLine
diff --git a/src/include/libpq/oauth.h b/src/include/libpq/oauth.h
new file mode 100644
index 0000000000..4fcdda7430
--- /dev/null
+++ b/src/include/libpq/oauth.h
@@ -0,0 +1,54 @@
+/*-------------------------------------------------------------------------
+ *
+ * oauth.h
+ *	  Interface to libpq/auth-oauth.c
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/include/libpq/oauth.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PG_OAUTH_H
+#define PG_OAUTH_H
+
+#include "libpq/libpq-be.h"
+#include "libpq/sasl.h"
+
+extern PGDLLIMPORT char *oauth_validator_libraries_string;
+
+typedef struct ValidatorModuleState
+{
+	void	   *private_data;
+} ValidatorModuleState;
+
+typedef struct ValidatorModuleResult
+{
+	bool		authorized;
+	char	   *authn_id;
+} ValidatorModuleResult;
+
+typedef void (*ValidatorStartupCB) (ValidatorModuleState *state);
+typedef void (*ValidatorShutdownCB) (ValidatorModuleState *state);
+typedef ValidatorModuleResult *(*ValidatorValidateCB) (ValidatorModuleState *state, const char *token, const char *role);
+
+typedef struct OAuthValidatorCallbacks
+{
+	ValidatorStartupCB startup_cb;
+	ValidatorShutdownCB shutdown_cb;
+	ValidatorValidateCB validate_cb;
+} OAuthValidatorCallbacks;
+
+typedef const OAuthValidatorCallbacks *(*OAuthValidatorModuleInit) (void);
+extern PGDLLEXPORT const OAuthValidatorCallbacks *_PG_oauth_validator_module_init(void);
+
+/* Implementation */
+extern const pg_be_sasl_mech pg_be_oauth_mech;
+
+/*
+ * Ensure a validator named in the HBA is permitted by the configuration.
+ */
+extern bool check_oauth_validator(HbaLine *hba, int elevel, char **err_msg);
+
+#endif							/* PG_OAUTH_H */
diff --git a/src/include/pg_config.h.in b/src/include/pg_config.h.in
index 07b2f798ab..c04ee38d08 100644
--- a/src/include/pg_config.h.in
+++ b/src/include/pg_config.h.in
@@ -229,6 +229,9 @@
 /* Define to 1 if you have the `crypto' library (-lcrypto). */
 #undef HAVE_LIBCRYPTO
 
+/* Define to 1 if you have the `curl' library (-lcurl). */
+#undef HAVE_LIBCURL
+
 /* Define to 1 if you have the `ldap' library (-lldap). */
 #undef HAVE_LIBLDAP
 
@@ -442,6 +445,9 @@
 /* Define to 1 if you have the <termios.h> header file. */
 #undef HAVE_TERMIOS_H
 
+/* Define to 1 if curl_global_init() is guaranteed to be threadsafe. */
+#undef HAVE_THREADSAFE_CURL_GLOBAL_INIT
+
 /* Define to 1 if your compiler understands `typeof' or something similar. */
 #undef HAVE_TYPEOF
 
@@ -663,6 +669,9 @@
 /* Define to 1 to build with LDAP support. (--with-ldap) */
 #undef USE_LDAP
 
+/* Define to 1 to build with libcurl support. (--with-libcurl) */
+#undef USE_LIBCURL
+
 /* Define to 1 to build with XML support. (--with-libxml) */
 #undef USE_LIBXML
 
diff --git a/src/interfaces/libpq/Makefile b/src/interfaces/libpq/Makefile
index 6a0def7273..e9422888e3 100644
--- a/src/interfaces/libpq/Makefile
+++ b/src/interfaces/libpq/Makefile
@@ -31,6 +31,7 @@ endif
 
 OBJS = \
 	$(WIN32RES) \
+	fe-auth-oauth.o \
 	fe-auth-scram.o \
 	fe-cancel.o \
 	fe-connect.o \
@@ -63,6 +64,10 @@ OBJS += \
 	fe-secure-gssapi.o
 endif
 
+ifeq ($(with_libcurl),yes)
+OBJS += fe-auth-oauth-curl.o
+endif
+
 ifeq ($(PORTNAME), cygwin)
 override shlib = cyg$(NAME)$(DLSUFFIX)
 endif
@@ -81,7 +86,7 @@ endif
 # that are built correctly for use in a shlib.
 SHLIB_LINK_INTERNAL = -lpgcommon_shlib -lpgport_shlib
 ifneq ($(PORTNAME), win32)
-SHLIB_LINK += $(filter -lcrypt -ldes -lcom_err -lcrypto -lk5crypto -lkrb5 -lgssapi_krb5 -lgss -lgssapi -lssl -lsocket -lnsl -lresolv -lintl -lm, $(LIBS)) $(LDAP_LIBS_FE) $(PTHREAD_LIBS)
+SHLIB_LINK += $(filter -lcrypt -ldes -lcom_err -lcrypto -lk5crypto -lkrb5 -lgssapi_krb5 -lgss -lgssapi -lssl -lcurl -lsocket -lnsl -lresolv -lintl -lm, $(LIBS)) $(LDAP_LIBS_FE) $(PTHREAD_LIBS)
 else
 SHLIB_LINK += $(filter -lcrypt -ldes -lcom_err -lcrypto -lk5crypto -lkrb5 -lgssapi32 -lssl -lsocket -lnsl -lresolv -lintl -lm $(PTHREAD_LIBS), $(LIBS)) $(LDAP_LIBS_FE)
 endif
@@ -110,6 +115,8 @@ backend_src = $(top_srcdir)/src/backend
 # which seems to insert references to that even in pure C code. Excluding
 # __tsan_func_exit is necessary when using ThreadSanitizer data race detector
 # which use this function for instrumentation of function exit.
+# libcurl registers an exit handler in the memory debugging code when running
+# with LeakSanitizer.
 # Skip the test when profiling, as gcc may insert exit() calls for that.
 # Also skip the test on platforms where libpq infrastructure may be provided
 # by statically-linked libraries, as we can't expect them to honor this
@@ -117,7 +124,7 @@ backend_src = $(top_srcdir)/src/backend
 libpq-refs-stamp: $(shlib)
 ifneq ($(enable_coverage), yes)
 ifeq (,$(filter solaris,$(PORTNAME)))
-	@if nm -A -u $< 2>/dev/null | grep -v -e __cxa_atexit -e __tsan_func_exit | grep exit; then \
+	@if nm -A -u $< 2>/dev/null | grep -v -e __cxa_atexit -e __tsan_func_exit -e _atexit | grep exit; then \
 		echo 'libpq must not be calling any function which invokes exit'; exit 1; \
 	fi
 endif
diff --git a/src/interfaces/libpq/exports.txt b/src/interfaces/libpq/exports.txt
index 2ad2cbf5ca..9b789cbec0 100644
--- a/src/interfaces/libpq/exports.txt
+++ b/src/interfaces/libpq/exports.txt
@@ -206,3 +206,6 @@ PQsocketPoll              203
 PQsetChunkedRowsMode      204
 PQgetCurrentTimeUSec      205
 PQservice                 206
+PQsetAuthDataHook         207
+PQgetAuthDataHook         208
+PQdefaultAuthDataHook     209
diff --git a/src/interfaces/libpq/fe-auth-oauth-curl.c b/src/interfaces/libpq/fe-auth-oauth-curl.c
new file mode 100644
index 0000000000..2407200ea9
--- /dev/null
+++ b/src/interfaces/libpq/fe-auth-oauth-curl.c
@@ -0,0 +1,2635 @@
+/*-------------------------------------------------------------------------
+ *
+ * fe-auth-oauth-curl.c
+ *	   The libcurl implementation of OAuth/OIDC authentication, using the
+ *	   OAuth Device Authorization Grant (RFC 8628).
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/interfaces/libpq/fe-auth-oauth-curl.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres_fe.h"
+
+#include <curl/curl.h>
+#include <math.h>
+#ifdef HAVE_SYS_EPOLL_H
+#include <sys/epoll.h>
+#include <sys/timerfd.h>
+#endif
+#ifdef HAVE_SYS_EVENT_H
+#include <sys/event.h>
+#endif
+#include <unistd.h>
+
+#include "common/jsonapi.h"
+#include "fe-auth.h"
+#include "fe-auth-oauth.h"
+#include "libpq-int.h"
+#include "mb/pg_wchar.h"
+
+#define MAX_OAUTH_RESPONSE_SIZE (1024 * 1024)
+
+/*
+ * Parsed JSON Representations
+ *
+ * As a general rule, we parse and cache only the fields we're currently using.
+ * When adding new fields, ensure the corresponding free_*() function is updated
+ * too.
+ */
+
+/*
+ * The OpenID Provider configuration (alternatively named "authorization server
+ * metadata") jointly described by OpenID Connect Discovery 1.0 and RFC 8414:
+ *
+ *     https://openid.net/specs/openid-connect-discovery-1_0.html
+ *     https://www.rfc-editor.org/rfc/rfc8414#section-3.2
+ */
+struct provider
+{
+	char	   *issuer;
+	char	   *token_endpoint;
+	char	   *device_authorization_endpoint;
+	struct curl_slist *grant_types_supported;
+};
+
+static void
+free_provider(struct provider *provider)
+{
+	free(provider->issuer);
+	free(provider->token_endpoint);
+	free(provider->device_authorization_endpoint);
+	curl_slist_free_all(provider->grant_types_supported);
+}
+
+/*
+ * The Device Authorization response, described by RFC 8628:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc8628#section-3.2
+ */
+struct device_authz
+{
+	char	   *device_code;
+	char	   *user_code;
+	char	   *verification_uri;
+	char	   *interval_str;
+
+	/* Fields below are parsed from the corresponding string above. */
+	int			interval;
+};
+
+static void
+free_device_authz(struct device_authz *authz)
+{
+	free(authz->device_code);
+	free(authz->user_code);
+	free(authz->verification_uri);
+	free(authz->interval_str);
+}
+
+/*
+ * The Token Endpoint error response, as described by RFC 6749:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc6749#section-5.2
+ *
+ * Note that this response type can also be returned from the Device
+ * Authorization Endpoint.
+ */
+struct token_error
+{
+	char	   *error;
+	char	   *error_description;
+};
+
+static void
+free_token_error(struct token_error *err)
+{
+	free(err->error);
+	free(err->error_description);
+}
+
+/*
+ * The Access Token response, as described by RFC 6749:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc6749#section-4.1.4
+ *
+ * During the Device Authorization flow, several temporary errors are expected
+ * as part of normal operation. To make it easy to handle these in the happy
+ * path, this contains an embedded token_error that is filled in if needed.
+ */
+struct token
+{
+	/* for successful responses */
+	char	   *access_token;
+	char	   *token_type;
+
+	/* for error responses */
+	struct token_error err;
+};
+
+static void
+free_token(struct token *tok)
+{
+	free(tok->access_token);
+	free(tok->token_type);
+	free_token_error(&tok->err);
+}
+
+/*
+ * Asynchronous State
+ */
+
+/* States for the overall async machine. */
+enum OAuthStep
+{
+	OAUTH_STEP_INIT = 0,
+	OAUTH_STEP_DISCOVERY,
+	OAUTH_STEP_DEVICE_AUTHORIZATION,
+	OAUTH_STEP_TOKEN_REQUEST,
+	OAUTH_STEP_WAIT_INTERVAL,
+};
+
+/*
+ * The async_ctx holds onto state that needs to persist across multiple calls
+ * to pg_fe_run_oauth_flow(). Almost everything interacts with this in some
+ * way.
+ */
+struct async_ctx
+{
+	enum OAuthStep step;		/* where are we in the flow? */
+
+#ifdef HAVE_SYS_EPOLL_H
+	int			timerfd;		/* a timerfd for signaling async timeouts */
+#endif
+	pgsocket	mux;			/* the multiplexer socket containing all
+								 * descriptors tracked by libcurl, plus the
+								 * timerfd */
+	CURLM	   *curlm;			/* top-level multi handle for libcurl
+								 * operations */
+	CURL	   *curl;			/* the (single) easy handle for serial
+								 * requests */
+
+	struct curl_slist *headers; /* common headers for all requests */
+	PQExpBufferData work_data;	/* scratch buffer for general use (remember to
+								 * clear out prior contents first!) */
+
+	/*------
+	 * Since a single logical operation may stretch across multiple calls to
+	 * our entry point, errors have three parts:
+	 *
+	 * - errctx:	an optional static string, describing the global operation
+	 *				currently in progress. It'll be translated for you.
+	 *
+	 * - errbuf:	contains the actual error message. Generally speaking, use
+	 *				actx_error[_str] to manipulate this. This must be filled
+	 *				with something useful on an error.
+	 *
+	 * - curl_err:	an optional static error buffer used by libcurl to put
+	 *				detailed information about failures. Unfortunately
+	 *				untranslatable.
+	 *
+	 * These pieces will be combined into a single error message looking
+	 * something like the following, with errctx and/or curl_err omitted when
+	 * absent:
+	 *
+	 *     connection to server ... failed: errctx: errbuf (curl_err)
+	 */
+	const char *errctx;			/* not freed; must point to static allocation */
+	PQExpBufferData errbuf;
+	char		curl_err[CURL_ERROR_SIZE];
+
+	/*
+	 * These documents need to survive over multiple calls, and are therefore
+	 * cached directly in the async_ctx.
+	 */
+	struct provider provider;
+	struct device_authz authz;
+
+	int			running;		/* is asynchronous work in progress? */
+	bool		user_prompted;	/* have we already sent the authz prompt? */
+	bool		used_basic_auth;	/* did we send a client secret? */
+	bool		debugging;		/* can we give unsafe developer assistance? */
+};
+
+/*
+ * Tears down the Curl handles and frees the async_ctx.
+ */
+static void
+free_async_ctx(PGconn *conn, struct async_ctx *actx)
+{
+	/*
+	 * TODO: in general, none of the error cases below should ever happen if
+	 * we have no bugs above. But if we do hit them, surfacing those errors
+	 * somehow might be the only way to have a chance to debug them. What's
+	 * the best way to do that? Assertions? Spraying messages on stderr?
+	 * Bubbling an error code to the top? Appending to the connection's error
+	 * message only helps if the bug caused a connection failure; otherwise
+	 * it'll be buried...
+	 */
+
+	if (actx->curlm && actx->curl)
+	{
+		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));
+	}
+
+	if (actx->curl)
+	{
+		/*
+		 * curl_multi_cleanup() doesn't free any associated easy handles; we
+		 * need to do that separately. We only ever have one easy handle per
+		 * multi handle.
+		 */
+		curl_easy_cleanup(actx->curl);
+	}
+
+	if (actx->curlm)
+	{
+		CURLMcode	err = curl_multi_cleanup(actx->curlm);
+
+		if (err)
+			libpq_append_conn_error(conn,
+									"libcurl multi handle cleanup failed: %s",
+									curl_multi_strerror(err));
+	}
+
+	free_provider(&actx->provider);
+	free_device_authz(&actx->authz);
+
+	curl_slist_free_all(actx->headers);
+	termPQExpBuffer(&actx->work_data);
+	termPQExpBuffer(&actx->errbuf);
+
+	if (actx->mux != PGINVALID_SOCKET)
+		close(actx->mux);
+#ifdef HAVE_SYS_EPOLL_H
+	if (actx->timerfd >= 0)
+		close(actx->timerfd);
+#endif
+
+	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.
+ */
+void
+pg_fe_cleanup_oauth_flow(PGconn *conn)
+{
+	fe_oauth_state *state = conn->sasl_state;
+
+	if (state->async_ctx)
+	{
+		free_async_ctx(conn, state->async_ctx);
+		state->async_ctx = NULL;
+	}
+
+	conn->altsock = PGINVALID_SOCKET;
+}
+
+/*
+ * Macros for manipulating actx->errbuf. actx_error() translates and formats a
+ * string for you; actx_error_str() appends a string directly without
+ * translation.
+ */
+
+#define actx_error(ACTX, FMT, ...) \
+	appendPQExpBuffer(&(ACTX)->errbuf, libpq_gettext(FMT), ##__VA_ARGS__)
+
+#define actx_error_str(ACTX, S) \
+	appendPQExpBufferStr(&(ACTX)->errbuf, S)
+
+/*
+ * Macros for getting and setting state for the connection's two libcurl
+ * handles, so you don't have to write out the error handling every time.
+ */
+
+#define CHECK_MSETOPT(ACTX, OPT, VAL, FAILACTION) \
+	do { \
+		struct async_ctx *_actx = (ACTX); \
+		CURLMcode	_setopterr = curl_multi_setopt(_actx->curlm, OPT, VAL); \
+		if (_setopterr) { \
+			actx_error(_actx, "failed to set %s on OAuth connection: %s",\
+					   #OPT, curl_multi_strerror(_setopterr)); \
+			FAILACTION; \
+		} \
+	} while (0)
+
+#define CHECK_SETOPT(ACTX, OPT, VAL, FAILACTION) \
+	do { \
+		struct async_ctx *_actx = (ACTX); \
+		CURLcode	_setopterr = curl_easy_setopt(_actx->curl, OPT, VAL); \
+		if (_setopterr) { \
+			actx_error(_actx, "failed to set %s on OAuth connection: %s",\
+					   #OPT, curl_easy_strerror(_setopterr)); \
+			FAILACTION; \
+		} \
+	} while (0)
+
+#define CHECK_GETINFO(ACTX, INFO, OUT, FAILACTION) \
+	do { \
+		struct async_ctx *_actx = (ACTX); \
+		CURLcode	_getinfoerr = curl_easy_getinfo(_actx->curl, INFO, OUT); \
+		if (_getinfoerr) { \
+			actx_error(_actx, "failed to get %s from OAuth response: %s",\
+					   #INFO, curl_easy_strerror(_getinfoerr)); \
+			FAILACTION; \
+		} \
+	} while (0)
+
+/*
+ * General JSON Parsing for OAuth Responses
+ */
+
+/*
+ * Represents a single name/value pair in a JSON object. This is the primary
+ * interface to parse_oauth_json().
+ *
+ * All fields are stored internally as strings or lists of strings, so clients
+ * have to explicitly parse other scalar types (though they will have gone
+ * through basic lexical validation). Storing nested objects is not currently
+ * supported, nor is parsing arrays of anything other than strings.
+ */
+struct json_field
+{
+	const char *name;			/* name (key) of the member */
+
+	JsonTokenType type;			/* currently supports JSON_TOKEN_STRING,
+								 * JSON_TOKEN_NUMBER, and
+								 * JSON_TOKEN_ARRAY_START */
+	union
+	{
+		char	  **scalar;		/* for all scalar types */
+		struct curl_slist **array;	/* for type == JSON_TOKEN_ARRAY_START */
+	}			target;
+
+	bool		required;		/* REQUIRED field, or just OPTIONAL? */
+};
+
+/* Documentation macros for json_field.required. */
+#define REQUIRED true
+#define OPTIONAL false
+
+/* Parse state for parse_oauth_json(). */
+struct oauth_parse
+{
+	PQExpBuffer errbuf;			/* detail message for JSON_SEM_ACTION_FAILED */
+	int			nested;			/* nesting level (zero is the top) */
+
+	const struct json_field *fields;	/* field definition array */
+	const struct json_field *active;	/* points inside the fields array */
+};
+
+#define oauth_parse_set_error(ctx, fmt, ...) \
+	appendPQExpBuffer((ctx)->errbuf, libpq_gettext(fmt), ##__VA_ARGS__)
+
+static void
+report_type_mismatch(struct oauth_parse *ctx)
+{
+	char	   *msgfmt;
+
+	Assert(ctx->active);
+
+	/*
+	 * At the moment, the only fields we're interested in are strings,
+	 * numbers, and arrays of strings.
+	 */
+	switch (ctx->active->type)
+	{
+		case JSON_TOKEN_STRING:
+			msgfmt = "field \"%s\" must be a string";
+			break;
+
+		case JSON_TOKEN_NUMBER:
+			msgfmt = "field \"%s\" must be a number";
+			break;
+
+		case JSON_TOKEN_ARRAY_START:
+			msgfmt = "field \"%s\" must be an array of strings";
+			break;
+
+		default:
+			Assert(false);
+			msgfmt = "field \"%s\" has unexpected type";
+	}
+
+	oauth_parse_set_error(ctx, msgfmt, ctx->active->name);
+}
+
+static JsonParseErrorType
+oauth_json_object_start(void *state)
+{
+	struct oauth_parse *ctx = state;
+
+	if (ctx->active)
+	{
+		/*
+		 * Currently, none of the fields we're interested in can be or contain
+		 * objects, so we can reject this case outright.
+		 */
+		report_type_mismatch(ctx);
+		return JSON_SEM_ACTION_FAILED;
+	}
+
+	++ctx->nested;
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_object_field_start(void *state, char *name, bool isnull)
+{
+	struct oauth_parse *ctx = state;
+
+	/* We care only about the top-level fields. */
+	if (ctx->nested == 1)
+	{
+		const struct json_field *field = ctx->fields;
+
+		/*
+		 * We should never start parsing a new field while a previous one is
+		 * still active.
+		 *
+		 * TODO: this code relies on assertions too much. We need to exit
+		 * sanely on internal logic errors, to avoid turning bugs into
+		 * vulnerabilities.
+		 */
+		Assert(!ctx->active);
+
+		while (field->name)
+		{
+			if (strcmp(name, field->name) == 0)
+			{
+				ctx->active = field;
+				break;
+			}
+
+			++field;
+		}
+
+		/*
+		 * We don't allow duplicate field names; error out if the target has
+		 * already been set.
+		 */
+		if (ctx->active)
+		{
+			field = ctx->active;
+
+			if ((field->type == JSON_TOKEN_ARRAY_START && *field->target.array)
+				|| (field->type != JSON_TOKEN_ARRAY_START && *field->target.scalar))
+			{
+				oauth_parse_set_error(ctx, "field \"%s\" is duplicated",
+									  field->name);
+				return JSON_SEM_ACTION_FAILED;
+			}
+		}
+	}
+
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_object_end(void *state)
+{
+	struct oauth_parse *ctx = state;
+
+	--ctx->nested;
+	if (!ctx->nested)
+		Assert(!ctx->active);	/* all fields should be fully processed */
+
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_array_start(void *state)
+{
+	struct oauth_parse *ctx = state;
+
+	if (!ctx->nested)
+	{
+		oauth_parse_set_error(ctx, "top-level element must be an object");
+		return JSON_SEM_ACTION_FAILED;
+	}
+
+	if (ctx->active)
+	{
+		if (ctx->active->type != JSON_TOKEN_ARRAY_START
+		/* The arrays we care about must not have arrays as values. */
+			|| ctx->nested > 1)
+		{
+			report_type_mismatch(ctx);
+			return JSON_SEM_ACTION_FAILED;
+		}
+	}
+
+	++ctx->nested;
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_array_end(void *state)
+{
+	struct oauth_parse *ctx = state;
+
+	if (ctx->active)
+	{
+		/*
+		 * This assumes that no target arrays can contain other arrays, which
+		 * we check in the array_start callback.
+		 */
+		Assert(ctx->nested == 2);
+		Assert(ctx->active->type == JSON_TOKEN_ARRAY_START);
+
+		ctx->active = NULL;
+	}
+
+	--ctx->nested;
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_scalar(void *state, char *token, JsonTokenType type)
+{
+	struct oauth_parse *ctx = state;
+
+	if (!ctx->nested)
+	{
+		oauth_parse_set_error(ctx, "top-level element must be an object");
+		return JSON_SEM_ACTION_FAILED;
+	}
+
+	if (ctx->active)
+	{
+		const struct json_field *field = ctx->active;
+		JsonTokenType expected = field->type;
+
+		/* Make sure this matches what the active field expects. */
+		if (expected == JSON_TOKEN_ARRAY_START)
+		{
+			/* Are we actually inside an array? */
+			if (ctx->nested < 2)
+			{
+				report_type_mismatch(ctx);
+				return JSON_SEM_ACTION_FAILED;
+			}
+
+			/* Currently, arrays can only contain strings. */
+			expected = JSON_TOKEN_STRING;
+		}
+
+		if (type != expected)
+		{
+			report_type_mismatch(ctx);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		if (field->type != JSON_TOKEN_ARRAY_START)
+		{
+			Assert(ctx->nested == 1);
+			Assert(!*field->target.scalar);
+
+			*field->target.scalar = strdup(token);
+			if (!*field->target.scalar)
+				return JSON_OUT_OF_MEMORY;
+
+			ctx->active = NULL;
+
+			return JSON_SUCCESS;
+		}
+		else
+		{
+			struct curl_slist *temp;
+
+			Assert(ctx->nested == 2);
+
+			/* Note that curl_slist_append() makes a copy of the token. */
+			temp = curl_slist_append(*field->target.array, token);
+			if (!temp)
+				return JSON_OUT_OF_MEMORY;
+
+			*field->target.array = temp;
+		}
+	}
+	else
+	{
+		/* otherwise we just ignore it */
+	}
+
+	return JSON_SUCCESS;
+}
+
+/*
+ * Checks the Content-Type header against the expected type. Parameters are
+ * allowed but ignored.
+ */
+static bool
+check_content_type(struct async_ctx *actx, const char *type)
+{
+	const size_t type_len = strlen(type);
+	char	   *content_type;
+
+	CHECK_GETINFO(actx, CURLINFO_CONTENT_TYPE, &content_type, return false);
+
+	if (!content_type)
+	{
+		actx_error(actx, "no content type was provided");
+		return false;
+	}
+
+	/*
+	 * We need to perform a length limited comparison and not compare the
+	 * whole string.
+	 */
+	if (pg_strncasecmp(content_type, type, type_len) != 0)
+		goto fail;
+
+	/* On an exact match, we're done. */
+	Assert(strlen(content_type) >= type_len);
+	if (content_type[type_len] == '\0')
+		return true;
+
+	/*
+	 * Only a semicolon (optionally preceded by HTTP optional whitespace) is
+	 * acceptable after the prefix we checked. This marks the start of media
+	 * type parameters, which we currently have no use for.
+	 */
+	for (size_t i = type_len; content_type[i]; ++i)
+	{
+		switch (content_type[i])
+		{
+			case ';':
+				return true;	/* success! */
+
+			case ' ':
+			case '\t':
+				/* HTTP optional whitespace allows only spaces and htabs. */
+				break;
+
+			default:
+				goto fail;
+		}
+	}
+
+fail:
+	actx_error(actx, "unexpected content type: \"%s\"", content_type);
+	return false;
+}
+
+/*
+ * A helper function for general JSON parsing. fields is the array of field
+ * definitions with their backing pointers. The response will be parsed from
+ * actx->curl and actx->work_data (as set up by start_request()), and any
+ * parsing errors will be placed into actx->errbuf.
+ */
+static bool
+parse_oauth_json(struct async_ctx *actx, const struct json_field *fields)
+{
+	PQExpBuffer resp = &actx->work_data;
+	JsonLexContext lex = {0};
+	JsonSemAction sem = {0};
+	JsonParseErrorType err;
+	struct oauth_parse ctx = {0};
+	bool		success = false;
+
+	if (!check_content_type(actx, "application/json"))
+		return false;
+
+	if (strlen(resp->data) != resp->len)
+	{
+		actx_error(actx, "response contains embedded NULLs");
+		return false;
+	}
+
+	/*
+	 * pg_parse_json doesn't validate the incoming UTF-8, so we have to check
+	 * that up front.
+	 */
+	if (pg_encoding_verifymbstr(PG_UTF8, resp->data, resp->len) != resp->len)
+	{
+		actx_error(actx, "response is not valid UTF-8");
+		return false;
+	}
+
+	makeJsonLexContextCstringLen(&lex, resp->data, resp->len, PG_UTF8, true);
+	setJsonLexContextOwnsTokens(&lex, true);	/* must not leak on error */
+
+	ctx.errbuf = &actx->errbuf;
+	ctx.fields = fields;
+	sem.semstate = &ctx;
+
+	sem.object_start = oauth_json_object_start;
+	sem.object_field_start = oauth_json_object_field_start;
+	sem.object_end = oauth_json_object_end;
+	sem.array_start = oauth_json_array_start;
+	sem.array_end = oauth_json_array_end;
+	sem.scalar = oauth_json_scalar;
+
+	err = pg_parse_json(&lex, &sem);
+
+	if (err != JSON_SUCCESS)
+	{
+		/*
+		 * For JSON_SEM_ACTION_FAILED, we've already written the error
+		 * message. Other errors come directly from pg_parse_json(), already
+		 * translated.
+		 */
+		if (err != JSON_SEM_ACTION_FAILED)
+			actx_error_str(actx, json_errdetail(err, &lex));
+
+		goto cleanup;
+	}
+
+	/* Check all required fields. */
+	while (fields->name)
+	{
+		if (fields->required
+			&& !*fields->target.scalar
+			&& !*fields->target.array)
+		{
+			actx_error(actx, "field \"%s\" is missing", fields->name);
+			goto cleanup;
+		}
+
+		fields++;
+	}
+
+	success = true;
+
+cleanup:
+	freeJsonLexContext(&lex);
+	return success;
+}
+
+/*
+ * JSON Parser Definitions
+ */
+
+/*
+ * Parses authorization server metadata. Fields are defined by OIDC Discovery
+ * 1.0 and RFC 8414.
+ */
+static bool
+parse_provider(struct async_ctx *actx, struct provider *provider)
+{
+	struct json_field fields[] = {
+		{"issuer", JSON_TOKEN_STRING, {&provider->issuer}, REQUIRED},
+		{"token_endpoint", JSON_TOKEN_STRING, {&provider->token_endpoint}, REQUIRED},
+
+		/*----
+		 * The following fields are technically REQUIRED, but we don't use
+		 * them anywhere yet:
+		 *
+		 * - jwks_uri
+		 * - response_types_supported
+		 * - subject_types_supported
+		 * - id_token_signing_alg_values_supported
+		 */
+
+		{"device_authorization_endpoint", JSON_TOKEN_STRING, {&provider->device_authorization_endpoint}, OPTIONAL},
+		{"grant_types_supported", JSON_TOKEN_ARRAY_START, {.array = &provider->grant_types_supported}, OPTIONAL},
+
+		{0},
+	};
+
+	return parse_oauth_json(actx, fields);
+}
+
+/*
+ * Parses the "interval" JSON number, corresponding to the number of seconds to
+ * wait between token endpoint requests.
+ *
+ * RFC 8628 is pretty silent on sanity checks for the interval. As a matter of
+ * practicality, round any fractional intervals up to the next second, and clamp
+ * the result at a minimum of one. (Zero-second intervals would result in an
+ * expensive network polling loop.) Tests may remove the lower bound with
+ * PGOAUTHDEBUG, for improved performance.
+ *
+ * TODO: maybe clamp the upper bound too, based on the libpq timeout and/or the
+ * code expiration time?
+ */
+static int
+parse_interval(struct async_ctx *actx, const char *interval_str)
+{
+	double		parsed;
+	int			cnt;
+
+	/*
+	 * The JSON lexer has already validated the number, which is stricter than
+	 * the %f format, so we should be good to use sscanf().
+	 */
+	cnt = sscanf(interval_str, "%lf", &parsed);
+
+	if (cnt != 1)
+	{
+		/*
+		 * Either the lexer screwed up or our assumption above isn't true, and
+		 * either way a developer needs to take a look.
+		 */
+		Assert(cnt == 1);
+		return 1;				/* don't fall through in release builds */
+	}
+
+	parsed = ceil(parsed);
+
+	if (parsed < 1)
+		return actx->debugging ? 0 : 1;
+
+	else if (INT_MAX <= parsed)
+		return INT_MAX;
+
+	return parsed;
+}
+
+/*
+ * Parses the Device Authorization Response (RFC 8628, Sec. 3.2).
+ */
+static bool
+parse_device_authz(struct async_ctx *actx, struct device_authz *authz)
+{
+	struct json_field fields[] = {
+		{"device_code", JSON_TOKEN_STRING, {&authz->device_code}, REQUIRED},
+		{"user_code", JSON_TOKEN_STRING, {&authz->user_code}, REQUIRED},
+		{"verification_uri", JSON_TOKEN_STRING, {&authz->verification_uri}, REQUIRED},
+
+		/*
+		 * Some services (Google, Azure) spell verification_uri differently.
+		 * We accept either.
+		 */
+		{"verification_url", JSON_TOKEN_STRING, {&authz->verification_uri}, REQUIRED},
+
+		/*
+		 * The following fields are technically REQUIRED, but we don't use
+		 * them anywhere yet:
+		 *
+		 * - expires_in
+		 */
+
+		{"interval", JSON_TOKEN_NUMBER, {&authz->interval_str}, OPTIONAL},
+
+		{0},
+	};
+
+	if (!parse_oauth_json(actx, fields))
+		return false;
+
+	/*
+	 * Parse our numeric fields. Lexing has already completed by this time, so
+	 * we at least know they're valid JSON numbers.
+	 */
+	if (authz->interval_str)
+		authz->interval = parse_interval(actx, authz->interval_str);
+	else
+	{
+		/*
+		 * RFC 8628 specifies 5 seconds as the default value if the server
+		 * doesn't provide an interval.
+		 */
+		authz->interval = 5;
+	}
+
+	return true;
+}
+
+/*
+ * Parses the device access token error response (RFC 8628, Sec. 3.5, which
+ * uses the error response defined in RFC 6749, Sec. 5.2).
+ */
+static bool
+parse_token_error(struct async_ctx *actx, struct token_error *err)
+{
+	bool		result;
+	struct json_field fields[] = {
+		{"error", JSON_TOKEN_STRING, {&err->error}, REQUIRED},
+
+		{"error_description", JSON_TOKEN_STRING, {&err->error_description}, OPTIONAL},
+
+		{0},
+	};
+
+	result = parse_oauth_json(actx, fields);
+
+	/*
+	 * Since token errors are parsed during other active error paths, only
+	 * override the errctx if parsing explicitly fails.
+	 */
+	if (!result)
+		actx->errctx = "failed to parse token error response";
+
+	return result;
+}
+
+/*
+ * Constructs a message from the token error response and puts it into
+ * actx->errbuf.
+ */
+static void
+record_token_error(struct async_ctx *actx, const struct token_error *err)
+{
+	if (err->error_description)
+		appendPQExpBuffer(&actx->errbuf, "%s ", err->error_description);
+	else
+	{
+		/*
+		 * Try to get some more helpful detail into the error string. A 401
+		 * status in particular implies that the oauth_client_secret is
+		 * missing or wrong.
+		 */
+		long		response_code;
+
+		CHECK_GETINFO(actx, CURLINFO_RESPONSE_CODE, &response_code, response_code = 0);
+
+		if (response_code == 401)
+		{
+			actx_error(actx, actx->used_basic_auth
+					   ? "provider rejected the oauth_client_secret"
+					   : "provider requires client authentication, and no oauth_client_secret is set");
+			actx_error_str(actx, " ");
+		}
+	}
+
+	appendPQExpBuffer(&actx->errbuf, "(%s)", err->error);
+}
+
+/*
+ * Parses the device access token response (RFC 8628, Sec. 3.5, which uses the
+ * success response defined in RFC 6749, Sec. 5.1).
+ */
+static bool
+parse_access_token(struct async_ctx *actx, struct token *tok)
+{
+	struct json_field fields[] = {
+		{"access_token", JSON_TOKEN_STRING, {&tok->access_token}, REQUIRED},
+		{"token_type", JSON_TOKEN_STRING, {&tok->token_type}, REQUIRED},
+
+		/*
+		 * The following fields are technically REQUIRED, but we don't use
+		 * them anywhere yet:
+		 *
+		 * - scope (only required if different than requested -- TODO check)
+		 */
+
+		{0},
+	};
+
+	return parse_oauth_json(actx, fields);
+}
+
+/*
+ * libcurl Multi Setup/Callbacks
+ */
+
+/*
+ * Sets up the actx->mux, which is the altsock that PQconnectPoll clients will
+ * select() on instead of the Postgres socket during OAuth negotiation.
+ *
+ * This is just an epoll set or kqueue abstracting multiple other descriptors.
+ * A timerfd is always part of the set when using epoll; it's just disabled
+ * when we're not using it.
+ */
+static bool
+setup_multiplexer(struct async_ctx *actx)
+{
+#ifdef HAVE_SYS_EPOLL_H
+	struct epoll_event ev = {.events = EPOLLIN};
+
+	actx->mux = epoll_create1(EPOLL_CLOEXEC);
+	if (actx->mux < 0)
+	{
+		actx_error(actx, "failed to create epoll set: %m");
+		return false;
+	}
+
+	actx->timerfd = timerfd_create(CLOCK_MONOTONIC, TFD_CLOEXEC);
+	if (actx->timerfd < 0)
+	{
+		actx_error(actx, "failed to create timerfd: %m");
+		return false;
+	}
+
+	if (epoll_ctl(actx->mux, EPOLL_CTL_ADD, actx->timerfd, &ev) < 0)
+	{
+		actx_error(actx, "failed to add timerfd to epoll set: %m");
+		return false;
+	}
+
+	return true;
+#endif
+#ifdef HAVE_SYS_EVENT_H
+	actx->mux = kqueue();
+	if (actx->mux < 0)
+	{
+		actx_error(actx, "failed to create kqueue: %m");
+		return false;
+	}
+
+	return true;
+#endif
+
+	actx_error(actx, "libpq does not support the Device Authorization flow on this platform");
+	return false;
+}
+
+/*
+ * Adds and removes sockets from the multiplexer set, as directed by the
+ * libcurl multi handle.
+ */
+static int
+register_socket(CURL *curl, curl_socket_t socket, int what, void *ctx,
+				void *socketp)
+{
+#ifdef HAVE_SYS_EPOLL_H
+	struct async_ctx *actx = ctx;
+	struct epoll_event ev = {0};
+	int			res;
+	int			op = EPOLL_CTL_ADD;
+
+	switch (what)
+	{
+		case CURL_POLL_IN:
+			ev.events = EPOLLIN;
+			break;
+
+		case CURL_POLL_OUT:
+			ev.events = EPOLLOUT;
+			break;
+
+		case CURL_POLL_INOUT:
+			ev.events = EPOLLIN | EPOLLOUT;
+			break;
+
+		case CURL_POLL_REMOVE:
+			op = EPOLL_CTL_DEL;
+			break;
+
+		default:
+			actx_error(actx, "unknown libcurl socket operation: %d", what);
+			return -1;
+	}
+
+	res = epoll_ctl(actx->mux, op, socket, &ev);
+	if (res < 0 && errno == EEXIST)
+	{
+		/* We already had this socket in the pollset. */
+		op = EPOLL_CTL_MOD;
+		res = epoll_ctl(actx->mux, op, socket, &ev);
+	}
+
+	if (res < 0)
+	{
+		switch (op)
+		{
+			case EPOLL_CTL_ADD:
+				actx_error(actx, "could not add to epoll set: %m");
+				break;
+
+			case EPOLL_CTL_DEL:
+				actx_error(actx, "could not delete from epoll set: %m");
+				break;
+
+			default:
+				actx_error(actx, "could not update epoll set: %m");
+		}
+
+		return -1;
+	}
+#endif
+#ifdef HAVE_SYS_EVENT_H
+	struct async_ctx *actx = ctx;
+	struct kevent ev[2] = {{0}};
+	struct kevent ev_out[2];
+	struct timespec timeout = {0};
+	int			nev = 0;
+	int			res;
+
+	switch (what)
+	{
+		case CURL_POLL_IN:
+			EV_SET(&ev[nev], socket, EVFILT_READ, EV_ADD | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			break;
+
+		case CURL_POLL_OUT:
+			EV_SET(&ev[nev], socket, EVFILT_WRITE, EV_ADD | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			break;
+
+		case CURL_POLL_INOUT:
+			EV_SET(&ev[nev], socket, EVFILT_READ, EV_ADD | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			EV_SET(&ev[nev], socket, EVFILT_WRITE, EV_ADD | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			break;
+
+		case CURL_POLL_REMOVE:
+
+			/*
+			 * We don't know which of these is currently registered, perhaps
+			 * both, so we try to remove both.  This means we need to tolerate
+			 * ENOENT below.
+			 */
+			EV_SET(&ev[nev], socket, EVFILT_READ, EV_DELETE | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			EV_SET(&ev[nev], socket, EVFILT_WRITE, EV_DELETE | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			break;
+
+		default:
+			actx_error(actx, "unknown libcurl socket operation: %d", what);
+			return -1;
+	}
+
+	res = kevent(actx->mux, ev, nev, ev_out, lengthof(ev_out), &timeout);
+	if (res < 0)
+	{
+		actx_error(actx, "could not modify kqueue: %m");
+		return -1;
+	}
+
+	/*
+	 * We can't use the simple errno version of kevent, because we need to
+	 * skip over ENOENT while still allowing a second change to be processed.
+	 * So we need a longer-form error checking loop.
+	 */
+	for (int i = 0; i < res; ++i)
+	{
+		/*
+		 * EV_RECEIPT should guarantee one EV_ERROR result for every change,
+		 * whether successful or not. Failed entries contain a non-zero errno
+		 * in the data field.
+		 */
+		Assert(ev_out[i].flags & EV_ERROR);
+
+		errno = ev_out[i].data;
+		if (errno && errno != ENOENT)
+		{
+			switch (what)
+			{
+				case CURL_POLL_REMOVE:
+					actx_error(actx, "could not delete from kqueue: %m");
+					break;
+				default:
+					actx_error(actx, "could not add to kqueue: %m");
+			}
+			return -1;
+		}
+	}
+#endif
+
+	return 0;
+}
+
+/*
+ * Enables or disables the timer in the multiplexer set. The timeout value is
+ * in milliseconds (negative values disable the timer). Rather than continually
+ * adding and removing the timer, we keep it in the set at all times and just
+ * disarm it when it's not needed.
+ */
+static bool
+set_timer(struct async_ctx *actx, long timeout)
+{
+#if HAVE_SYS_EPOLL_H
+	struct itimerspec spec = {0};
+
+	if (timeout < 0)
+	{
+		/* the zero itimerspec will disarm the timer below */
+	}
+	else if (timeout == 0)
+	{
+		/*
+		 * A zero timeout means libcurl wants us to call back immediately.
+		 * That's not technically an option for timerfd, but we can make the
+		 * timeout ridiculously short.
+		 */
+		spec.it_value.tv_nsec = 1;
+	}
+	else
+	{
+		spec.it_value.tv_sec = timeout / 1000;
+		spec.it_value.tv_nsec = (timeout % 1000) * 1000000;
+	}
+
+	if (timerfd_settime(actx->timerfd, 0 /* no flags */ , &spec, NULL) < 0)
+	{
+		actx_error(actx, "setting timerfd to %ld: %m", timeout);
+		return false;
+	}
+#endif
+#ifdef HAVE_SYS_EVENT_H
+	struct kevent ev;
+
+	EV_SET(&ev, 1, EVFILT_TIMER, timeout < 0 ? EV_DELETE : EV_ADD,
+		   0, timeout, 0);
+	if (kevent(actx->mux, &ev, 1, NULL, 0, NULL) < 0 && errno != ENOENT)
+	{
+		actx_error(actx, "setting kqueue timer to %ld: %m", timeout);
+		return false;
+	}
+#endif
+
+	return true;
+}
+
+/*
+ * Adds or removes timeouts from the multiplexer set, as directed by the
+ * libcurl multi handle.
+ */
+static int
+register_timer(CURLM *curlm, long timeout, void *ctx)
+{
+	struct async_ctx *actx = ctx;
+
+	/*
+	 * TODO: maybe just signal drive_request() to immediately call back in the
+	 * (timeout == 0) case?
+	 */
+	if (!set_timer(actx, timeout))
+		return -1;				/* actx_error already called */
+
+	return 0;
+}
+
+/*
+ * Prints Curl request debugging information to stderr.
+ *
+ * Note that this will expose a number of critical secrets, so users have to opt
+ * into this (see PGOAUTHDEBUG).
+ */
+static int
+debug_callback(CURL *handle, curl_infotype type, char *data, size_t size,
+			   void *clientp)
+{
+	const char *const end = data + size;
+	const char *prefix;
+
+	/* Prefixes are modeled off of the default libcurl debug output. */
+	switch (type)
+	{
+		case CURLINFO_TEXT:
+			prefix = "*";
+			break;
+
+		case CURLINFO_HEADER_IN:	/* fall through */
+		case CURLINFO_DATA_IN:
+			prefix = "<";
+			break;
+
+		case CURLINFO_HEADER_OUT:	/* fall through */
+		case CURLINFO_DATA_OUT:
+			prefix = ">";
+			break;
+
+		default:
+			return 0;
+	}
+
+	/*
+	 * Split the output into lines for readability; sometimes multiple headers
+	 * are included in a single call.
+	 */
+	while (data < end)
+	{
+		size_t		len = end - data;
+		char	   *eol = memchr(data, '\n', len);
+
+		if (eol)
+			len = eol - data + 1;
+
+		/* TODO: handle unprintables */
+		fprintf(stderr, "%s %.*s%s", prefix, (int) len, data,
+				eol ? "" : "\n");
+
+		data += len;
+	}
+
+	return 0;
+}
+
+/*
+ * Initializes the two libcurl handles in the async_ctx. The multi handle,
+ * actx->curlm, is what drives the asynchronous engine and tells us what to do
+ * next. The easy handle, actx->curl, encapsulates the state for a single
+ * request/response. It's added to the multi handle as needed, during
+ * start_request().
+ */
+static bool
+setup_curl_handles(struct async_ctx *actx)
+{
+	curl_version_info_data *curl_info;
+
+	/*
+	 * Create our multi handle. This encapsulates the entire conversation with
+	 * libcurl for this connection.
+	 */
+	actx->curlm = curl_multi_init();
+	if (!actx->curlm)
+	{
+		/* We don't get a lot of feedback on the failure reason. */
+		actx_error(actx, "failed to create libcurl multi handle");
+		return false;
+	}
+
+	/*
+	 * Extract information about the libcurl we are linked against.
+	 */
+	curl_info = curl_version_info(CURLVERSION_NOW);
+
+	/*
+	 * The multi handle tells us what to wait on using two callbacks. These
+	 * will manipulate actx->mux as needed.
+	 */
+	CHECK_MSETOPT(actx, CURLMOPT_SOCKETFUNCTION, register_socket, return false);
+	CHECK_MSETOPT(actx, CURLMOPT_SOCKETDATA, actx, return false);
+	CHECK_MSETOPT(actx, CURLMOPT_TIMERFUNCTION, register_timer, return false);
+	CHECK_MSETOPT(actx, CURLMOPT_TIMERDATA, actx, return false);
+
+	/*
+	 * Set up an easy handle. All of our requests are made serially, so we
+	 * only ever need to keep track of one.
+	 */
+	actx->curl = curl_easy_init();
+	if (!actx->curl)
+	{
+		actx_error(actx, "failed to create libcurl handle");
+		return false;
+	}
+
+	/*
+	 * Multi-threaded applications must set CURLOPT_NOSIGNAL. This requires us
+	 * to handle the possibility of SIGPIPE ourselves using pq_block_sigpipe;
+	 * see pg_fe_run_oauth_flow().
+	 */
+	CHECK_SETOPT(actx, CURLOPT_NOSIGNAL, 1L, return false);
+	if (!curl_info->ares_num)
+	{
+		/* No alternative resolver, TODO: warn about timeouts */
+	}
+
+	if (actx->debugging)
+	{
+		/*
+		 * Set a callback for retrieving error information from libcurl, the
+		 * function only takes effect when CURLOPT_VERBOSE has been set so
+		 * make sure the order is kept.
+		 */
+		CHECK_SETOPT(actx, CURLOPT_DEBUGFUNCTION, debug_callback, return false);
+		CHECK_SETOPT(actx, CURLOPT_VERBOSE, 1L, return false);
+	}
+
+	CHECK_SETOPT(actx, CURLOPT_ERRORBUFFER, actx->curl_err, return false);
+
+	/*
+	 * Only HTTPS is allowed. (Debug mode additionally allows HTTP; this is
+	 * intended for testing only.)
+	 *
+	 * There's a bit of unfortunate complexity around the choice of
+	 * CURLoption. CURLOPT_PROTOCOLS is deprecated in modern Curls, but its
+	 * replacement didn't show up until relatively recently.
+	 */
+	{
+#if CURL_AT_LEAST_VERSION(7, 85, 0)
+		const CURLoption popt = CURLOPT_PROTOCOLS_STR;
+		const char *protos = "https";
+		const char *const unsafe = "https,http";
+#else
+		const CURLoption popt = CURLOPT_PROTOCOLS;
+		long		protos = CURLPROTO_HTTPS;
+		const long	unsafe = CURLPROTO_HTTPS | CURLPROTO_HTTP;
+#endif
+
+		if (actx->debugging)
+			protos = unsafe;
+
+		CHECK_SETOPT(actx, popt, protos, return false);
+	}
+
+	/* TODO: would anyone use this in "real" situations, or just testing? */
+	if (actx->debugging)
+	{
+		const char *env;
+
+		if ((env = getenv("PGOAUTHCAFILE")) != NULL)
+			CHECK_SETOPT(actx, CURLOPT_CAINFO, env, return false);
+	}
+
+	/*
+	 * Suppress the Accept header to make our request as minimal as possible.
+	 * (Ideally we would set it to "application/json" instead, but OpenID is
+	 * pretty strict when it comes to provider behavior, so we have to check
+	 * what comes back anyway.)
+	 */
+	actx->headers = curl_slist_append(actx->headers, "Accept:");
+	if (actx->headers == NULL)
+	{
+		actx_error(actx, "out of memory");
+		return false;
+	}
+	CHECK_SETOPT(actx, CURLOPT_HTTPHEADER, actx->headers, return false);
+
+	return true;
+}
+
+/*
+ * Generic HTTP Request Handlers
+ */
+
+/*
+ * Response callback from libcurl which appends the response body into
+ * actx->work_data (see start_request()). The maximum size of the data is
+ * defined by CURL_MAX_WRITE_SIZE which by default is 16kb (and can only be
+ * changed by recompiling libcurl).
+ */
+static size_t
+append_data(char *buf, size_t size, size_t nmemb, void *userdata)
+{
+	struct async_ctx *actx = userdata;
+	PQExpBuffer resp = &actx->work_data;
+	size_t		len = size * nmemb;
+
+	/* In case we receive data over the threshold, abort the transfer */
+	if ((resp->len + len) > MAX_OAUTH_RESPONSE_SIZE)
+	{
+		actx_error(actx, "response is too large");
+		return 0;
+	}
+
+	/* The data passed from libcurl is not null-terminated */
+	appendBinaryPQExpBuffer(resp, buf, len);
+
+	/*
+	 * Signal an error in order to abort the transfer in case we ran out of
+	 * memory in accepting the data.
+	 */
+	if (PQExpBufferBroken(resp))
+	{
+		actx_error(actx, "out of memory");
+		return 0;
+	}
+
+	return len;
+}
+
+/*
+ * Begins an HTTP request on the multi handle. The caller should have set up all
+ * request-specific options on actx->curl first. The server's response body will
+ * be accumulated in actx->work_data (which will be reset, so don't store
+ * anything important there across this call).
+ *
+ * Once a request is queued, it can be driven to completion via drive_request().
+ * If actx->running is zero upon return, the request has already finished and
+ * drive_request() can be called without returning control to the client.
+ */
+static bool
+start_request(struct async_ctx *actx)
+{
+	CURLMcode	err;
+
+	resetPQExpBuffer(&actx->work_data);
+	CHECK_SETOPT(actx, CURLOPT_WRITEFUNCTION, append_data, return false);
+	CHECK_SETOPT(actx, CURLOPT_WRITEDATA, actx, return false);
+
+	err = curl_multi_add_handle(actx->curlm, actx->curl);
+	if (err)
+	{
+		actx_error(actx, "failed to queue HTTP request: %s",
+				   curl_multi_strerror(err));
+		return false;
+	}
+
+	/*
+	 * actx->running tracks the number of running handles, so we can
+	 * immediately call back if no waiting is needed.
+	 *
+	 * Even though this is nominally an asynchronous process, there are some
+	 * operations that can synchronously fail by this point (e.g. connections
+	 * to closed local ports) or even synchronously succeed if the stars align
+	 * (all the libcurl connection caches hit and the server is fast).
+	 */
+	err = curl_multi_socket_action(actx->curlm, CURL_SOCKET_TIMEOUT, 0, &actx->running);
+	if (err)
+	{
+		actx_error(actx, "asynchronous HTTP request failed: %s",
+				   curl_multi_strerror(err));
+		return false;
+	}
+
+	return true;
+}
+
+/*
+ * CURL_IGNORE_DEPRECATION was added in 7.87.0. If it's not defined, we can make
+ * it a no-op.
+ */
+#ifndef CURL_IGNORE_DEPRECATION
+#define CURL_IGNORE_DEPRECATION(x) x
+#endif
+
+/*
+ * Drives the multi handle towards completion. The caller should have already
+ * set up an asynchronous request via start_request().
+ */
+static PostgresPollingStatusType
+drive_request(struct async_ctx *actx)
+{
+	CURLMcode	err;
+	CURLMsg    *msg;
+	int			msgs_left;
+	bool		done;
+
+	if (actx->running)
+	{
+		/*---
+		 * There's an async request in progress. Pump the multi handle.
+		 *
+		 * curl_multi_socket_all() is officially deprecated, because it's
+		 * inefficient and pointless if your event loop has already handed you
+		 * the exact sockets that are ready. But that's not our use case --
+		 * our client has no way to tell us which sockets are ready. (They
+		 * don't even know there are sockets to begin with.)
+		 *
+		 * We can grab the list of triggered events from the multiplexer
+		 * ourselves, but that's effectively what curl_multi_socket_all() is
+		 * going to do. And there are currently no plans for the Curl project
+		 * to remove or break this API, so ignore the deprecation. See
+		 *
+		 *    https://curl.se/mail/lib-2024-11/0028.html
+		 *
+		 */
+		CURL_IGNORE_DEPRECATION(
+			err = curl_multi_socket_all(actx->curlm, &actx->running);
+		)
+
+		if (err)
+		{
+			actx_error(actx, "asynchronous HTTP request failed: %s",
+					   curl_multi_strerror(err));
+			return PGRES_POLLING_FAILED;
+		}
+
+		if (actx->running)
+		{
+			/* We'll come back again. */
+			return PGRES_POLLING_READING;
+		}
+	}
+
+	done = false;
+	while ((msg = curl_multi_info_read(actx->curlm, &msgs_left)) != NULL)
+	{
+		if (msg->msg != CURLMSG_DONE)
+		{
+			/*
+			 * Future libcurl versions may define new message types; we don't
+			 * know how to handle them, so we'll ignore them.
+			 */
+			continue;
+		}
+
+		/* First check the status of the request itself. */
+		if (msg->data.result != CURLE_OK)
+		{
+			/*
+			 * If a more specific error hasn't already been reported, use
+			 * libcurl's description.
+			 */
+			if (actx->errbuf.len == 0)
+				actx_error_str(actx, curl_easy_strerror(msg->data.result));
+
+			return PGRES_POLLING_FAILED;
+		}
+
+		/* Now remove the finished handle; we'll add it back later if needed. */
+		err = curl_multi_remove_handle(actx->curlm, msg->easy_handle);
+		if (err)
+		{
+			actx_error(actx, "libcurl easy handle removal failed: %s",
+					   curl_multi_strerror(err));
+			return PGRES_POLLING_FAILED;
+		}
+
+		done = true;
+	}
+
+	/* Sanity check. */
+	if (!done)
+	{
+		actx_error(actx, "no result was retrieved for the finished handle");
+		return PGRES_POLLING_FAILED;
+	}
+
+	return PGRES_POLLING_OK;
+}
+
+/*
+ * URL-Encoding Helpers
+ */
+
+/*
+ * Encodes a string using the application/x-www-form-urlencoded format, and
+ * appends it to the given buffer.
+ */
+static void
+append_urlencoded(PQExpBuffer buf, const char *s)
+{
+	char	   *escaped;
+	char	   *haystack;
+	char	   *match;
+
+	escaped = curl_easy_escape(NULL, s, 0);
+	if (!escaped)
+	{
+		termPQExpBuffer(buf);	/* mark the buffer broken */
+		return;
+	}
+
+	/*
+	 * curl_easy_escape() almost does what we want, but we need the
+	 * query-specific flavor which uses '+' instead of '%20' for spaces. The
+	 * Curl command-line tool does this with a simple search-and-replace, so
+	 * follow its lead.
+	 */
+	haystack = escaped;
+
+	while ((match = strstr(haystack, "%20")) != NULL)
+	{
+		/* Append the unmatched portion, followed by the plus sign. */
+		appendBinaryPQExpBuffer(buf, haystack, match - haystack);
+		appendPQExpBufferChar(buf, '+');
+
+		/* Keep searching after the match. */
+		haystack = match + 3 /* strlen("%20") */ ;
+	}
+
+	/* Push the remainder of the string onto the buffer. */
+	appendPQExpBufferStr(buf, haystack);
+
+	curl_free(escaped);
+}
+
+/*
+ * Convenience wrapper for encoding a single string. Returns NULL on allocation
+ * failure.
+ */
+static char *
+urlencode(const char *s)
+{
+	PQExpBufferData buf;
+
+	initPQExpBuffer(&buf);
+	append_urlencoded(&buf, s);
+
+	return PQExpBufferDataBroken(buf) ? NULL : buf.data;
+}
+
+/*
+ * Appends a key/value pair to the end of an application/x-www-form-urlencoded
+ * list.
+ */
+static void
+build_urlencoded(PQExpBuffer buf, const char *key, const char *value)
+{
+	if (buf->len)
+		appendPQExpBufferChar(buf, '&');
+
+	append_urlencoded(buf, key);
+	appendPQExpBufferChar(buf, '=');
+	append_urlencoded(buf, value);
+}
+
+/*
+ * Specific HTTP Request Handlers
+ *
+ * This is finally the beginning of the actual application logic. Generally
+ * speaking, a single request consists of a start_* and a finish_* step, with
+ * drive_request() pumping the machine in between.
+ */
+
+/*
+ * Queue an OpenID Provider Configuration Request:
+ *
+ *     https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationRequest
+ *     https://www.rfc-editor.org/rfc/rfc8414#section-3.1
+ *
+ * This is done first to get the endpoint URIs we need to contact and to make
+ * sure the provider provides a device authorization flow. finish_discovery()
+ * will fill in actx->provider.
+ */
+static bool
+start_discovery(struct async_ctx *actx, const char *discovery_uri)
+{
+	CHECK_SETOPT(actx, CURLOPT_HTTPGET, 1L, return false);
+	CHECK_SETOPT(actx, CURLOPT_URL, discovery_uri, return false);
+
+	return start_request(actx);
+}
+
+static bool
+finish_discovery(struct async_ctx *actx)
+{
+	long		response_code;
+
+	/*----
+	 * Now check the response. OIDC Discovery 1.0 is pretty strict:
+	 *
+	 *     A successful response MUST use the 200 OK HTTP status code and
+	 *     return a JSON object using the application/json content type that
+	 *     contains a set of Claims as its members that are a subset of the
+	 *     Metadata values defined in Section 3.
+	 *
+	 * Compared to standard HTTP semantics, this makes life easy -- we don't
+	 * need to worry about redirections (which would call the Issuer host
+	 * validation into question), or non-authoritative responses, or any other
+	 * complications.
+	 */
+	CHECK_GETINFO(actx, CURLINFO_RESPONSE_CODE, &response_code, return false);
+
+	if (response_code != 200)
+	{
+		actx_error(actx, "unexpected response code %ld", response_code);
+		return false;
+	}
+
+	/*
+	 * Pull the fields we care about from the document.
+	 */
+	actx->errctx = "failed to parse OpenID discovery document";
+	if (!parse_provider(actx, &actx->provider))
+		return false;			/* error message already set */
+
+	/*
+	 * Fill in any defaults for OPTIONAL/RECOMMENDED fields we care about.
+	 */
+	if (!actx->provider.grant_types_supported)
+	{
+		/*
+		 * Per Section 3, the default is ["authorization_code", "implicit"].
+		 */
+		struct curl_slist *temp = actx->provider.grant_types_supported;
+
+		temp = curl_slist_append(temp, "authorization_code");
+		if (temp)
+		{
+			temp = curl_slist_append(temp, "implicit");
+		}
+
+		if (!temp)
+		{
+			actx_error(actx, "out of memory");
+			return false;
+		}
+
+		actx->provider.grant_types_supported = temp;
+	}
+
+	return true;
+}
+
+/*
+ * Ensure that the discovery document is provided by the expected issuer.
+ * Currently, issuers are statically configured in the connection string.
+ */
+static bool
+check_issuer(struct async_ctx *actx, PGconn *conn)
+{
+	const struct provider *provider = &actx->provider;
+
+	Assert(conn->oauth_issuer_id);	/* ensured by setup_oauth_parameters() */
+	Assert(provider->issuer);	/* ensured by parse_provider() */
+
+	/*---
+	 * We require strict equality for issuer identifiers -- no path or case
+	 * normalization, no substitution of default ports and schemes, etc. This
+	 * is done to match the rules in OIDC Discovery Sec. 4.3 for config
+	 * validation:
+	 *
+	 *    The issuer value returned MUST be identical to the Issuer URL that
+	 *    was used as the prefix to /.well-known/openid-configuration to
+	 *    retrieve the configuration information.
+	 *
+	 * as well as the rules set out in RFC 9207 for avoiding mix-up attacks:
+	 *
+	 *    Clients MUST then [...] compare the result to the issuer identifier
+	 *    of the authorization server where the authorization request was
+	 *    sent to. This comparison MUST use simple string comparison as defined
+	 *    in Section 6.2.1 of [RFC3986].
+	 *
+	 * TODO: Encoding support?
+	 */
+	if (strcmp(conn->oauth_issuer_id, provider->issuer) != 0)
+	{
+		actx_error(actx,
+				   "the issuer identifier (%s) does not match oauth_issuer (%s)",
+				   provider->issuer, conn->oauth_issuer_id);
+		return false;
+	}
+
+	return true;
+}
+
+#define HTTPS_SCHEME "https://"
+#define OAUTH_GRANT_TYPE_DEVICE_CODE "urn:ietf:params:oauth:grant-type:device_code"
+
+/*
+ * Ensure that the provider supports the Device Authorization flow (i.e. it
+ * provides an authorization endpoint, and both the token and authorization
+ * endpoint URLs seem reasonable).
+ */
+static bool
+check_for_device_flow(struct async_ctx *actx)
+{
+	const struct provider *provider = &actx->provider;
+
+	Assert(provider->issuer);	/* ensured by parse_provider() */
+	Assert(provider->token_endpoint);	/* ensured by parse_provider() */
+
+	if (!provider->device_authorization_endpoint)
+	{
+		actx_error(actx,
+				   "issuer \"%s\" does not provide a device authorization endpoint",
+				   provider->issuer);
+		return false;
+	}
+
+	/*
+	 * The original implementation checked that OAUTH_GRANT_TYPE_DEVICE_CODE
+	 * was present in the discovery document's grant_types_supported list. MS
+	 * Entra does not advertise this grant type, though, and since it doesn't
+	 * make sense to stand up a device_authorization_endpoint without also
+	 * accepting device codes at the token_endpoint, that's the only thing we
+	 * currently require.
+	 */
+
+	/*
+	 * Although libcurl will fail later if the URL contains an unsupported
+	 * scheme, that error message is going to be a bit opaque. This is a
+	 * decent time to bail out if we're not using HTTPS for the endpoints
+	 * we'll use for the flow.
+	 */
+	if (!actx->debugging)
+	{
+		if (pg_strncasecmp(provider->device_authorization_endpoint,
+						   HTTPS_SCHEME, strlen(HTTPS_SCHEME)) != 0)
+		{
+			actx_error(actx,
+					   "device authorization endpoint \"%s\" must use HTTPS",
+					   provider->device_authorization_endpoint);
+			return false;
+		}
+
+		if (pg_strncasecmp(provider->token_endpoint,
+						   HTTPS_SCHEME, strlen(HTTPS_SCHEME)) != 0)
+		{
+			actx_error(actx,
+					   "token endpoint \"%s\" must use HTTPS",
+					   provider->token_endpoint);
+			return false;
+		}
+	}
+
+	return true;
+}
+
+/*
+ * Adds the client ID (and secret, if provided) to the current request, using
+ * either HTTP headers or the request body.
+ */
+static bool
+add_client_identification(struct async_ctx *actx, PQExpBuffer reqbody, PGconn *conn)
+{
+	bool		success = false;
+	char	   *username = NULL;
+	char	   *password = NULL;
+
+	if (conn->oauth_client_secret)	/* Zero-length secrets are permitted! */
+	{
+		/*----
+		 * Use HTTP Basic auth to send the client_id and secret. Per RFC 6749,
+		 * Sec. 2.3.1,
+		 *
+		 *   Including the client credentials in the request-body using the
+		 *   two parameters is NOT RECOMMENDED and SHOULD be limited to
+		 *   clients unable to directly utilize the HTTP Basic authentication
+		 *   scheme (or other password-based HTTP authentication schemes).
+		 *
+		 * Additionally:
+		 *
+		 *   The client identifier is encoded using the
+		 *   "application/x-www-form-urlencoded" encoding algorithm per Appendix
+		 *   B, and the encoded value is used as the username; the client
+		 *   password is encoded using the same algorithm and used as the
+		 *   password.
+		 *
+		 * (Appendix B modifies application/x-www-form-urlencoded by requiring
+		 * an initial UTF-8 encoding step. Since the client ID and secret must
+		 * both be 7-bit ASCII -- RFC 6749 Appendix A -- we don't worry about
+		 * that in this function.)
+		 *
+		 * client_id is not added to the request body in this case. Not only
+		 * would it be redundant, but some providers in the wild (e.g. Okta)
+		 * refuse to accept it.
+		 */
+		username = urlencode(conn->oauth_client_id);
+		password = urlencode(conn->oauth_client_secret);
+
+		if (!username || !password)
+		{
+			actx_error(actx, "out of memory");
+			goto cleanup;
+		}
+
+		CHECK_SETOPT(actx, CURLOPT_HTTPAUTH, CURLAUTH_BASIC, goto cleanup);
+		CHECK_SETOPT(actx, CURLOPT_USERNAME, username, goto cleanup);
+		CHECK_SETOPT(actx, CURLOPT_PASSWORD, password, goto cleanup);
+
+		actx->used_basic_auth = true;
+	}
+	else
+	{
+		/*
+		 * If we're not otherwise authenticating, client_id is REQUIRED in the
+		 * request body.
+		 */
+		build_urlencoded(reqbody, "client_id", conn->oauth_client_id);
+
+		CHECK_SETOPT(actx, CURLOPT_HTTPAUTH, CURLAUTH_NONE, goto cleanup);
+		actx->used_basic_auth = false;
+	}
+
+	success = true;
+
+cleanup:
+	free(username);
+	free(password);
+
+	return success;
+}
+
+/*
+ * Queue a Device Authorization Request:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc8628#section-3.1
+ *
+ * This is the second step. We ask the provider to verify the end user out of
+ * band and authorize us to act on their behalf; it will give us the required
+ * nonces for us to later poll the request status, which we'll grab in
+ * finish_device_authz().
+ */
+static bool
+start_device_authz(struct async_ctx *actx, PGconn *conn)
+{
+	const char *device_authz_uri = actx->provider.device_authorization_endpoint;
+	PQExpBuffer work_buffer = &actx->work_data;
+
+	Assert(conn->oauth_client_id);	/* ensured by setup_oauth_parameters() */
+	Assert(device_authz_uri);	/* ensured by check_for_device_flow() */
+
+	/* Construct our request body. */
+	resetPQExpBuffer(work_buffer);
+	if (conn->oauth_scope && conn->oauth_scope[0])
+		build_urlencoded(work_buffer, "scope", conn->oauth_scope);
+
+	if (!add_client_identification(actx, work_buffer, conn))
+		return false;
+
+	if (PQExpBufferBroken(work_buffer))
+	{
+		actx_error(actx, "out of memory");
+		return false;
+	}
+
+	/* Make our request. */
+	CHECK_SETOPT(actx, CURLOPT_URL, device_authz_uri, return false);
+	CHECK_SETOPT(actx, CURLOPT_COPYPOSTFIELDS, work_buffer->data, return false);
+
+	return start_request(actx);
+}
+
+static bool
+finish_device_authz(struct async_ctx *actx)
+{
+	long		response_code;
+
+	CHECK_GETINFO(actx, CURLINFO_RESPONSE_CODE, &response_code, return false);
+
+	/*
+	 * Per RFC 8628, Section 3, a successful device authorization response
+	 * uses 200 OK.
+	 */
+	if (response_code == 200)
+	{
+		actx->errctx = "failed to parse device authorization";
+		if (!parse_device_authz(actx, &actx->authz))
+			return false;		/* error message already set */
+
+		return true;
+	}
+
+	/*
+	 * The device authorization endpoint uses the same error response as the
+	 * token endpoint, so the error handling roughly follows
+	 * finish_token_request(). The key difference is that an error here is
+	 * immediately fatal.
+	 */
+	if (response_code == 400 || response_code == 401)
+	{
+		struct token_error err = {0};
+
+		if (!parse_token_error(actx, &err))
+		{
+			free_token_error(&err);
+			return false;
+		}
+
+		record_token_error(actx, &err);
+
+		free_token_error(&err);
+		return false;
+	}
+
+	/* Any other response codes are considered invalid */
+	actx_error(actx, "unexpected response code %ld", response_code);
+	return false;
+}
+
+/*
+ * Queue an Access Token Request:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc6749#section-4.1.3
+ *
+ * This is the final step. We continually poll the token endpoint to see if the
+ * user has authorized us yet. finish_token_request() will pull either the token
+ * or a (ideally temporary) error status from the provider.
+ */
+static bool
+start_token_request(struct async_ctx *actx, PGconn *conn)
+{
+	const char *token_uri = actx->provider.token_endpoint;
+	const char *device_code = actx->authz.device_code;
+	PQExpBuffer work_buffer = &actx->work_data;
+
+	Assert(conn->oauth_client_id);	/* ensured by setup_oauth_parameters() */
+	Assert(token_uri);			/* ensured by parse_provider() */
+	Assert(device_code);		/* ensured by parse_device_authz() */
+
+	/* Construct our request body. */
+	resetPQExpBuffer(work_buffer);
+	build_urlencoded(work_buffer, "device_code", device_code);
+	build_urlencoded(work_buffer, "grant_type", OAUTH_GRANT_TYPE_DEVICE_CODE);
+
+	if (!add_client_identification(actx, work_buffer, conn))
+		return false;
+
+	if (PQExpBufferBroken(work_buffer))
+	{
+		actx_error(actx, "out of memory");
+		return false;
+	}
+
+	/* Make our request. */
+	CHECK_SETOPT(actx, CURLOPT_URL, token_uri, return false);
+	CHECK_SETOPT(actx, CURLOPT_COPYPOSTFIELDS, work_buffer->data, return false);
+
+	return start_request(actx);
+}
+
+static bool
+finish_token_request(struct async_ctx *actx, struct token *tok)
+{
+	long		response_code;
+
+	CHECK_GETINFO(actx, CURLINFO_RESPONSE_CODE, &response_code, return false);
+
+	/*
+	 * Per RFC 6749, Section 5, a successful response uses 200 OK.
+	 */
+	if (response_code == 200)
+	{
+		actx->errctx = "failed to parse access token response";
+		if (!parse_access_token(actx, tok))
+			return false;		/* error message already set */
+
+		return true;
+	}
+
+	/*
+	 * An error response uses either 400 Bad Request or 401 Unauthorized.
+	 * There are references online to implementations using 403 for error
+	 * return which would violate the specification. For now we stick to the
+	 * specification but we might have to revisit this.
+	 */
+	if (response_code == 400 || response_code == 401)
+	{
+		if (!parse_token_error(actx, &tok->err))
+			return false;
+
+		return true;
+	}
+
+	/* Any other response codes are considered invalid */
+	actx_error(actx, "unexpected response code %ld", response_code);
+	return false;
+}
+
+/*
+ * Finishes the token request and examines the response. If the flow has
+ * completed, a valid token will be returned via the parameter list. Otherwise,
+ * the token parameter remains unchanged, and the caller needs to wait for
+ * another interval (which will have been increased in response to a slow_down
+ * message from the server) before starting a new token request.
+ *
+ * False is returned only for permanent error conditions.
+ */
+static bool
+handle_token_response(struct async_ctx *actx, char **token)
+{
+	bool		success = false;
+	struct token tok = {0};
+	const struct token_error *err;
+
+	if (!finish_token_request(actx, &tok))
+		goto token_cleanup;
+
+	/* A successful token request gives either a token or an in-band error. */
+	Assert(tok.access_token || tok.err.error);
+
+	if (tok.access_token)
+	{
+		*token = tok.access_token;
+		tok.access_token = NULL;
+
+		success = true;
+		goto token_cleanup;
+	}
+
+	/*
+	 * authorization_pending and slow_down are the only acceptable errors;
+	 * anything else and we bail. These are defined in RFC 8628, Sec. 3.5.
+	 */
+	err = &tok.err;
+	if (strcmp(err->error, "authorization_pending") != 0 &&
+		strcmp(err->error, "slow_down") != 0)
+	{
+		record_token_error(actx, err);
+		goto token_cleanup;
+	}
+
+	/*
+	 * A slow_down error requires us to permanently increase our retry
+	 * interval by five seconds.
+	 */
+	if (strcmp(err->error, "slow_down") == 0)
+	{
+		int			prev_interval = actx->authz.interval;
+
+		actx->authz.interval += 5;
+		if (actx->authz.interval < prev_interval)
+		{
+			actx_error(actx, "slow_down interval overflow");
+			goto token_cleanup;
+		}
+	}
+
+	success = true;
+
+token_cleanup:
+	free_token(&tok);
+	return success;
+}
+
+/*
+ * Displays a device authorization prompt for action by the end user, either via
+ * the PQauthDataHook, or by a message on standard error if no hook is set.
+ */
+static bool
+prompt_user(struct async_ctx *actx, PGconn *conn)
+{
+	int			res;
+	PGpromptOAuthDevice prompt = {
+		.verification_uri = actx->authz.verification_uri,
+		.user_code = actx->authz.user_code,
+		/* TODO: optional fields */
+	};
+
+	res = PQauthDataHook(PQAUTHDATA_PROMPT_OAUTH_DEVICE, conn, &prompt);
+
+	if (!res)
+	{
+		/*
+		 * translator: The first %s is a URL for the user to visit in a
+		 * browser, and the second %s is a code to be copy-pasted there.
+		 */
+		fprintf(stderr, libpq_gettext("Visit %s and enter the code: %s\n"),
+				prompt.verification_uri, prompt.user_code);
+	}
+	else if (res < 0)
+	{
+		actx_error(actx, "device prompt failed");
+		return false;
+	}
+
+	return true;
+}
+
+/*
+ * Calls curl_global_init() in a thread-safe way.
+ *
+ * libcurl has stringent requirements for the thread context in which you call
+ * curl_global_init(), because it's going to try initializing a bunch of other
+ * libraries (OpenSSL, Winsock, etc). Recent versions of libcurl have improved
+ * the thread-safety situation, but there's a chicken-and-egg problem at
+ * runtime: you can't check the thread safety until you've initialized libcurl,
+ * which you can't do from within a thread unless you know it's thread-safe...
+ *
+ * Returns true if initialization was successful. Successful or not, this
+ * function will not try to reinitialize Curl on successive calls.
+ */
+static bool
+initialize_curl(PGconn *conn)
+{
+	/*
+	 * Don't let the compiler play tricks with this variable. In the
+	 * HAVE_THREADSAFE_CURL_GLOBAL_INIT case, we don't care if two threads
+	 * enter simultaneously, but we do care if this gets set transiently to
+	 * PG_BOOL_YES/NO in cases where that's not the final answer.
+	 */
+	static volatile PGTernaryBool init_successful = PG_BOOL_UNKNOWN;
+#if HAVE_THREADSAFE_CURL_GLOBAL_INIT
+	curl_version_info_data *info;
+#endif
+
+#if !HAVE_THREADSAFE_CURL_GLOBAL_INIT
+
+	/*
+	 * Lock around the whole function. If a libpq client performs its own work
+	 * with libcurl, it must either ensure that Curl is initialized safely
+	 * before calling us (in which case our call will be a no-op), or else it
+	 * must guard its own calls to curl_global_init() with a registered
+	 * threadlock handler. See PQregisterThreadLock().
+	 */
+	pglock_thread();
+#endif
+
+	/*
+	 * Skip initialization if we've already done it. (Curl tracks the number
+	 * of calls; there's no point in incrementing the counter every time we
+	 * connect.)
+	 */
+	if (init_successful == PG_BOOL_YES)
+		goto done;
+	else if (init_successful == PG_BOOL_NO)
+	{
+		libpq_append_conn_error(conn,
+								"curl_global_init previously failed during OAuth setup");
+		goto done;
+	}
+
+	/*
+	 * We know we've already initialized Winsock by this point (see
+	 * pqMakeEmptyPGconn()), so we should be able to safely skip that bit. But
+	 * we have to tell libcurl to initialize everything else, because other
+	 * pieces of our client executable may already be using libcurl for their
+	 * own purposes. If we initialize libcurl with only a subset of its
+	 * features, we could break those other clients nondeterministically, and
+	 * that would probably be a nightmare to debug.
+	 *
+	 * If some other part of the program has already called this, it's a
+	 * no-op.
+	 */
+	if (curl_global_init(CURL_GLOBAL_ALL & ~CURL_GLOBAL_WIN32) != CURLE_OK)
+	{
+		libpq_append_conn_error(conn,
+								"curl_global_init failed during OAuth setup");
+		init_successful = PG_BOOL_NO;
+		goto done;
+	}
+
+#if HAVE_THREADSAFE_CURL_GLOBAL_INIT
+
+	/*
+	 * If we determined at configure time that the Curl installation is
+	 * threadsafe, our job here is much easier. We simply initialize above
+	 * without any locking (concurrent or duplicated calls are fine in that
+	 * situation), then double-check to make sure the runtime setting agrees,
+	 * to try to catch silent downgrades.
+	 */
+	info = curl_version_info(CURLVERSION_NOW);
+	if (!(info->features & CURL_VERSION_THREADSAFE))
+	{
+		/*
+		 * 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 threadsafe\n"
+								"\tCurl initialization was reported threadsafe 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;
+	}
+#endif
+
+	init_successful = PG_BOOL_YES;
+
+done:
+#if !HAVE_THREADSAFE_CURL_GLOBAL_INIT
+	pgunlock_thread();
+#endif
+	return (init_successful == PG_BOOL_YES);
+}
+
+/*
+ * The core nonblocking libcurl implementation. This will be called several
+ * times to pump the async engine.
+ *
+ * The architecture is based on PQconnectPoll(). The first half drives the
+ * connection state forward as necessary, returning if we're not ready to
+ * proceed to the next step yet. The second half performs the actual transition
+ * between states.
+ *
+ * You can trace the overall OAuth flow through the second half. It's linear
+ * until we get to the end, where we flip back and forth between
+ * OAUTH_STEP_TOKEN_REQUEST and OAUTH_STEP_WAIT_INTERVAL to regularly ping the
+ * provider.
+ */
+static PostgresPollingStatusType
+pg_fe_run_oauth_flow_impl(PGconn *conn)
+{
+	fe_oauth_state *state = conn->sasl_state;
+	struct async_ctx *actx;
+
+	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;
+#ifdef HAVE_SYS_EPOLL_H
+		actx->timerfd = -1;
+#endif
+
+		/* 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. */
+		conn->altsock = actx->mux;
+
+		switch (actx->step)
+		{
+			case OAUTH_STEP_INIT:
+				break;
+
+			case OAUTH_STEP_DISCOVERY:
+			case OAUTH_STEP_DEVICE_AUTHORIZATION:
+			case OAUTH_STEP_TOKEN_REQUEST:
+				{
+					PostgresPollingStatusType status;
+
+					status = drive_request(actx);
+
+					if (status == PGRES_POLLING_FAILED)
+						goto error_return;
+					else if (status != PGRES_POLLING_OK)
+					{
+						/* not done yet */
+						return status;
+					}
+				}
+
+			case OAUTH_STEP_WAIT_INTERVAL:
+				/* TODO check that the timer has expired */
+				break;
+		}
+
+		/*
+		 * Each case here must ensure that actx->running is set while we're
+		 * waiting on some asynchronous work. Most cases rely on
+		 * start_request() to do that for them.
+		 */
+		switch (actx->step)
+		{
+			case OAUTH_STEP_INIT:
+				actx->errctx = "failed to fetch OpenID discovery document";
+				if (!start_discovery(actx, conn->oauth_discovery_uri))
+					goto error_return;
+
+				actx->step = OAUTH_STEP_DISCOVERY;
+				break;
+
+			case OAUTH_STEP_DISCOVERY:
+				if (!finish_discovery(actx))
+					goto error_return;
+
+				if (!check_issuer(actx, conn))
+					goto error_return;
+
+				actx->errctx = "cannot run OAuth device authorization";
+				if (!check_for_device_flow(actx))
+					goto error_return;
+
+				actx->errctx = "failed to obtain device authorization";
+				if (!start_device_authz(actx, conn))
+					goto error_return;
+
+				actx->step = OAUTH_STEP_DEVICE_AUTHORIZATION;
+				break;
+
+			case OAUTH_STEP_DEVICE_AUTHORIZATION:
+				if (!finish_device_authz(actx))
+					goto error_return;
+
+				actx->errctx = "failed to obtain access token";
+				if (!start_token_request(actx, conn))
+					goto error_return;
+
+				actx->step = OAUTH_STEP_TOKEN_REQUEST;
+				break;
+
+			case OAUTH_STEP_TOKEN_REQUEST:
+				if (!handle_token_response(actx, &conn->oauth_token))
+					goto error_return;
+
+				if (!actx->user_prompted)
+				{
+					/*
+					 * Now that we know the token endpoint isn't broken, give
+					 * the user the login instructions.
+					 */
+					if (!prompt_user(actx, conn))
+						goto error_return;
+
+					actx->user_prompted = true;
+				}
+
+				if (conn->oauth_token)
+					break;		/* done! */
+
+				/*
+				 * Wait for the required interval before issuing the next
+				 * request.
+				 */
+				if (!set_timer(actx, actx->authz.interval * 1000))
+					goto error_return;
+
+#ifdef HAVE_SYS_EPOLL_H
+
+				/*
+				 * No Curl requests are running, so we can simplify by having
+				 * the client wait directly on the timerfd rather than the
+				 * multiplexer. (This isn't possible for kqueue.)
+				 */
+				conn->altsock = actx->timerfd;
+#endif
+
+				actx->step = OAUTH_STEP_WAIT_INTERVAL;
+				actx->running = 1;
+				break;
+
+			case OAUTH_STEP_WAIT_INTERVAL:
+				actx->errctx = "failed to obtain access token";
+				if (!start_token_request(actx, conn))
+					goto error_return;
+
+				actx->step = OAUTH_STEP_TOKEN_REQUEST;
+				break;
+		}
+
+		/*
+		 * The vast majority of the time, if we don't have a token at this
+		 * point, actx->running will be set. But there are some corner cases
+		 * where we can immediately loop back around; see start_request().
+		 */
+	} while (!conn->oauth_token && !actx->running);
+
+	/* If we've stored a token, we're done. Otherwise come back later. */
+	return conn->oauth_token ? PGRES_POLLING_OK : PGRES_POLLING_READING;
+
+error_return:
+
+	/*
+	 * Assemble the three parts of our error: context, body, and detail. See
+	 * also the documentation for struct async_ctx.
+	 */
+	if (actx->errctx)
+	{
+		appendPQExpBufferStr(&conn->errorMessage,
+							 libpq_gettext(actx->errctx));
+		appendPQExpBufferStr(&conn->errorMessage, ": ");
+	}
+
+	if (PQExpBufferDataBroken(actx->errbuf))
+		appendPQExpBufferStr(&conn->errorMessage,
+							 libpq_gettext("out of memory"));
+	else
+		appendPQExpBufferStr(&conn->errorMessage, actx->errbuf.data);
+
+	if (actx->curl_err[0])
+	{
+		size_t		len;
+
+		appendPQExpBuffer(&conn->errorMessage, " (%s)", actx->curl_err);
+
+		/* Sometimes libcurl adds a newline to the error buffer. :( */
+		len = conn->errorMessage.len;
+		if (len >= 2 && conn->errorMessage.data[len - 2] == '\n')
+		{
+			conn->errorMessage.data[len - 2] = ')';
+			conn->errorMessage.data[len - 1] = '\0';
+			conn->errorMessage.len--;
+		}
+	}
+
+	appendPQExpBufferStr(&conn->errorMessage, "\n");
+
+	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.
+ */
+PostgresPollingStatusType
+pg_fe_run_oauth_flow(PGconn *conn)
+{
+	PostgresPollingStatusType result;
+#ifndef WIN32
+	sigset_t	osigset;
+	bool		sigpipe_pending;
+	bool		masked;
+
+	/*---
+	 * Ignore SIGPIPE on this thread during all Curl processing.
+	 *
+	 * Because we support multiple threads, we have to set up libcurl with
+	 * CURLOPT_NOSIGNAL, which disables its default global handling of
+	 * SIGPIPE. From the Curl docs:
+	 *
+	 *     libcurl makes an effort to never cause such SIGPIPE signals to
+	 *     trigger, but some operating systems have no way to avoid them and
+	 *     even on those that have there are some corner cases when they may
+	 *     still happen, contrary to our desire.
+	 *
+	 * Note that libcurl is also at the mercy of its DNS resolution and SSL
+	 * libraries; if any of them forget a MSG_NOSIGNAL then we're in trouble.
+	 * Modern platforms and libraries seem to get it right, so this is a
+	 * difficult corner case to exercise in practice, and unfortunately it's
+	 * not really clear whether it's necessary in all cases.
+	 */
+	masked = (pq_block_sigpipe(&osigset, &sigpipe_pending) == 0);
+#endif
+
+	result = pg_fe_run_oauth_flow_impl(conn);
+
+#ifndef WIN32
+	if (masked)
+	{
+		/*
+		 * Undo the SIGPIPE mask. Assume we may have gotten EPIPE (we have no
+		 * way of knowing at this level).
+		 */
+		pq_reset_sigpipe(&osigset, sigpipe_pending, true /* EPIPE, maybe */ );
+	}
+#endif
+
+	return result;
+}
diff --git a/src/interfaces/libpq/fe-auth-oauth.c b/src/interfaces/libpq/fe-auth-oauth.c
new file mode 100644
index 0000000000..cc53e2bdd1
--- /dev/null
+++ b/src/interfaces/libpq/fe-auth-oauth.c
@@ -0,0 +1,1141 @@
+/*-------------------------------------------------------------------------
+ *
+ * fe-auth-oauth.c
+ *	   The front-end (client) implementation of OAuth/OIDC authentication
+ *	   using the SASL OAUTHBEARER mechanism.
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/interfaces/libpq/fe-auth-oauth.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres_fe.h"
+
+#include "common/base64.h"
+#include "common/hmac.h"
+#include "common/jsonapi.h"
+#include "common/oauth-common.h"
+#include "fe-auth.h"
+#include "fe-auth-oauth.h"
+#include "mb/pg_wchar.h"
+
+/* The exported OAuth callback mechanism. */
+static void *oauth_init(PGconn *conn, const char *password,
+						const char *sasl_mechanism);
+static SASLStatus oauth_exchange(void *opaq, bool final,
+								 char *input, int inputlen,
+								 char **output, int *outputlen);
+static bool oauth_channel_bound(void *opaq);
+static void oauth_free(void *opaq);
+
+const pg_fe_sasl_mech pg_oauth_mech = {
+	oauth_init,
+	oauth_exchange,
+	oauth_channel_bound,
+	oauth_free,
+};
+
+/*
+ * Initializes mechanism state for OAUTHBEARER.
+ *
+ * For a full description of the API, see libpq/fe-auth-sasl.h.
+ */
+static void *
+oauth_init(PGconn *conn, const char *password,
+		   const char *sasl_mechanism)
+{
+	fe_oauth_state *state;
+
+	/*
+	 * We only support one SASL mechanism here; anything else is programmer
+	 * error.
+	 */
+	Assert(sasl_mechanism != NULL);
+	Assert(strcmp(sasl_mechanism, OAUTHBEARER_NAME) == 0);
+
+	state = calloc(1, sizeof(*state));
+	if (!state)
+		return NULL;
+
+	state->step = FE_OAUTH_INIT;
+	state->conn = conn;
+
+	return state;
+}
+
+/*
+ * Frees the state allocated by oauth_init().
+ *
+ * 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().
+ */
+static void
+oauth_free(void *opaq)
+{
+	fe_oauth_state *state = opaq;
+
+	/* Any async authentication state should have been cleaned up already. */
+	Assert(!state->async_ctx);
+
+	free(state);
+}
+
+#define kvsep "\x01"
+
+/*
+ * Constructs an OAUTHBEARER client initial response (RFC 7628, Sec. 3.1).
+ *
+ * If discover is true, the initial response will contain a request for the
+ * server's required OAuth parameters (Sec. 4.3). Otherwise, conn->token must
+ * be set; it will be sent as the connection's bearer token.
+ *
+ * Returns the response as a null-terminated string, or NULL on error.
+ */
+static char *
+client_initial_response(PGconn *conn, bool discover)
+{
+	static const char *const resp_format = "n,," kvsep "auth=%s%s" kvsep kvsep;
+
+	PQExpBufferData buf;
+	const char *authn_scheme;
+	char	   *response = NULL;
+	const char *token = conn->oauth_token;
+
+	if (discover)
+	{
+		/* Parameter discovery uses a completely empty auth value. */
+		authn_scheme = token = "";
+	}
+	else
+	{
+		/*
+		 * Use a Bearer authentication scheme (RFC 6750, Sec. 2.1). A trailing
+		 * space is used as a separator.
+		 */
+		authn_scheme = "Bearer ";
+
+		/* conn->token must have been set in this case. */
+		if (!token)
+		{
+			Assert(false);
+			libpq_append_conn_error(conn,
+									"internal error: no OAuth token was set for the connection");
+			return NULL;
+		}
+	}
+
+	initPQExpBuffer(&buf);
+	appendPQExpBuffer(&buf, resp_format, authn_scheme, token);
+
+	if (!PQExpBufferDataBroken(buf))
+		response = strdup(buf.data);
+	termPQExpBuffer(&buf);
+
+	if (!response)
+		libpq_append_conn_error(conn, "out of memory");
+
+	return response;
+}
+
+/*
+ * JSON Parser (for the OAUTHBEARER error result)
+ */
+
+/* Relevant JSON fields in the error result object. */
+#define ERROR_STATUS_FIELD "status"
+#define ERROR_SCOPE_FIELD "scope"
+#define ERROR_OPENID_CONFIGURATION_FIELD "openid-configuration"
+
+struct json_ctx
+{
+	char	   *errmsg;			/* any non-NULL value stops all processing */
+	PQExpBufferData errbuf;		/* backing memory for errmsg */
+	int			nested;			/* nesting level (zero is the top) */
+
+	const char *target_field_name;	/* points to a static allocation */
+	char	  **target_field;	/* see below */
+
+	/* target_field, if set, points to one of the following: */
+	char	   *status;
+	char	   *scope;
+	char	   *discovery_uri;
+};
+
+#define oauth_json_has_error(ctx) \
+	(PQExpBufferDataBroken((ctx)->errbuf) || (ctx)->errmsg)
+
+#define oauth_json_set_error(ctx, ...) \
+	do { \
+		appendPQExpBuffer(&(ctx)->errbuf, __VA_ARGS__); \
+		(ctx)->errmsg = (ctx)->errbuf.data; \
+	} while (0)
+
+static JsonParseErrorType
+oauth_json_object_start(void *state)
+{
+	struct json_ctx *ctx = state;
+
+	if (ctx->target_field)
+	{
+		Assert(ctx->nested == 1);
+
+		oauth_json_set_error(ctx,
+							 libpq_gettext("field \"%s\" must be a string"),
+							 ctx->target_field_name);
+	}
+
+	++ctx->nested;
+	return oauth_json_has_error(ctx) ? JSON_SEM_ACTION_FAILED : JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_object_end(void *state)
+{
+	struct json_ctx *ctx = state;
+
+	--ctx->nested;
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_object_field_start(void *state, char *name, bool isnull)
+{
+	struct json_ctx *ctx = state;
+
+	if (ctx->nested == 1)
+	{
+		if (strcmp(name, ERROR_STATUS_FIELD) == 0)
+		{
+			ctx->target_field_name = ERROR_STATUS_FIELD;
+			ctx->target_field = &ctx->status;
+		}
+		else if (strcmp(name, ERROR_SCOPE_FIELD) == 0)
+		{
+			ctx->target_field_name = ERROR_SCOPE_FIELD;
+			ctx->target_field = &ctx->scope;
+		}
+		else if (strcmp(name, ERROR_OPENID_CONFIGURATION_FIELD) == 0)
+		{
+			ctx->target_field_name = ERROR_OPENID_CONFIGURATION_FIELD;
+			ctx->target_field = &ctx->discovery_uri;
+		}
+	}
+
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_array_start(void *state)
+{
+	struct json_ctx *ctx = state;
+
+	if (!ctx->nested)
+	{
+		ctx->errmsg = libpq_gettext("top-level element must be an object");
+	}
+	else if (ctx->target_field)
+	{
+		Assert(ctx->nested == 1);
+
+		oauth_json_set_error(ctx,
+							 libpq_gettext("field \"%s\" must be a string"),
+							 ctx->target_field_name);
+	}
+
+	return oauth_json_has_error(ctx) ? JSON_SEM_ACTION_FAILED : JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_scalar(void *state, char *token, JsonTokenType type)
+{
+	struct json_ctx *ctx = state;
+
+	if (!ctx->nested)
+	{
+		ctx->errmsg = libpq_gettext("top-level element must be an object");
+		return JSON_SEM_ACTION_FAILED;
+	}
+
+	if (ctx->target_field)
+	{
+		Assert(ctx->nested == 1);
+
+		/*
+		 * We don't allow duplicate field names; error out if the target has
+		 * already been set.
+		 */
+		if (*ctx->target_field)
+		{
+			oauth_json_set_error(ctx,
+								 libpq_gettext("field \"%s\" is duplicated"),
+								 ctx->target_field_name);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		/* The only fields we support are strings. */
+		if (type != JSON_TOKEN_STRING)
+		{
+			oauth_json_set_error(ctx,
+								 libpq_gettext("field \"%s\" must be a string"),
+								 ctx->target_field_name);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		*ctx->target_field = strdup(token);
+		if (!*ctx->target_field)
+			return JSON_OUT_OF_MEMORY;
+
+		ctx->target_field = NULL;
+		ctx->target_field_name = NULL;
+	}
+	else
+	{
+		/* otherwise we just ignore it */
+	}
+
+	return JSON_SUCCESS;
+}
+
+#define HTTPS_SCHEME "https://"
+#define HTTP_SCHEME "http://"
+
+/* We support both well-known suffixes defined by RFC 8414. */
+#define WK_PREFIX "/.well-known/"
+#define OPENID_WK_SUFFIX "openid-configuration"
+#define OAUTH_WK_SUFFIX "oauth-authorization-server"
+
+/*
+ * Derives an issuer identifier from one of our recognized .well-known URIs,
+ * using the rules in RFC 8414.
+ */
+static char *
+issuer_from_well_known_uri(PGconn *conn, const char *wkuri)
+{
+	const char *authority_start = NULL;
+	const char *wk_start;
+	const char *wk_end;
+	char	   *issuer;
+	ptrdiff_t	start_offset,
+				end_offset;
+	size_t		end_len;
+
+	/*
+	 * https:// is required for issuer identifiers (RFC 8414, Sec. 2; OIDC
+	 * Discovery 1.0, Sec. 3). This is a case-insensitive comparison at this
+	 * level (but issuer identifier comparison at the level above this is
+	 * case-sensitive, so in practice it's probably moot).
+	 */
+	if (pg_strncasecmp(wkuri, HTTPS_SCHEME, strlen(HTTPS_SCHEME)) == 0)
+		authority_start = wkuri + strlen(HTTPS_SCHEME);
+
+	if (!authority_start
+		&& oauth_unsafe_debugging_enabled()
+		&& pg_strncasecmp(wkuri, HTTP_SCHEME, strlen(HTTP_SCHEME)) == 0)
+	{
+		/* Allow http:// for testing only. */
+		authority_start = wkuri + strlen(HTTP_SCHEME);
+	}
+
+	if (!authority_start)
+	{
+		libpq_append_conn_error(conn,
+								"OAuth discovery URI \"%s\" must use HTTPS",
+								wkuri);
+		return NULL;
+	}
+
+	/*
+	 * Well-known URIs in general may support queries and fragments, but the
+	 * two types we support here do not. (They must be constructed from the
+	 * components of issuer identifiers, which themselves may not contain any
+	 * queries or fragments.)
+	 *
+	 * It's important to check this first, to avoid getting tricked later by a
+	 * prefix buried inside a query or fragment.
+	 */
+	if (strpbrk(authority_start, "?#") != NULL)
+	{
+		libpq_append_conn_error(conn,
+								"OAuth discovery URI \"%s\" must not contain query or fragment components",
+								wkuri);
+		return NULL;
+	}
+
+	/*
+	 * Find the start of the .well-known prefix. IETF rules (RFC 8615) state
+	 * this must be at the beginning of the path component, but OIDC defined
+	 * it at the end instead (OIDC Discovery 1.0, Sec. 4), so we have to
+	 * search for it anywhere.
+	 */
+	wk_start = strstr(authority_start, WK_PREFIX);
+	if (!wk_start)
+	{
+		libpq_append_conn_error(conn,
+								"OAuth discovery URI \"%s\" is not a .well-known URI",
+								wkuri);
+		return NULL;
+	}
+
+	/*
+	 * Now find the suffix type. We only support the two defined in OIDC
+	 * Discovery 1.0 and RFC 8414.
+	 */
+	wk_end = wk_start + strlen(WK_PREFIX);
+
+	if (strncmp(wk_end, OPENID_WK_SUFFIX, strlen(OPENID_WK_SUFFIX)) == 0)
+		wk_end += strlen(OPENID_WK_SUFFIX);
+	else if (strncmp(wk_end, OAUTH_WK_SUFFIX, strlen(OAUTH_WK_SUFFIX)) == 0)
+		wk_end += strlen(OAUTH_WK_SUFFIX);
+	else
+		wk_end = NULL;
+
+	/*
+	 * Even if there's a match, we still need to check to make sure the suffix
+	 * takes up the entire path segment, to weed out constructions like
+	 * "/.well-known/openid-configuration-bad".
+	 */
+	if (!wk_end || (*wk_end != '/' && *wk_end != '\0'))
+	{
+		libpq_append_conn_error(conn,
+								"OAuth discovery URI \"%s\" uses an unsupported .well-known suffix",
+								wkuri);
+		return NULL;
+	}
+
+	/*
+	 * Finally, make sure the .well-known components are provided either as a
+	 * prefix (IETF style) or as a postfix (OIDC style). In other words,
+	 * "https://localhost/a/.well-known/openid-configuration/b" is not allowed
+	 * to claim association with "https://localhost/a/b".
+	 */
+	if (*wk_end != '\0')
+	{
+		/*
+		 * It's not at the end, so it's required to be at the beginning at the
+		 * path. Find the starting slash.
+		 */
+		const char *path_start;
+
+		path_start = strchr(authority_start, '/');
+		Assert(path_start);		/* otherwise we wouldn't have found WK_PREFIX */
+
+		if (wk_start != path_start)
+		{
+			libpq_append_conn_error(conn,
+									"OAuth discovery URI \"%s\" uses an invalid format",
+									wkuri);
+			return NULL;
+		}
+	}
+
+	/* Checks passed! Now build the issuer. */
+	issuer = strdup(wkuri);
+	if (!issuer)
+	{
+		libpq_append_conn_error(conn, "out of memory");
+		return NULL;
+	}
+
+	/*
+	 * The .well-known components are from [wk_start, wk_end). Remove those to
+	 * form the issuer ID, by shifting the path suffix (which may be empty)
+	 * leftwards.
+	 */
+	start_offset = wk_start - wkuri;
+	end_offset = wk_end - wkuri;
+	end_len = strlen(wk_end) + 1;	/* move the NULL terminator too */
+
+	memmove(issuer + start_offset, issuer + end_offset, end_len);
+
+	return issuer;
+}
+
+/*
+ * Parses the server error result (RFC 7628, Sec. 3.2.2) contained in msg and
+ * stores any discovered openid_configuration and scope settings for the
+ * connection.
+ */
+static bool
+handle_oauth_sasl_error(PGconn *conn, const char *msg, int msglen)
+{
+	JsonLexContext lex = {0};
+	JsonSemAction sem = {0};
+	JsonParseErrorType err;
+	struct json_ctx ctx = {0};
+	char	   *errmsg = NULL;
+	bool		success = false;
+
+	Assert(conn->oauth_issuer_id);	/* ensured by setup_oauth_parameters() */
+
+	/* Sanity check. */
+	if (strlen(msg) != msglen)
+	{
+		libpq_append_conn_error(conn,
+								"server's error message contained an embedded NULL, and was discarded");
+		return false;
+	}
+
+	/*
+	 * pg_parse_json doesn't validate the incoming UTF-8, so we have to check
+	 * that up front.
+	 */
+	if (pg_encoding_verifymbstr(PG_UTF8, msg, msglen) != msglen)
+	{
+		libpq_append_conn_error(conn,
+								"server's error response is not valid UTF-8");
+		return false;
+	}
+
+	makeJsonLexContextCstringLen(&lex, msg, msglen, PG_UTF8, true);
+	setJsonLexContextOwnsTokens(&lex, true);	/* must not leak on error */
+
+	initPQExpBuffer(&ctx.errbuf);
+	sem.semstate = &ctx;
+
+	sem.object_start = oauth_json_object_start;
+	sem.object_end = oauth_json_object_end;
+	sem.object_field_start = oauth_json_object_field_start;
+	sem.array_start = oauth_json_array_start;
+	sem.scalar = oauth_json_scalar;
+
+	err = pg_parse_json(&lex, &sem);
+
+	if (err == JSON_SEM_ACTION_FAILED)
+	{
+		if (PQExpBufferDataBroken(ctx.errbuf))
+			errmsg = libpq_gettext("out of memory");
+		else if (ctx.errmsg)
+			errmsg = ctx.errmsg;
+		else
+		{
+			/*
+			 * Developer error: one of the action callbacks didn't call
+			 * oauth_json_set_error() before erroring out.
+			 */
+			Assert(oauth_json_has_error(&ctx));
+			errmsg = "<unexpected empty error>";
+		}
+	}
+	else if (err != JSON_SUCCESS)
+		errmsg = json_errdetail(err, &lex);
+
+	if (errmsg)
+		libpq_append_conn_error(conn,
+								"failed to parse server's error response: %s",
+								errmsg);
+
+	/* Don't need the error buffer or the JSON lexer anymore. */
+	termPQExpBuffer(&ctx.errbuf);
+	freeJsonLexContext(&lex);
+
+	if (errmsg)
+		goto cleanup;
+
+	if (ctx.discovery_uri)
+	{
+		char	   *discovery_issuer;
+
+		/*
+		 * The URI MUST correspond to our existing issuer, to avoid mix-ups.
+		 *
+		 * Issuer comparison is done byte-wise, rather than performing any URL
+		 * normalization; this follows the suggestions for issuer comparison
+		 * in RFC 9207 Sec. 2.4 (which requires simple string comparison) and
+		 * vastly simplifies things. Since this is the key protection against
+		 * a rogue server sending the client to an untrustworthy location,
+		 * simpler is better.
+		 */
+		discovery_issuer = issuer_from_well_known_uri(conn, ctx.discovery_uri);
+		if (!discovery_issuer)
+			goto cleanup;		/* error message already set */
+
+		if (strcmp(conn->oauth_issuer_id, discovery_issuer) != 0)
+		{
+			libpq_append_conn_error(conn,
+									"server's discovery document at %s (issuer \"%s\") is incompatible with oauth_issuer (%s)",
+									ctx.discovery_uri, discovery_issuer,
+									conn->oauth_issuer_id);
+
+			free(discovery_issuer);
+			goto cleanup;
+		}
+
+		free(discovery_issuer);
+
+		if (!conn->oauth_discovery_uri)
+		{
+			conn->oauth_discovery_uri = ctx.discovery_uri;
+			ctx.discovery_uri = NULL;
+		}
+		else
+		{
+			/* This must match the URI we'd previously determined. */
+			if (strcmp(conn->oauth_discovery_uri, ctx.discovery_uri) != 0)
+			{
+				libpq_append_conn_error(conn,
+										"server's discovery document has moved to %s (previous location was %s)",
+										ctx.discovery_uri,
+										conn->oauth_discovery_uri);
+				goto cleanup;
+			}
+		}
+	}
+
+	if (ctx.scope)
+	{
+		/* Servers may not override a previously set oauth_scope. */
+		if (!conn->oauth_scope)
+		{
+			conn->oauth_scope = ctx.scope;
+			ctx.scope = NULL;
+		}
+	}
+
+	if (!ctx.status)
+	{
+		libpq_append_conn_error(conn,
+								"server sent error response without a status");
+		goto cleanup;
+	}
+
+	if (strcmp(ctx.status, "invalid_token") != 0)
+	{
+		/*
+		 * invalid_token is the only error code we'll automatically retry for;
+		 * otherwise, just bail out now.
+		 */
+		libpq_append_conn_error(conn,
+								"server rejected OAuth bearer token: %s",
+								ctx.status);
+		goto cleanup;
+	}
+
+	success = true;
+
+cleanup:
+	free(ctx.status);
+	free(ctx.scope);
+	free(ctx.discovery_uri);
+
+	return success;
+}
+
+/*
+ * Callback implementation of conn->async_auth() for a user-defined OAuth flow.
+ * Delegates the retrieval of the token to the application's 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_*
+ * statuses for use by PQconnectPoll().
+ */
+static PostgresPollingStatusType
+run_user_oauth_flow(PGconn *conn)
+{
+	fe_oauth_state *state = conn->sasl_state;
+	PGoauthBearerRequest *request = state->async_ctx;
+	PostgresPollingStatusType status;
+
+	if (!request->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);
+	if (status == PGRES_POLLING_FAILED)
+	{
+		libpq_append_conn_error(conn, "user-defined OAuth flow failed");
+		return status;
+	}
+	else if (status == PGRES_POLLING_OK)
+	{
+		/*
+		 * 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.)
+		 */
+		if (!request->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);
+		if (!conn->oauth_token)
+		{
+			libpq_append_conn_error(conn, "out of memory");
+			return PGRES_POLLING_FAILED;
+		}
+
+		return PGRES_POLLING_OK;
+	}
+
+	/* The hook wants the client to poll the altsock. Make sure it set one. */
+	if (conn->altsock == PGINVALID_SOCKET)
+	{
+		libpq_append_conn_error(conn,
+								"user-defined OAuth flow did not provide a socket for polling");
+		return PGRES_POLLING_FAILED;
+	}
+
+	return status;
+}
+
+/*
+ * Cleanup callback for the async user flow. Delegates most of its job to the
+ * user-provided cleanup implementation, then disconnects the altsock.
+ */
+static void
+cleanup_user_oauth_flow(PGconn *conn)
+{
+	fe_oauth_state *state = conn->sasl_state;
+	PGoauthBearerRequest *request = state->async_ctx;
+
+	Assert(request);
+
+	if (request->cleanup)
+		request->cleanup(conn, request);
+	conn->altsock = PGINVALID_SOCKET;
+
+	free(request);
+	state->async_ctx = NULL;
+}
+
+/*
+ * Chooses an OAuth client flow for the connection, which will retrieve a Bearer
+ * 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
+ * asynchronous callbacks which will be managed by run_user_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.)
+ *
+ * If neither a custom handler nor the builtin flow is available, the connection
+ * fails here.
+ */
+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,
+	};
+
+	Assert(request.openid_configuration);
+
+	/* The client may have overridden the OAuth flow. */
+	res = PQauthDataHook(PQAUTHDATA_OAUTH_BEARER_TOKEN, conn, &request);
+	if (res > 0)
+	{
+		PGoauthBearerRequest *request_copy;
+
+		if (request.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);
+			if (!conn->oauth_token)
+			{
+				libpq_append_conn_error(conn, "out of memory");
+				goto fail;
+			}
+
+			/* short-circuit */
+			if (request.cleanup)
+				request.cleanup(conn, &request);
+			return true;
+		}
+
+		request_copy = malloc(sizeof(*request_copy));
+		if (!request_copy)
+		{
+			libpq_append_conn_error(conn, "out of memory");
+			goto fail;
+		}
+
+		memcpy(request_copy, &request, sizeof(request));
+
+		conn->async_auth = run_user_oauth_flow;
+		conn->cleanup_async_auth = cleanup_user_oauth_flow;
+		state->async_ctx = request_copy;
+	}
+	else if (res < 0)
+	{
+		libpq_append_conn_error(conn, "user-defined OAuth flow failed");
+		goto fail;
+	}
+	else
+	{
+#if USE_LIBCURL
+		/* Hand off to our built-in OAuth flow. */
+		conn->async_auth = pg_fe_run_oauth_flow;
+		conn->cleanup_async_auth = pg_fe_cleanup_oauth_flow;
+
+#else
+		libpq_append_conn_error(conn, "no custom OAuth flows are available, and libpq was not built with libcurl support");
+		goto fail;
+
+#endif
+	}
+
+	return true;
+
+fail:
+	if (request.cleanup)
+		request.cleanup(conn, &request);
+	return false;
+}
+
+/*
+ * Fill in our issuer identifier and discovery URI, if possible, using the
+ * connection parameters. If conn->oauth_discovery_uri can't be populated in
+ * this function, it will be requested from the server.
+ */
+static bool
+setup_oauth_parameters(PGconn *conn)
+{
+	/*---
+	 * To talk to a server, we require the user to provide issuer and client
+	 * identifiers.
+	 *
+	 * While it's possible for an OAuth client to support multiple issuers, it
+	 * requires additional effort to make sure the flows in use are safe -- to
+	 * quote RFC 9207,
+	 *
+	 *     OAuth clients that interact with only one authorization server are
+	 *     not vulnerable to mix-up attacks. However, when such clients decide
+	 *     to add support for a second authorization server in the future, they
+	 *     become vulnerable and need to apply countermeasures to mix-up
+	 *     attacks.
+	 *
+	 * For now, we allow only one.
+	 */
+	if (!conn->oauth_issuer || !conn->oauth_client_id)
+	{
+		libpq_append_conn_error(conn,
+								"server requires OAuth authentication, but oauth_issuer and oauth_client_id are not both set");
+		return false;
+	}
+
+	/*
+	 * oauth_issuer is interpreted differently if it's a well-known discovery
+	 * URI rather than just an issuer identifier.
+	 */
+	if (strstr(conn->oauth_issuer, WK_PREFIX) != NULL)
+	{
+		/*
+		 * Convert the URI back to an issuer identifier. (This also performs
+		 * validation of the URI format.)
+		 */
+		conn->oauth_issuer_id = issuer_from_well_known_uri(conn,
+														   conn->oauth_issuer);
+		if (!conn->oauth_issuer_id)
+			return false;		/* error message already set */
+
+		conn->oauth_discovery_uri = strdup(conn->oauth_issuer);
+		if (!conn->oauth_discovery_uri)
+		{
+			libpq_append_conn_error(conn, "out of memory");
+			return false;
+		}
+	}
+	else
+	{
+		/*
+		 * Treat oauth_issuer as an issuer identifier. We'll ask the server
+		 * for the discovery URI.
+		 */
+		conn->oauth_issuer_id = strdup(conn->oauth_issuer);
+		if (!conn->oauth_issuer_id)
+		{
+			libpq_append_conn_error(conn, "out of memory");
+			return false;
+		}
+	}
+
+	return true;
+}
+
+/*
+ * Implements the OAUTHBEARER SASL exchange (RFC 7628, Sec. 3.2).
+ *
+ * If the necessary OAuth parameters are set up on the connection, this will run
+ * the client flow asynchronously and present the resulting token to the server.
+ * Otherwise, an empty discovery response will be sent and any parameters sent
+ * back by the server will be stored for a second attempt.
+ *
+ * For a full description of the API, see libpq/sasl.h.
+ */
+static SASLStatus
+oauth_exchange(void *opaq, bool final,
+			   char *input, int inputlen,
+			   char **output, int *outputlen)
+{
+	fe_oauth_state *state = opaq;
+	PGconn	   *conn = state->conn;
+	bool		discover = false;
+
+	*output = NULL;
+	*outputlen = 0;
+
+	switch (state->step)
+	{
+		case FE_OAUTH_INIT:
+			/* We begin in the initial response phase. */
+			Assert(inputlen == -1);
+
+			if (!setup_oauth_parameters(conn))
+				return SASL_FAILED;
+
+			if (conn->oauth_token)
+			{
+				/*
+				 * A previous connection already fetched the token; we'll use
+				 * it below.
+				 */
+			}
+			else if (conn->oauth_discovery_uri)
+			{
+				/*
+				 * We don't have a token, but we have a discovery URI already
+				 * stored. Decide whether we're using a user-provided OAuth
+				 * flow or the one we have built in.
+				 */
+				if (!setup_token_request(conn, state))
+					return SASL_FAILED;
+
+				if (conn->oauth_token)
+				{
+					/*
+					 * A really smart user implementation may have already
+					 * given us the token (e.g. if there was an unexpired copy
+					 * already cached), and we can use it immediately.
+					 */
+				}
+				else
+				{
+					/*
+					 * Otherwise, we'll have to hand the connection over to
+					 * our OAuth implementation.
+					 *
+					 * This could take a while, since it generally involves a
+					 * user in the loop. To avoid consuming the server's
+					 * authentication timeout, we'll continue this handshake
+					 * to the end, so that the server can close its side of
+					 * the connection. We'll open a second connection later
+					 * once we've retrieved a token.
+					 */
+					discover = true;
+				}
+			}
+			else
+			{
+				/*
+				 * If we don't have a token, and we don't have a discovery URI
+				 * to be able to request a token, we ask the server for one
+				 * explicitly.
+				 */
+				discover = true;
+			}
+
+			/*
+			 * Generate an initial response. This either contains a token, if
+			 * we have one, or an empty discovery response which is doomed to
+			 * fail.
+			 */
+			*output = client_initial_response(conn, discover);
+			if (!*output)
+				return SASL_FAILED;
+
+			*outputlen = strlen(*output);
+			state->step = FE_OAUTH_BEARER_SENT;
+
+			if (conn->oauth_token)
+			{
+				/*
+				 * For the purposes of require_auth, our side of
+				 * authentication is done at this point; the server will
+				 * either accept the connection or send an error. Unlike
+				 * SCRAM, there is no additional server data to check upon
+				 * success.
+				 */
+				conn->client_finished_auth = true;
+			}
+
+			return SASL_CONTINUE;
+
+		case FE_OAUTH_BEARER_SENT:
+			if (final)
+			{
+				/*
+				 * OAUTHBEARER does not make use of additional data with a
+				 * successful SASL exchange, so we shouldn't get an
+				 * AuthenticationSASLFinal message.
+				 */
+				libpq_append_conn_error(conn,
+										"server sent unexpected additional OAuth data");
+				return SASL_FAILED;
+			}
+
+			/*
+			 * An error message was sent by the server. Respond with the
+			 * required dummy message (RFC 7628, sec. 3.2.3).
+			 */
+			*output = strdup(kvsep);
+			if (unlikely(!*output))
+			{
+				libpq_append_conn_error(conn, "out of memory");
+				return SASL_FAILED;
+			}
+			*outputlen = strlen(*output);	/* == 1 */
+
+			/* Grab the settings from discovery. */
+			if (!handle_oauth_sasl_error(conn, input, inputlen))
+				return SASL_FAILED;
+
+			if (conn->oauth_token)
+			{
+				/*
+				 * The server rejected our token. Continue onwards towards the
+				 * expected FATAL message, but mark our state to catch any
+				 * unexpected "success" from the server.
+				 */
+				state->step = FE_OAUTH_SERVER_ERROR;
+				return SASL_CONTINUE;
+			}
+
+			if (!conn->async_auth)
+			{
+				/*
+				 * No OAuth flow is set up yet. Did we get enough information
+				 * from the server to create one?
+				 */
+				if (!conn->oauth_discovery_uri)
+				{
+					libpq_append_conn_error(conn,
+											"server requires OAuth authentication, but no discovery metadata was provided");
+					return SASL_FAILED;
+				}
+
+				/* Yes. Set up the flow now. */
+				if (!setup_token_request(conn, state))
+					return SASL_FAILED;
+
+				if (conn->oauth_token)
+				{
+					/*
+					 * A token was available in a custom flow's cache. Skip
+					 * the asynchronous processing.
+					 */
+					goto reconnect;
+				}
+			}
+
+			/*
+			 * Time to retrieve a token. This involves a number of HTTP
+			 * connections and timed waits, so we escape the synchronous auth
+			 * processing and tell PQconnectPoll to transfer control to our
+			 * async implementation.
+			 */
+			Assert(conn->async_auth);	/* should have been set already */
+			state->step = FE_OAUTH_REQUESTING_TOKEN;
+			return SASL_ASYNC;
+
+		case FE_OAUTH_REQUESTING_TOKEN:
+
+			/*
+			 * We've returned successfully from token retrieval. Double-check
+			 * that we have what we need for the next connection.
+			 */
+			if (!conn->oauth_token)
+			{
+				Assert(false);	/* should have failed before this point! */
+				libpq_append_conn_error(conn,
+										"internal error: OAuth flow did not set a token");
+				return SASL_FAILED;
+			}
+
+			goto reconnect;
+
+		case FE_OAUTH_SERVER_ERROR:
+
+			/*
+			 * After an error, the server should send an error response to
+			 * fail the SASL handshake, which is handled in higher layers.
+			 *
+			 * If we get here, the server either sent *another* challenge
+			 * which isn't defined in the RFC, or completed the handshake
+			 * successfully after telling us it was going to fail. Neither is
+			 * acceptable.
+			 */
+			libpq_append_conn_error(conn,
+									"server sent additional OAuth data after error");
+			return SASL_FAILED;
+
+		default:
+			libpq_append_conn_error(conn, "invalid OAuth exchange state");
+			break;
+	}
+
+	Assert(false);				/* should never get here */
+	return SASL_FAILED;
+
+reconnect:
+
+	/*
+	 * Despite being a failure from the point of view of SASL, we have enough
+	 * information to restart with a new connection.
+	 */
+	libpq_append_conn_error(conn, "retrying connection with new bearer token");
+	conn->oauth_want_retry = true;
+	return SASL_FAILED;
+}
+
+static bool
+oauth_channel_bound(void *opaq)
+{
+	/* This mechanism does not support channel binding. */
+	return false;
+}
+
+/*
+ * Fully clears out any stored OAuth token. This is done proactively upon
+ * successful connection as well as during pqClosePGconn().
+ */
+void
+pqClearOAuthToken(PGconn *conn)
+{
+	if (!conn->oauth_token)
+		return;
+
+	explicit_bzero(conn->oauth_token, strlen(conn->oauth_token));
+	free(conn->oauth_token);
+	conn->oauth_token = NULL;
+}
+
+/*
+ * Returns true if the PGOAUTHDEBUG=UNSAFE flag is set in the environment.
+ */
+bool
+oauth_unsafe_debugging_enabled(void)
+{
+	const char *env = getenv("PGOAUTHDEBUG");
+
+	return (env && strcmp(env, "UNSAFE") == 0);
+}
diff --git a/src/interfaces/libpq/fe-auth-oauth.h b/src/interfaces/libpq/fe-auth-oauth.h
new file mode 100644
index 0000000000..3259872168
--- /dev/null
+++ b/src/interfaces/libpq/fe-auth-oauth.h
@@ -0,0 +1,45 @@
+/*-------------------------------------------------------------------------
+ *
+ * fe-auth-oauth.h
+ *
+ *	  Definitions for OAuth authentication implementations
+ *
+ * Portions Copyright (c) 2024, PostgreSQL Global Development Group
+ *
+ * src/interfaces/libpq/fe-auth-oauth.h
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#ifndef FE_AUTH_OAUTH_H
+#define FE_AUTH_OAUTH_H
+
+#include "libpq-fe.h"
+#include "libpq-int.h"
+
+
+enum fe_oauth_step
+{
+	FE_OAUTH_INIT,
+	FE_OAUTH_BEARER_SENT,
+	FE_OAUTH_REQUESTING_TOKEN,
+	FE_OAUTH_SERVER_ERROR,
+};
+
+typedef struct
+{
+	enum fe_oauth_step step;
+
+	PGconn	   *conn;
+	void	   *async_ctx;
+} fe_oauth_state;
+
+extern PostgresPollingStatusType pg_fe_run_oauth_flow(PGconn *conn);
+extern void pg_fe_cleanup_oauth_flow(PGconn *conn);
+extern void pqClearOAuthToken(PGconn *conn);
+extern bool oauth_unsafe_debugging_enabled(void);
+
+/* Mechanisms in fe-auth-oauth.c */
+extern const pg_fe_sasl_mech pg_oauth_mech;
+
+#endif							/* FE_AUTH_OAUTH_H */
diff --git a/src/interfaces/libpq/fe-auth.c b/src/interfaces/libpq/fe-auth.c
index 761ee8f88f..ec7a923604 100644
--- a/src/interfaces/libpq/fe-auth.c
+++ b/src/interfaces/libpq/fe-auth.c
@@ -40,9 +40,11 @@
 #endif
 
 #include "common/md5.h"
+#include "common/oauth-common.h"
 #include "common/scram-common.h"
 #include "fe-auth.h"
 #include "fe-auth-sasl.h"
+#include "fe-auth-oauth.h"
 #include "libpq-fe.h"
 
 #ifdef ENABLE_GSS
@@ -535,6 +537,13 @@ pg_SASL_init(PGconn *conn, int payloadlen, bool *async)
 			conn->sasl = &pg_scram_mech;
 			conn->password_needed = true;
 		}
+		else if (strcmp(mechanism_buf.data, OAUTHBEARER_NAME) == 0 &&
+				 !selected_mechanism)
+		{
+			selected_mechanism = OAUTHBEARER_NAME;
+			conn->sasl = &pg_oauth_mech;
+			conn->password_needed = false;
+		}
 	}
 
 	if (!selected_mechanism)
@@ -559,13 +568,6 @@ pg_SASL_init(PGconn *conn, int payloadlen, bool *async)
 
 		if (!allowed)
 		{
-			/*
-			 * TODO: this is dead code until a second SASL mechanism is added;
-			 * the connection can't have proceeded past check_expected_areq()
-			 * if no SASL methods are allowed.
-			 */
-			Assert(false);
-
 			libpq_append_conn_error(conn, "authentication method requirement \"%s\" failed: server requested %s authentication",
 									conn->require_auth, selected_mechanism);
 			goto error;
@@ -1580,3 +1582,23 @@ PQchangePassword(PGconn *conn, const char *user, const char *passwd)
 		}
 	}
 }
+
+PQauthDataHook_type PQauthDataHook = PQdefaultAuthDataHook;
+
+PQauthDataHook_type
+PQgetAuthDataHook(void)
+{
+	return PQauthDataHook;
+}
+
+void
+PQsetAuthDataHook(PQauthDataHook_type hook)
+{
+	PQauthDataHook = hook ? hook : PQdefaultAuthDataHook;
+}
+
+int
+PQdefaultAuthDataHook(PGauthData type, PGconn *conn, void *data)
+{
+	return 0;					/* handle nothing */
+}
diff --git a/src/interfaces/libpq/fe-auth.h b/src/interfaces/libpq/fe-auth.h
index 1d4991f899..de98e0d20c 100644
--- a/src/interfaces/libpq/fe-auth.h
+++ b/src/interfaces/libpq/fe-auth.h
@@ -18,6 +18,9 @@
 #include "libpq-int.h"
 
 
+extern PQauthDataHook_type PQauthDataHook;
+
+
 /* Prototypes for functions in fe-auth.c */
 extern int	pg_fe_sendauth(AuthRequest areq, int payloadlen, PGconn *conn,
 						   bool *async);
diff --git a/src/interfaces/libpq/fe-connect.c b/src/interfaces/libpq/fe-connect.c
index 85d1ca2864..d5051f5e82 100644
--- a/src/interfaces/libpq/fe-connect.c
+++ b/src/interfaces/libpq/fe-connect.c
@@ -28,6 +28,7 @@
 #include "common/scram-common.h"
 #include "common/string.h"
 #include "fe-auth.h"
+#include "fe-auth-oauth.h"
 #include "libpq-fe.h"
 #include "libpq-int.h"
 #include "mb/pg_wchar.h"
@@ -373,6 +374,23 @@ static const internalPQconninfoOption PQconninfoOptions[] = {
 	{"scram_server_key", NULL, NULL, NULL, "SCRAM-Server-Key", "D", SCRAM_MAX_KEY_LEN * 2,
 	offsetof(struct pg_conn, scram_server_key)},
 
+	/* OAuth v2 */
+	{"oauth_issuer", NULL, NULL, NULL,
+		"OAuth-Issuer", "", 40,
+	offsetof(struct pg_conn, oauth_issuer)},
+
+	{"oauth_client_id", NULL, NULL, NULL,
+		"OAuth-Client-ID", "", 40,
+	offsetof(struct pg_conn, oauth_client_id)},
+
+	{"oauth_client_secret", NULL, NULL, NULL,
+		"OAuth-Client-Secret", "", 40,
+	offsetof(struct pg_conn, oauth_client_secret)},
+
+	{"oauth_scope", NULL, NULL, NULL,
+		"OAuth-Scope", "", 15,
+	offsetof(struct pg_conn, oauth_scope)},
+
 	/* Terminating entry --- MUST BE LAST */
 	{NULL, NULL, NULL, NULL,
 	NULL, NULL, 0}
@@ -399,6 +417,7 @@ static const PQEnvironmentOption EnvironmentOptions[] =
 static const pg_fe_sasl_mech *supported_sasl_mechs[] =
 {
 	&pg_scram_mech,
+	&pg_oauth_mech,
 };
 #define SASL_MECHANISM_COUNT lengthof(supported_sasl_mechs)
 
@@ -655,6 +674,7 @@ pqDropServerData(PGconn *conn)
 	conn->write_failed = false;
 	free(conn->write_err_msg);
 	conn->write_err_msg = NULL;
+	conn->oauth_want_retry = false;
 
 	/*
 	 * Cancel connections need to retain their be_pid and be_key across
@@ -1144,7 +1164,7 @@ static inline void
 fill_allowed_sasl_mechs(PGconn *conn)
 {
 	/*---
-	 * We only support one mechanism at the moment, so rather than deal with a
+	 * We only support two mechanisms at the moment, so rather than deal with a
 	 * linked list, conn->allowed_sasl_mechs is an array of static length. We
 	 * rely on the compile-time assertion here to keep us honest.
 	 *
@@ -1519,6 +1539,10 @@ pqConnectOptions2(PGconn *conn)
 			{
 				mech = &pg_scram_mech;
 			}
+			else if (strcmp(method, "oauth") == 0)
+			{
+				mech = &pg_oauth_mech;
+			}
 
 			/*
 			 * Final group: meta-options.
@@ -4111,7 +4135,19 @@ keep_going:						/* We will come back to here until there is
 				conn->inStart = conn->inCursor;
 
 				if (res != STATUS_OK)
+				{
+					/*
+					 * OAuth connections may perform two-step discovery, where
+					 * the first connection is a dummy.
+					 */
+					if (conn->sasl == &pg_oauth_mech && conn->oauth_want_retry)
+					{
+						need_new_connection = true;
+						goto keep_going;
+					}
+
 					goto error_return;
+				}
 
 				/*
 				 * Just make sure that any data sent by pg_fe_sendauth is
@@ -4390,6 +4426,9 @@ keep_going:						/* We will come back to here until there is
 					}
 				}
 
+				/* Don't hold onto any OAuth tokens longer than necessary. */
+				pqClearOAuthToken(conn);
+
 				/*
 				 * For non cancel requests we can release the address list
 				 * now. For cancel requests we never actually resolve
@@ -5002,6 +5041,12 @@ freePGconn(PGconn *conn)
 	free(conn->load_balance_hosts);
 	free(conn->scram_client_key);
 	free(conn->scram_server_key);
+	free(conn->oauth_issuer);
+	free(conn->oauth_issuer_id);
+	free(conn->oauth_discovery_uri);
+	free(conn->oauth_client_id);
+	free(conn->oauth_client_secret);
+	free(conn->oauth_scope);
 	termPQExpBuffer(&conn->errorMessage);
 	termPQExpBuffer(&conn->workBuffer);
 
@@ -5155,6 +5200,7 @@ pqClosePGconn(PGconn *conn)
 	conn->asyncStatus = PGASYNC_IDLE;
 	conn->xactStatus = PQTRANS_IDLE;
 	conn->pipelineStatus = PQ_PIPELINE_OFF;
+	pqClearOAuthToken(conn);
 	pqClearAsyncResult(conn);	/* deallocate result */
 	pqClearConnErrorState(conn);
 
diff --git a/src/interfaces/libpq/libpq-fe.h b/src/interfaces/libpq/libpq-fe.h
index a3491faf0c..5f8d608261 100644
--- a/src/interfaces/libpq/libpq-fe.h
+++ b/src/interfaces/libpq/libpq-fe.h
@@ -59,6 +59,8 @@ extern "C"
 /* Features added in PostgreSQL v18: */
 /* Indicates presence of PQfullProtocolVersion */
 #define LIBPQ_HAS_FULL_PROTOCOL_VERSION 1
+/* Indicates presence of the PQAUTHDATA_PROMPT_OAUTH_DEVICE authdata hook */
+#define LIBPQ_HAS_PROMPT_OAUTH_DEVICE 1
 
 /*
  * Option flags for PQcopyResult
@@ -186,6 +188,13 @@ typedef enum
 	PQ_PIPELINE_ABORTED
 } PGpipelineStatus;
 
+typedef enum
+{
+	PQAUTHDATA_PROMPT_OAUTH_DEVICE, /* user must visit a device-authorization
+									 * URL */
+	PQAUTHDATA_OAUTH_BEARER_TOKEN,	/* server requests an OAuth Bearer token */
+} PGauthData;
+
 /* PGconn encapsulates a connection to the backend.
  * The contents of this struct are not supposed to be known to applications.
  */
@@ -720,10 +729,83 @@ extern int	PQenv2encoding(void);
 
 /* === in fe-auth.c === */
 
+typedef struct _PGpromptOAuthDevice
+{
+	const char *verification_uri;	/* verification URI to visit */
+	const char *user_code;		/* user code to enter */
+} PGpromptOAuthDevice;
+
+/* for PGoauthBearerRequest.async() */
+#ifdef _WIN32
+#define SOCKTYPE uintptr_t		/* avoids depending on winsock2.h for SOCKET */
+#else
+#define SOCKTYPE int
+#endif
+
+typedef struct _PGoauthBearerRequest
+{
+	/* Hook inputs (constant across all calls) */
+	const char *const openid_configuration; /* OIDC discovery URI */
+	const char *const scope;	/* required scope(s), or NULL */
+
+	/* Hook outputs */
+
+	/*---------
+	 * Callback implementing a custom asynchronous OAuth flow.
+	 *
+	 * The callback may return
+	 * - PGRES_POLLING_READING/WRITING, to indicate that a socket descriptor
+	 *   has been stored in *altsock and libpq should wait until it is
+	 *   readable or writable before calling back;
+	 * - PGRES_POLLING_OK, to indicate that the flow is complete and
+	 *   request->token has been set; or
+	 * - PGRES_POLLING_FAILED, to indicate that token retrieval has failed.
+	 *
+	 * This callback is optional. If the token can be obtained without
+	 * 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.
+	 */
+	PostgresPollingStatusType (*async) (PGconn *conn,
+										struct _PGoauthBearerRequest *request,
+										SOCKTYPE * altsock);
+
+	/*
+	 * Callback to clean up custom allocations. A hook implementation may use
+	 * this to free request->token and any resources in request->user.
+	 *
+	 * This is technically optional, but highly recommended, because there is
+	 * no other indication as to when it is safe to free the token.
+	 */
+	void		(*cleanup) (PGconn *conn, struct _PGoauthBearerRequest *request);
+
+	/*
+	 * The hook should set this to the Bearer token contents for the
+	 * connection, once the flow is completed.  The token contents must remain
+	 * available to libpq until the hook's cleanup callback is called.
+	 */
+	char	   *token;
+
+	/*
+	 * Hook-defined data. libpq will not modify this pointer across calls to
+	 * the async callback, so it can be used to keep track of
+	 * application-specific state. Resources allocated here should be freed by
+	 * the cleanup callback.
+	 */
+	void	   *user;
+} PGoauthBearerRequest;
+
+#undef SOCKTYPE
+
 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);
 
+typedef int (*PQauthDataHook_type) (PGauthData type, PGconn *conn, void *data);
+extern void PQsetAuthDataHook(PQauthDataHook_type hook);
+extern PQauthDataHook_type PQgetAuthDataHook(void);
+extern int	PQdefaultAuthDataHook(PGauthData type, PGconn *conn, void *data);
+
 /* === in encnames.c === */
 
 extern int	pg_char_to_encoding(const char *name);
diff --git a/src/interfaces/libpq/libpq-int.h b/src/interfaces/libpq/libpq-int.h
index 2546f9f8a5..f36f7f19d5 100644
--- a/src/interfaces/libpq/libpq-int.h
+++ b/src/interfaces/libpq/libpq-int.h
@@ -437,6 +437,17 @@ struct pg_conn
 								 * cancel request, instead of being a normal
 								 * connection that's used for queries */
 
+	/* OAuth v2 */
+	char	   *oauth_issuer;	/* token issuer/URL */
+	char	   *oauth_issuer_id;	/* token issuer identifier */
+	char	   *oauth_discovery_uri;	/* URI of the issuer's discovery
+										 * document */
+	char	   *oauth_client_id;	/* client identifier */
+	char	   *oauth_client_secret;	/* client secret */
+	char	   *oauth_scope;	/* access token scope */
+	char	   *oauth_token;	/* access token */
+	bool		oauth_want_retry;	/* should we retry on failure? */
+
 	/* Optional file to write trace info to */
 	FILE	   *Pfdebug;
 	int			traceFlags;
@@ -505,7 +516,7 @@ struct pg_conn
 								 * the server? */
 	uint32		allowed_auth_methods;	/* bitmask of acceptable AuthRequest
 										 * codes */
-	const pg_fe_sasl_mech *allowed_sasl_mechs[1];	/* and acceptable SASL
+	const pg_fe_sasl_mech *allowed_sasl_mechs[2];	/* and acceptable SASL
 													 * mechanisms */
 	bool		client_finished_auth;	/* have we finished our half of the
 										 * authentication exchange? */
diff --git a/src/interfaces/libpq/meson.build b/src/interfaces/libpq/meson.build
index 1a5a223e1a..4180e35f8c 100644
--- a/src/interfaces/libpq/meson.build
+++ b/src/interfaces/libpq/meson.build
@@ -4,6 +4,7 @@
 # args for executables (which depend on libpq).
 
 libpq_sources = files(
+  'fe-auth-oauth.c',
   'fe-auth-scram.c',
   'fe-auth.c',
   'fe-cancel.c',
@@ -40,6 +41,10 @@ if gssapi.found()
   )
 endif
 
+if libcurl.found()
+  libpq_sources += files('fe-auth-oauth-curl.c')
+endif
+
 export_file = custom_target('libpq.exports',
   kwargs: gen_export_kwargs,
 )
diff --git a/src/makefiles/meson.build b/src/makefiles/meson.build
index d49b2079a4..60e13d5023 100644
--- a/src/makefiles/meson.build
+++ b/src/makefiles/meson.build
@@ -229,6 +229,7 @@ pgxs_deps = {
   'gssapi': gssapi,
   'icu': icu,
   'ldap': ldap,
+  'libcurl': libcurl,
   'libxml': libxml,
   'libxslt': libxslt,
   'llvm': llvm,
diff --git a/src/test/authentication/t/001_password.pl b/src/test/authentication/t/001_password.pl
index 1357f806b6..4ce22ccbdf 100644
--- a/src/test/authentication/t/001_password.pl
+++ b/src/test/authentication/t/001_password.pl
@@ -404,11 +404,11 @@ $node->connect_fails(
 $node->connect_fails(
 	"user=scram_role require_auth=!scram-sha-256",
 	"SCRAM authentication forbidden, fails with SCRAM auth",
-	expected_stderr => qr/server requested SASL authentication/);
+	expected_stderr => qr/server requested SCRAM-SHA-256 authentication/);
 $node->connect_fails(
 	"user=scram_role require_auth=!password,!md5,!scram-sha-256",
 	"multiple authentication types forbidden, fails with SCRAM auth",
-	expected_stderr => qr/server requested SASL authentication/);
+	expected_stderr => qr/server requested SCRAM-SHA-256 authentication/);
 
 # Test that bad passwords are rejected.
 $ENV{"PGPASSWORD"} = 'badpass';
@@ -465,13 +465,13 @@ $node->connect_fails(
 	"user=scram_role require_auth=!scram-sha-256",
 	"password authentication forbidden, fails with SCRAM auth",
 	expected_stderr =>
-	  qr/authentication method requirement "!scram-sha-256" failed: server requested SASL authentication/
+	  qr/authentication method requirement "!scram-sha-256" failed: server requested SCRAM-SHA-256 authentication/
 );
 $node->connect_fails(
 	"user=scram_role require_auth=!password,!md5,!scram-sha-256",
 	"multiple authentication types forbidden, fails with SCRAM auth",
 	expected_stderr =>
-	  qr/authentication method requirement "!password,!md5,!scram-sha-256" failed: server requested SASL authentication/
+	  qr/authentication method requirement "!password,!md5,!scram-sha-256" failed: server requested SCRAM-SHA-256 authentication/
 );
 
 # Test SYSTEM_USER <> NULL with parallel workers.
diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile
index c0d3cf0e14..bdfd5f1f8d 100644
--- a/src/test/modules/Makefile
+++ b/src/test/modules/Makefile
@@ -11,6 +11,7 @@ SUBDIRS = \
 		  dummy_index_am \
 		  dummy_seclabel \
 		  libpq_pipeline \
+		  oauth_validator \
 		  plsample \
 		  spgist_name_ops \
 		  test_bloomfilter \
diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build
index 4f544a042d..0c2ccc75a6 100644
--- a/src/test/modules/meson.build
+++ b/src/test/modules/meson.build
@@ -9,6 +9,7 @@ subdir('gin')
 subdir('injection_points')
 subdir('ldap_password_func')
 subdir('libpq_pipeline')
+subdir('oauth_validator')
 subdir('plsample')
 subdir('spgist_name_ops')
 subdir('ssl_passphrase_callback')
diff --git a/src/test/modules/oauth_validator/.gitignore b/src/test/modules/oauth_validator/.gitignore
new file mode 100644
index 0000000000..5dcb3ff972
--- /dev/null
+++ b/src/test/modules/oauth_validator/.gitignore
@@ -0,0 +1,4 @@
+# Generated subdirectories
+/log/
+/results/
+/tmp_check/
diff --git a/src/test/modules/oauth_validator/Makefile b/src/test/modules/oauth_validator/Makefile
new file mode 100644
index 0000000000..f297ed5c96
--- /dev/null
+++ b/src/test/modules/oauth_validator/Makefile
@@ -0,0 +1,40 @@
+#-------------------------------------------------------------------------
+#
+# Makefile for src/test/modules/oauth_validator
+#
+# Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/test/modules/oauth_validator/Makefile
+#
+#-------------------------------------------------------------------------
+
+MODULES = validator fail_validator
+PGFILEDESC = "validator - test OAuth validator module"
+
+PROGRAM = oauth_hook_client
+PGAPPICON = win32
+OBJS = $(WIN32RES) oauth_hook_client.o
+
+PG_CPPFLAGS = -I$(libpq_srcdir)
+PG_LIBS_INTERNAL += $(libpq_pgport)
+
+NO_INSTALLCHECK = 1
+
+TAP_TESTS = 1
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = src/test/modules/oauth_validator
+top_builddir = ../../../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+
+export PYTHON
+export with_libcurl
+export with_python
+
+endif
diff --git a/src/test/modules/oauth_validator/README b/src/test/modules/oauth_validator/README
new file mode 100644
index 0000000000..138a810462
--- /dev/null
+++ b/src/test/modules/oauth_validator/README
@@ -0,0 +1,13 @@
+Test programs and libraries for OAuth
+-------------------------------------
+
+This folder contains tests for the client- and server-side OAuth
+implementations. Most tests are run end-to-end to test both simultaneously. The
+tests in t/001_server use a mock OAuth authorization server, implemented jointly
+by t/OAuth/Server.pm and t/oauth_server.py, to run the libpq Device
+Authorization flow. The tests in t/002_client exercise custom OAuth flows and
+don't need an authorization server.
+
+Tests in this folder generally require 'oauth' to be present in PG_TEST_EXTRA,
+since localhost HTTP servers will be started. A Python installation is required
+to run the mock authorization server.
diff --git a/src/test/modules/oauth_validator/fail_validator.c b/src/test/modules/oauth_validator/fail_validator.c
new file mode 100644
index 0000000000..f77a3e115c
--- /dev/null
+++ b/src/test/modules/oauth_validator/fail_validator.c
@@ -0,0 +1,42 @@
+/*-------------------------------------------------------------------------
+ *
+ * fail_validator.c
+ *	  Test module for serverside OAuth token validation callbacks, which always
+ *	  fails
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/test/modules/oauth_validator/fail_validator.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "fmgr.h"
+#include "libpq/oauth.h"
+
+PG_MODULE_MAGIC;
+
+static ValidatorModuleResult *fail_token(ValidatorModuleState *state,
+										 const char *token,
+										 const char *role);
+
+/* Callback implementations (we only need the main one) */
+static const OAuthValidatorCallbacks validator_callbacks = {
+	.validate_cb = fail_token,
+};
+
+const OAuthValidatorCallbacks *
+_PG_oauth_validator_module_init(void)
+{
+	return &validator_callbacks;
+}
+
+static ValidatorModuleResult *
+fail_token(ValidatorModuleState *state, const char *token, const char *role)
+{
+	elog(FATAL, "fail_validator: sentinel error");
+	pg_unreachable();
+}
diff --git a/src/test/modules/oauth_validator/meson.build b/src/test/modules/oauth_validator/meson.build
new file mode 100644
index 0000000000..4b78c90557
--- /dev/null
+++ b/src/test/modules/oauth_validator/meson.build
@@ -0,0 +1,69 @@
+# Copyright (c) 2024, PostgreSQL Global Development Group
+
+validator_sources = files(
+  'validator.c',
+)
+
+if host_system == 'windows'
+  validator_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'validator',
+    '--FILEDESC', 'validator - test OAuth validator module',])
+endif
+
+validator = shared_module('validator',
+  validator_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += validator
+
+fail_validator_sources = files(
+  'fail_validator.c',
+)
+
+if host_system == 'windows'
+  fail_validator_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'fail_validator',
+    '--FILEDESC', 'fail_validator - failing OAuth validator module',])
+endif
+
+fail_validator = shared_module('fail_validator',
+  fail_validator_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += fail_validator
+
+oauth_hook_client_sources = files(
+  'oauth_hook_client.c',
+)
+
+if host_system == 'windows'
+  oauth_hook_client_sources += rc_bin_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'oauth_hook_client',
+    '--FILEDESC', 'oauth_hook_client - test program for libpq OAuth hooks',])
+endif
+
+oauth_hook_client = executable('oauth_hook_client',
+  oauth_hook_client_sources,
+  dependencies: [frontend_code, libpq],
+  kwargs: default_bin_args + {
+    'install': false,
+  },
+)
+testprep_targets += oauth_hook_client
+
+tests += {
+  'name': 'oauth_validator',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'tap': {
+    'tests': [
+      't/001_server.pl',
+      't/002_client.pl',
+    ],
+    'env': {
+      'PYTHON': python.path(),
+      'with_libcurl': libcurl.found() ? 'yes' : 'no',
+      'with_python': 'yes',
+    },
+  },
+}
diff --git a/src/test/modules/oauth_validator/oauth_hook_client.c b/src/test/modules/oauth_validator/oauth_hook_client.c
new file mode 100644
index 0000000000..12fe70c990
--- /dev/null
+++ b/src/test/modules/oauth_validator/oauth_hook_client.c
@@ -0,0 +1,264 @@
+/*-------------------------------------------------------------------------
+ *
+ * oauth_hook_client.c
+ *		Test driver for t/002_client.pl, which verifies OAuth hook
+ *		functionality in libpq.
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ *
+ * IDENTIFICATION
+ *		src/test/modules/oauth_validator/oauth_hook_client.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres_fe.h"
+
+#include <sys/socket.h>
+
+#include "getopt_long.h"
+#include "libpq-fe.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(" --expected-scope SCOPE	fail if received scopes do not match SCOPE\n");
+	printf(" --expected-uri URI		fail if received configuration link does not match URI\n");
+	printf(" --misbehave=MODE		have the hook fail required postconditions\n"
+		   "						(MODEs: no-hook, fail-async, no-token, no-socket)\n");
+	printf(" --no-hook				don't install OAuth hooks (connection will fail)\n");
+	printf(" --hang-forever			don't ever return a token (combine with connect_timeout)\n");
+	printf(" --token TOKEN			use the provided TOKEN value\n");
+}
+
+/* --options */
+static bool no_hook = false;
+static bool hang_forever = false;
+static const char *expected_uri = NULL;
+static const char *expected_scope = NULL;
+static const char *misbehave_mode = NULL;
+static char *token = NULL;
+
+int
+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},
+		{0}
+	};
+
+	const char *conninfo;
+	PGconn	   *conn;
+	int			c;
+
+	while ((c = getopt_long(argc, argv, "h", long_options, NULL)) != -1)
+	{
+		switch (c)
+		{
+			case 'h':
+				usage(argv);
+				return 0;
+
+			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;
+
+			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);
+
+	/* Connect. (All the actual work is in the hook.) */
+	conn = PQconnectdb(conninfo);
+	if (PQstatus(conn) != CONNECTION_OK)
+	{
+		fprintf(stderr, "Connection to database failed: %s\n",
+				PQerrorMessage(conn));
+		PQfinish(conn);
+		return 1;
+	}
+
+	printf("connection succeeded\n");
+	PQfinish(conn);
+	return 0;
+}
+
+/*
+ * PQauthDataHook implementation. Replaces the default client flow by handling
+ * PQAUTHDATA_OAUTH_BEARER_TOKEN.
+ */
+static int
+handle_auth_data(PGauthData type, PGconn *conn, void *data)
+{
+	PGoauthBearerRequest *req = data;
+
+	if (no_hook || (type != PQAUTHDATA_OAUTH_BEARER_TOKEN))
+		return 0;
+
+	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;
+		}
+	}
+
+	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". */
+		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/001_server.pl b/src/test/modules/oauth_validator/t/001_server.pl
new file mode 100644
index 0000000000..80f5258589
--- /dev/null
+++ b/src/test/modules/oauth_validator/t/001_server.pl
@@ -0,0 +1,551 @@
+
+#
+# Tests the libpq builtin OAuth flow, as well as server-side HBA and validator
+# setup.
+#
+# Copyright (c) 2021-2024, PostgreSQL Global Development Group
+#
+
+use strict;
+use warnings FATAL => 'all';
+
+use JSON::PP     qw(encode_json);
+use MIME::Base64 qw(encode_base64);
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+use FindBin;
+use lib $FindBin::RealBin;
+
+use OAuth::Server;
+
+if (!$ENV{PG_TEST_EXTRA} || $ENV{PG_TEST_EXTRA} !~ /\boauth\b/)
+{
+	plan skip_all =>
+	  'Potentially unsafe test oauth not enabled in PG_TEST_EXTRA';
+}
+
+if ($ENV{with_libcurl} ne 'yes')
+{
+	plan skip_all => 'client-side OAuth not supported by this build';
+}
+
+if ($ENV{with_python} ne 'yes')
+{
+	plan skip_all => 'OAuth tests require --with-python to run';
+}
+
+my $node = PostgreSQL::Test::Cluster->new('primary');
+$node->init;
+$node->append_conf('postgresql.conf', "log_connections = on\n");
+$node->append_conf('postgresql.conf',
+	"oauth_validator_libraries = 'validator'\n");
+$node->start;
+
+$node->safe_psql('postgres', 'CREATE USER test;');
+$node->safe_psql('postgres', 'CREATE USER testalt;');
+$node->safe_psql('postgres', 'CREATE USER testparam;');
+
+# Save a background connection for later configuration changes.
+my $bgconn = $node->background_psql('postgres');
+
+my $webserver = OAuth::Server->new();
+$webserver->run();
+
+END
+{
+	my $exit_code = $?;
+
+	$webserver->stop() if defined $webserver;    # might have been SKIP'd
+
+	$? = $exit_code;
+}
+
+my $port = $webserver->port();
+my $issuer = "http://localhost:$port";
+
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test      oauth issuer="$issuer"       scope="openid postgres"
+local all testalt   oauth issuer="$issuer/.well-known/oauth-authorization-server/alternate" scope="openid postgres alt"
+local all testparam oauth issuer="$issuer/param" scope="openid postgres"
+});
+$node->reload;
+
+my $log_start = $node->wait_for_log(qr/reloading configuration files/);
+
+# Check pg_hba_file_rules() support.
+my $contents = $bgconn->query_safe(
+	qq(SELECT rule_number, auth_method, options
+		 FROM pg_hba_file_rules
+		 ORDER BY rule_number;));
+is( $contents,
+	qq{1|oauth|\{issuer=$issuer,"scope=openid postgres",validator=validator\}
+2|oauth|\{issuer=$issuer/.well-known/oauth-authorization-server/alternate,"scope=openid postgres alt",validator=validator\}
+3|oauth|\{issuer=$issuer/param,"scope=openid postgres",validator=validator\}},
+	"pg_hba_file_rules recreates OAuth HBA settings");
+
+# To test against HTTP rather than HTTPS, we need to enable PGOAUTHDEBUG. But
+# first, check to make sure the client refuses such connections by default.
+$node->connect_fails(
+	"user=test dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635",
+	"HTTPS is required without debug mode",
+	expected_stderr =>
+	  qr@OAuth discovery URI "\Q$issuer\E/.well-known/openid-configuration" must use HTTPS@
+);
+
+$ENV{PGOAUTHDEBUG} = "UNSAFE";
+
+my $user = "test";
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635",
+	"connect as test",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@,
+	log_like => [
+		qr/oauth_validator: token="9243959234", role="$user"/,
+		qr/oauth_validator: issuer="\Q$issuer\E", scope="openid postgres"/,
+		qr/connection authenticated: identity="test" method=oauth/,
+		qr/connection authorized/,
+	]);
+
+# The /alternate issuer uses slightly different parameters, along with an
+# OAuth-style discovery document.
+$user = "testalt";
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer/alternate oauth_client_id=f02c6361-0636",
+	"connect as testalt",
+	expected_stderr =>
+	  qr@Visit https://example\.org/ and enter the code: postgresuser@,
+	log_like => [
+		qr/oauth_validator: token="9243959234-alt", role="$user"/,
+		qr|oauth_validator: issuer="\Q$issuer/.well-known/oauth-authorization-server/alternate\E", scope="openid postgres alt"|,
+		qr/connection authenticated: identity="testalt" method=oauth/,
+		qr/connection authorized/,
+	]);
+
+# The issuer linked by the server must match the client's oauth_issuer setting.
+$node->connect_fails(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0636",
+	"oauth_issuer must match discovery",
+	expected_stderr =>
+	  qr@server's discovery document at \Q$issuer/.well-known/oauth-authorization-server/alternate\E \(issuer "\Q$issuer/alternate\E"\) is incompatible with oauth_issuer \(\Q$issuer\E\)@
+);
+
+# Test require_auth settings against OAUTHBEARER.
+my @cases = (
+	{ require_auth => "oauth" },
+	{ require_auth => "oauth,scram-sha-256" },
+	{ require_auth => "password,oauth" },
+	{ require_auth => "none,oauth" },
+	{ require_auth => "!scram-sha-256" },
+	{ require_auth => "!none" },
+
+	{
+		require_auth => "!oauth",
+		failure => qr/server requested OAUTHBEARER authentication/
+	},
+	{
+		require_auth => "scram-sha-256",
+		failure => qr/server requested OAUTHBEARER authentication/
+	},
+	{
+		require_auth => "!password,!oauth",
+		failure => qr/server requested OAUTHBEARER authentication/
+	},
+	{
+		require_auth => "none",
+		failure => qr/server requested SASL authentication/
+	},
+	{
+		require_auth => "!oauth,!scram-sha-256",
+		failure => qr/server requested SASL authentication/
+	});
+
+$user = "test";
+foreach my $c (@cases)
+{
+	my $connstr =
+	  "user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635 require_auth=$c->{'require_auth'}";
+
+	if (defined $c->{'failure'})
+	{
+		$node->connect_fails(
+			$connstr,
+			"require_auth=$c->{'require_auth'} fails",
+			expected_stderr => $c->{'failure'});
+	}
+	else
+	{
+		$node->connect_ok(
+			$connstr,
+			"require_auth=$c->{'require_auth'} succeeds",
+			expected_stderr =>
+			  qr@Visit https://example\.com/ and enter the code: postgresuser@
+		);
+	}
+}
+
+# Make sure the client_id and secret are correctly encoded. $vschars contains
+# every allowed character for a client_id/_secret (the "VSCHAR" class).
+# $vschars_esc is additionally backslash-escaped for inclusion in a
+# single-quoted connection string.
+my $vschars =
+  " !\"#\$%&'()*+,-./0123456789:;<=>?\@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~";
+my $vschars_esc =
+  " !\"#\$%&\\'()*+,-./0123456789:;<=>?\@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\\\]^_`abcdefghijklmnopqrstuvwxyz{|}~";
+
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id='$vschars_esc'",
+	"escapable characters: client_id",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id='$vschars_esc' oauth_client_secret='$vschars_esc'",
+	"escapable characters: client_id and secret",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+#
+# Further tests rely on support for specific behaviors in oauth_server.py. To
+# trigger these behaviors, we ask for the special issuer .../param (which is set
+# up in HBA for the testparam user) and encode magic instructions into the
+# oauth_client_id.
+#
+
+my $common_connstr =
+  "user=testparam dbname=postgres oauth_issuer=$issuer/param ";
+my $base_connstr = $common_connstr;
+
+sub connstr
+{
+	my (%params) = @_;
+
+	my $json = encode_json(\%params);
+	my $encoded = encode_base64($json, "");
+
+	return "$base_connstr oauth_client_id=$encoded";
+}
+
+# Make sure the param system works end-to-end first.
+$node->connect_ok(
+	connstr(),
+	"connect to /param",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$node->connect_ok(
+	connstr(stage => 'token', retries => 1),
+	"token retry",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(stage => 'token', retries => 2),
+	"token retry (twice)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(stage => 'all', retries => 1, interval => 2),
+	"token retry (two second interval)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(stage => 'all', retries => 1, interval => JSON::PP::null),
+	"token retry (default interval)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$node->connect_ok(
+	connstr(stage => 'all', content_type => 'application/json;charset=utf-8'),
+	"content type with charset",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(
+		stage => 'all',
+		content_type => "application/json \t;\t charset=utf-8"),
+	"content type with charset (whitespace)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(stage => 'device', uri_spelling => "verification_url"),
+	"alternative spelling of verification_uri",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$node->connect_fails(
+	connstr(stage => 'device', huge_response => JSON::PP::true),
+	"bad device authz response: overlarge JSON",
+	expected_stderr =>
+	  qr/failed to obtain device authorization: response is too large/);
+$node->connect_fails(
+	connstr(stage => 'token', huge_response => JSON::PP::true),
+	"bad token response: overlarge JSON",
+	expected_stderr =>
+	  qr/failed to obtain access token: response is too large/);
+
+$node->connect_fails(
+	connstr(stage => 'device', content_type => 'text/plain'),
+	"bad device authz response: wrong content type",
+	expected_stderr =>
+	  qr/failed to parse device authorization: unexpected content type/);
+$node->connect_fails(
+	connstr(stage => 'token', content_type => 'text/plain'),
+	"bad token response: wrong content type",
+	expected_stderr =>
+	  qr/failed to parse access token response: unexpected content type/);
+$node->connect_fails(
+	connstr(stage => 'token', content_type => 'application/jsonx'),
+	"bad token response: wrong content type (correct prefix)",
+	expected_stderr =>
+	  qr/failed to parse access token response: unexpected content type/);
+
+$node->connect_fails(
+	connstr(
+		stage => 'all',
+		interval => ~0,
+		retries => 1,
+		retry_code => "slow_down"),
+	"bad token response: server overflows the device authz interval",
+	expected_stderr =>
+	  qr/failed to obtain access token: slow_down interval overflow/);
+
+$node->connect_fails(
+	connstr(stage => 'token', error_code => "invalid_grant"),
+	"bad token response: invalid_grant, no description",
+	expected_stderr => qr/failed to obtain access token: \(invalid_grant\)/);
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_grant",
+		error_desc => "grant expired"),
+	"bad token response: expired grant",
+	expected_stderr =>
+	  qr/failed to obtain access token: grant expired \(invalid_grant\)/);
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_client",
+		error_status => 401),
+	"bad token response: client authentication failure, default description",
+	expected_stderr =>
+	  qr/failed to obtain access token: provider requires client authentication, and no oauth_client_secret is set \(invalid_client\)/
+);
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_client",
+		error_status => 401,
+		error_desc => "authn failure"),
+	"bad token response: client authentication failure, provided description",
+	expected_stderr =>
+	  qr/failed to obtain access token: authn failure \(invalid_client\)/);
+
+$node->connect_fails(
+	connstr(stage => 'token', token => ""),
+	"server rejects access: empty token",
+	expected_stderr => qr/bearer authentication failed/);
+$node->connect_fails(
+	connstr(stage => 'token', token => "****"),
+	"server rejects access: invalid token contents",
+	expected_stderr => qr/bearer authentication failed/);
+
+# Test behavior of the oauth_client_secret.
+$base_connstr = "$common_connstr oauth_client_secret=''";
+
+$node->connect_ok(
+	connstr(stage => 'all', expected_secret => ''),
+	"empty oauth_client_secret",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$base_connstr = "$common_connstr oauth_client_secret='$vschars_esc'";
+
+$node->connect_ok(
+	connstr(stage => 'all', expected_secret => $vschars),
+	"nonempty oauth_client_secret",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_client",
+		error_status => 401),
+	"bad token response: client authentication failure, default description with oauth_client_secret",
+	expected_stderr =>
+	  qr/failed to obtain access token: provider rejected the oauth_client_secret \(invalid_client\)/
+);
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_client",
+		error_status => 401,
+		error_desc => "mutual TLS required for client"),
+	"bad token response: client authentication failure, provided description with oauth_client_secret",
+	expected_stderr =>
+	  qr/failed to obtain access token: mutual TLS required for client \(invalid_client\)/
+);
+
+#
+# This section of tests reconfigures the validator module itself, rather than
+# the OAuth server.
+#
+
+# Searching the logs is easier if OAuth parameter discovery isn't cluttering
+# things up; hardcode the discovery URI. (Scope is hardcoded to empty to cover
+# that case as well.)
+$common_connstr =
+  "dbname=postgres oauth_issuer=$issuer/.well-known/openid-configuration oauth_scope='' oauth_client_id=f02c6361-0635";
+
+# Misbehaving validators must fail shut.
+$bgconn->query_safe("ALTER SYSTEM SET oauth_validator.authn_id TO ''");
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+$node->connect_fails(
+	"$common_connstr user=test",
+	"validator must set authn_id",
+	expected_stderr => qr/OAuth bearer authentication failed/,
+	log_like => [
+		qr/connection authenticated: identity=""/,
+		qr/DETAIL:\s+Validator provided no identity/,
+		qr/FATAL:\s+OAuth bearer authentication failed/,
+	]);
+
+# Even if a validator authenticates the user, if the token isn't considered
+# valid, the connection fails.
+$bgconn->query_safe(
+	"ALTER SYSTEM SET oauth_validator.authn_id TO 'test\@example.org'");
+$bgconn->query_safe(
+	"ALTER SYSTEM SET oauth_validator.authorize_tokens TO false");
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+$node->connect_fails(
+	"$common_connstr user=test",
+	"validator must authorize token explicitly",
+	expected_stderr => qr/OAuth bearer authentication failed/,
+	log_like => [
+		qr/connection authenticated: identity="test\@example\.org"/,
+		qr/DETAIL:\s+Validator failed to authorize the provided token/,
+		qr/FATAL:\s+OAuth bearer authentication failed/,
+	]);
+
+#
+# Test user mapping.
+#
+
+# Allow "[email protected]" to log in under the test role.
+unlink($node->data_dir . '/pg_ident.conf');
+$node->append_conf(
+	'pg_ident.conf', qq{
+oauthmap	user\@example.com	test
+});
+
+# test and testalt use the map; testparam uses ident delegation.
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test      oauth issuer="$issuer" scope="" map=oauthmap
+local all testalt   oauth issuer="$issuer" scope="" map=oauthmap
+local all testparam oauth issuer="$issuer" scope="" delegate_ident_mapping=1
+});
+
+# To start, have the validator use the role names as authn IDs.
+$bgconn->query_safe("ALTER SYSTEM RESET oauth_validator.authn_id");
+$bgconn->query_safe("ALTER SYSTEM RESET oauth_validator.authorize_tokens");
+
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+# The test and testalt roles should no longer map correctly.
+$node->connect_fails(
+	"$common_connstr user=test",
+	"mismatched username map (test)",
+	expected_stderr => qr/OAuth bearer authentication failed/);
+$node->connect_fails(
+	"$common_connstr user=testalt",
+	"mismatched username map (testalt)",
+	expected_stderr => qr/OAuth bearer authentication failed/);
+
+# Have the validator identify the end user as [email protected].
+$bgconn->query_safe(
+	"ALTER SYSTEM SET oauth_validator.authn_id TO 'user\@example.com'");
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+# Now the test role can be logged into. (testalt still can't be mapped.)
+$node->connect_ok(
+	"$common_connstr user=test",
+	"matched username map (test)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_fails(
+	"$common_connstr user=testalt",
+	"mismatched username map (testalt)",
+	expected_stderr => qr/OAuth bearer authentication failed/);
+
+# testparam ignores the map entirely.
+$node->connect_ok(
+	"$common_connstr user=testparam",
+	"delegated ident (testparam)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$bgconn->query_safe("ALTER SYSTEM RESET oauth_validator.authn_id");
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+#
+# Test multiple validators.
+#
+
+$node->append_conf('postgresql.conf',
+	"oauth_validator_libraries = 'validator, fail_validator'\n");
+
+# With multiple validators, every HBA line must explicitly declare one.
+my $result = $node->restart(fail_ok => 1);
+is($result, 0,
+	'restart fails without explicit validators in oauth HBA entries');
+
+$log_start = $node->wait_for_log(
+	qr/authentication method "oauth" requires argument "validator" to be set/,
+	$log_start);
+
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test    oauth validator=validator      issuer="$issuer"           scope="openid postgres"
+local all testalt oauth validator=fail_validator issuer="$issuer/.well-known/oauth-authorization-server/alternate" scope="openid postgres alt"
+});
+$node->restart;
+
+$log_start = $node->wait_for_log(qr/ready to accept connections/, $log_start);
+
+# The test user should work as before.
+$user = "test";
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635",
+	"validator is used for $user",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@,
+	log_like => [qr/connection authorized/]);
+
+# testalt should be routed through the fail_validator.
+$user = "testalt";
+$node->connect_fails(
+	"user=$user dbname=postgres oauth_issuer=$issuer/.well-known/oauth-authorization-server/alternate oauth_client_id=f02c6361-0636",
+	"fail_validator is used for $user",
+	expected_stderr => qr/FATAL:\s+fail_validator: sentinel error/);
+
+$node->stop;
+
+done_testing();
diff --git a/src/test/modules/oauth_validator/t/002_client.pl b/src/test/modules/oauth_validator/t/002_client.pl
new file mode 100644
index 0000000000..95cccf90dd
--- /dev/null
+++ b/src/test/modules/oauth_validator/t/002_client.pl
@@ -0,0 +1,154 @@
+#
+# Exercises the API for custom OAuth client flows, using the oauth_hook_client
+# test driver.
+#
+# Copyright (c) 2021-2024, PostgreSQL Global Development Group
+#
+
+use strict;
+use warnings FATAL => 'all';
+
+use JSON::PP     qw(encode_json);
+use MIME::Base64 qw(encode_base64);
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+if (!$ENV{PG_TEST_EXTRA} || $ENV{PG_TEST_EXTRA} !~ /\boauth\b/)
+{
+	plan skip_all =>
+	  'Potentially unsafe test oauth not enabled in PG_TEST_EXTRA';
+}
+
+#
+# Cluster Setup
+#
+
+my $node = PostgreSQL::Test::Cluster->new('primary');
+$node->init;
+$node->append_conf('postgresql.conf', "log_connections = on\n");
+$node->append_conf('postgresql.conf',
+	"oauth_validator_libraries = 'validator'\n");
+$node->start;
+
+$node->safe_psql('postgres', 'CREATE USER test;');
+
+# These tests don't use the builtin flow, and we don't have an authorization
+# server running, so the address used here shouldn't matter. Use an invalid IP
+# address, so if there's some cascade of errors that causes the client to
+# attempt a connection, we'll fail noisily.
+my $issuer = "https://256.256.256.256";
+my $scope = "openid postgres";
+
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test oauth issuer="$issuer" scope="$scope"
+});
+$node->reload;
+
+my ($log_start, $log_end);
+$log_start = $node->wait_for_log(qr/reloading configuration files/);
+
+$ENV{PGOAUTHDEBUG} = "UNSAFE";
+
+#
+# Tests
+#
+
+my $user = "test";
+my $base_connstr = $node->connstr() . " user=$user";
+my $common_connstr =
+  "$base_connstr oauth_issuer=$issuer oauth_client_id=myID";
+
+sub test
+{
+	my ($test_name, %params) = @_;
+
+	my $flags = [];
+	if (defined($params{flags}))
+	{
+		$flags = $params{flags};
+	}
+
+	my @cmd = ("oauth_hook_client", @{$flags}, $common_connstr);
+	note "running '" . join("' '", @cmd) . "'";
+
+	my ($stdout, $stderr) = run_command(\@cmd);
+
+	if (defined($params{expected_stdout}))
+	{
+		like($stdout, $params{expected_stdout}, "$test_name: stdout matches");
+	}
+
+	if (defined($params{expected_stderr}))
+	{
+		like($stderr, $params{expected_stderr}, "$test_name: stderr matches");
+	}
+	else
+	{
+		is($stderr, "", "$test_name: no stderr");
+	}
+}
+
+test(
+	"basic synchronous hook can provide a token",
+	flags => [
+		"--token", "my-token",
+		"--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"/, ]);
+
+if ($ENV{with_libcurl} ne 'yes')
+{
+	# libpq should help users out if no OAuth support is built in.
+	test(
+		"fails without custom hook installed",
+		flags => ["--no-hook"],
+		expected_stderr =>
+		  qr/no custom OAuth flows are available, and libpq was not built with libcurl support/
+	);
+}
+
+# connect_timeout should work if the flow doesn't respond.
+$common_connstr = "$common_connstr connect_timeout=1";
+test(
+	"connect_timeout interrupts hung client flow",
+	flags => ["--hang-forever"],
+	expected_stderr => qr/failed: timeout expired/);
+
+# Test various misbehaviors of the client hook.
+my @cases = (
+	{
+		flag => "--misbehave=no-hook",
+		expected_error =>
+		  qr/user-defined OAuth flow provided neither a token nor an async callback/,
+	},
+	{
+		flag => "--misbehave=fail-async",
+		expected_error => qr/user-defined OAuth flow failed/,
+	},
+	{
+		flag => "--misbehave=no-token",
+		expected_error => qr/user-defined OAuth flow did not provide a token/,
+	},
+	{
+		flag => "--misbehave=no-socket",
+		expected_error =>
+		  qr/user-defined OAuth flow did not provide a socket for polling/,
+	});
+
+foreach my $c (@cases)
+{
+	test(
+		"hook misbehavior: $c->{'flag'}",
+		flags => [ $c->{'flag'} ],
+		expected_stderr => $c->{'expected_error'});
+}
+
+done_testing();
diff --git a/src/test/modules/oauth_validator/t/OAuth/Server.pm b/src/test/modules/oauth_validator/t/OAuth/Server.pm
new file mode 100644
index 0000000000..f0f23d1d1a
--- /dev/null
+++ b/src/test/modules/oauth_validator/t/OAuth/Server.pm
@@ -0,0 +1,140 @@
+
+# Copyright (c) 2024, PostgreSQL Global Development Group
+
+=pod
+
+=head1 NAME
+
+OAuth::Server - runs a mock OAuth authorization server for testing
+
+=head1 SYNOPSIS
+
+  use OAuth::Server;
+
+  my $server = OAuth::Server->new();
+  $server->run;
+
+  my $port = $server->port;
+  my $issuer = "http://localhost:$port";
+
+  # test against $issuer...
+
+  $server->stop;
+
+=head1 DESCRIPTION
+
+This is glue API between the Perl tests and the Python authorization server
+daemon implemented in t/oauth_server.py. (Python has a fairly usable HTTP server
+in its standard library, so the implementation was ported from Perl.)
+
+This authorization server does not use TLS (it implements a nonstandard, unsafe
+issuer at "http://localhost:<port>"), so libpq in particular will need to set
+PGOAUTHDEBUG=UNSAFE to be able to talk to it.
+
+=cut
+
+package OAuth::Server;
+
+use warnings;
+use strict;
+use Scalar::Util;
+use Test::More;
+
+=pod
+
+=head1 METHODS
+
+=over
+
+=item SSL::Server->new()
+
+Create a new OAuth Server object.
+
+=cut
+
+sub new
+{
+	my $class = shift;
+
+	my $self = {};
+	bless($self, $class);
+
+	return $self;
+}
+
+=pod
+
+=item $server->port()
+
+Returns the port in use by the server.
+
+=cut
+
+sub port
+{
+	my $self = shift;
+
+	return $self->{'port'};
+}
+
+=pod
+
+=item $server->run()
+
+Runs the authorization server daemon in t/oauth_server.py.
+
+=cut
+
+sub run
+{
+	my $self = shift;
+	my $port;
+
+	my $pid = open(my $read_fh, "-|", $ENV{PYTHON}, "t/oauth_server.py")
+	  or die "failed to start OAuth server: $!";
+
+	# Get the port number from the daemon. It closes stdout afterwards; that way
+	# we can slurp in the entire contents here rather than worrying about the
+	# number of bytes to read.
+	$port = do { local $/ = undef; <$read_fh> }
+	  // die "failed to read port number: $!";
+	chomp $port;
+	die "server did not advertise a valid port"
+	  unless Scalar::Util::looks_like_number($port);
+
+	$self->{'pid'} = $pid;
+	$self->{'port'} = $port;
+	$self->{'child'} = $read_fh;
+
+	note("OAuth provider (PID $pid) is listening on port $port\n");
+}
+
+=pod
+
+=item $server->stop()
+
+Sends SIGTERM to the authorization server and waits for it to exit.
+
+=cut
+
+sub stop
+{
+	my $self = shift;
+
+	note("Sending SIGTERM to OAuth provider PID: $self->{'pid'}\n");
+
+	kill(15, $self->{'pid'});
+	$self->{'pid'} = undef;
+
+	# Closing the popen() handle waits for the process to exit.
+	close($self->{'child'});
+	$self->{'child'} = undef;
+}
+
+=pod
+
+=back
+
+=cut
+
+1;
diff --git a/src/test/modules/oauth_validator/t/oauth_server.py b/src/test/modules/oauth_validator/t/oauth_server.py
new file mode 100755
index 0000000000..8ec0910202
--- /dev/null
+++ b/src/test/modules/oauth_validator/t/oauth_server.py
@@ -0,0 +1,391 @@
+#! /usr/bin/env python3
+#
+# A mock OAuth authorization server, designed to be invoked from
+# OAuth/Server.pm. This listens on an ephemeral port number (printed to stdout
+# so that the Perl tests can contact it) and runs as a daemon until it is
+# signaled.
+#
+
+import base64
+import http.server
+import json
+import os
+import sys
+import time
+import urllib.parse
+from collections import defaultdict
+
+
+class OAuthHandler(http.server.BaseHTTPRequestHandler):
+    """
+    Core implementation of the authorization server. The API is
+    inheritance-based, with entry points at do_GET() and do_POST(). See the
+    documentation for BaseHTTPRequestHandler.
+    """
+
+    JsonObject = dict[str, object]  # TypeAlias is not available until 3.10
+
+    def _check_issuer(self):
+        """
+        Switches the behavior of the provider depending on the issuer URI.
+        """
+        self._alt_issuer = (
+            self.path.startswith("/alternate/")
+            or self.path == "/.well-known/oauth-authorization-server/alternate"
+        )
+        self._parameterized = self.path.startswith("/param/")
+
+        if self._alt_issuer:
+            # The /alternate issuer uses IETF-style .well-known URIs.
+            if self.path.startswith("/.well-known/"):
+                self.path = self.path.removesuffix("/alternate")
+            else:
+                self.path = self.path.removeprefix("/alternate")
+        elif self._parameterized:
+            self.path = self.path.removeprefix("/param")
+
+    def _check_authn(self):
+        """
+        Checks the expected value of the Authorization header, if any.
+        """
+        secret = self._get_param("expected_secret", None)
+        if secret is None:
+            return
+
+        assert "Authorization" in self.headers
+        method, creds = self.headers["Authorization"].split()
+
+        if method != "Basic":
+            raise RuntimeError(f"client used {method} auth; expected Basic")
+
+        username = urllib.parse.quote_plus(self.client_id)
+        password = urllib.parse.quote_plus(secret)
+        expected_creds = f"{username}:{password}"
+
+        if creds.encode() != base64.b64encode(expected_creds.encode()):
+            raise RuntimeError(
+                f"client sent '{creds}'; expected b64encode('{expected_creds}')"
+            )
+
+    def do_GET(self):
+        self._response_code = 200
+        self._check_issuer()
+
+        config_path = "/.well-known/openid-configuration"
+        if self._alt_issuer:
+            config_path = "/.well-known/oauth-authorization-server"
+
+        if self.path == config_path:
+            resp = self.config()
+        else:
+            self.send_error(404, "Not Found")
+            return
+
+        self._send_json(resp)
+
+    def _parse_params(self) -> dict[str, str]:
+        """
+        Parses apart the form-urlencoded request body and returns the resulting
+        dict. For use by do_POST().
+        """
+        size = int(self.headers["Content-Length"])
+        form = self.rfile.read(size)
+
+        assert self.headers["Content-Type"] == "application/x-www-form-urlencoded"
+        return urllib.parse.parse_qs(
+            form.decode("utf-8"),
+            strict_parsing=True,
+            keep_blank_values=True,
+            encoding="utf-8",
+            errors="strict",
+        )
+
+    @property
+    def client_id(self) -> str:
+        """
+        Returns the client_id sent in the POST body or the Authorization header.
+        self._parse_params() must have been called first.
+        """
+        if "client_id" in self._params:
+            return self._params["client_id"][0]
+
+        if "Authorization" not in self.headers:
+            raise RuntimeError("client did not send any client_id")
+
+        _, creds = self.headers["Authorization"].split()
+
+        decoded = base64.b64decode(creds).decode("utf-8")
+        username, _ = decoded.split(":", 1)
+
+        return urllib.parse.unquote_plus(username)
+
+    def do_POST(self):
+        self._response_code = 200
+        self._check_issuer()
+
+        self._params = self._parse_params()
+        if self._parameterized:
+            # Pull encoded test parameters out of the peer's client_id field.
+            # This is expected to be Base64-encoded JSON.
+            js = base64.b64decode(self.client_id)
+            self._test_params = json.loads(js)
+
+        self._check_authn()
+
+        if self.path == "/authorize":
+            resp = self.authorization()
+        elif self.path == "/token":
+            resp = self.token()
+        else:
+            self.send_error(404)
+            return
+
+        self._send_json(resp)
+
+    def _should_modify(self) -> bool:
+        """
+        Returns True if the client has requested a modification to this stage of
+        the exchange.
+        """
+        if not hasattr(self, "_test_params"):
+            return False
+
+        stage = self._test_params.get("stage")
+
+        return (
+            stage == "all"
+            or (
+                stage == "discovery"
+                and self.path == "/.well-known/openid-configuration"
+            )
+            or (stage == "device" and self.path == "/authorize")
+            or (stage == "token" and self.path == "/token")
+        )
+
+    def _get_param(self, name, default):
+        """
+        If the client has requested a modification to this stage (see
+        _should_modify()), this method searches the provided test parameters for
+        a key of the given name, and returns it if found. Otherwise the provided
+        default is returned.
+        """
+        if self._should_modify() and name in self._test_params:
+            return self._test_params[name]
+
+        return default
+
+    @property
+    def _content_type(self) -> str:
+        """
+        Returns "application/json" unless the test has requested something
+        different.
+        """
+        return self._get_param("content_type", "application/json")
+
+    @property
+    def _interval(self) -> int:
+        """
+        Returns 0 unless the test has requested something different.
+        """
+        return self._get_param("interval", 0)
+
+    @property
+    def _retry_code(self) -> str:
+        """
+        Returns "authorization_pending" unless the test has requested something
+        different.
+        """
+        return self._get_param("retry_code", "authorization_pending")
+
+    @property
+    def _uri_spelling(self) -> str:
+        """
+        Returns "verification_uri" unless the test has requested something
+        different.
+        """
+        return self._get_param("uri_spelling", "verification_uri")
+
+    @property
+    def _response_padding(self):
+        """
+        If the huge_response test parameter is set to True, returns a dict
+        containing a gigantic string value, which can then be folded into a JSON
+        response.
+        """
+        if not self._get_param("huge_response", False):
+            return dict()
+
+        return {"_pad_": "x" * 1024 * 1024}
+
+    @property
+    def _access_token(self):
+        """
+        The actual Bearer token sent back to the client on success. Tests may
+        override this with the "token" test parameter.
+        """
+        token = self._get_param("token", None)
+        if token is not None:
+            return token
+
+        token = "9243959234"
+        if self._alt_issuer:
+            token += "-alt"
+
+        return token
+
+    def _send_json(self, js: JsonObject) -> None:
+        """
+        Sends the provided JSON dict as an application/json response.
+        self._response_code can be modified to send JSON error responses.
+        """
+        resp = json.dumps(js).encode("ascii")
+        self.log_message("sending JSON response: %s", resp)
+
+        self.send_response(self._response_code)
+        self.send_header("Content-Type", self._content_type)
+        self.send_header("Content-Length", str(len(resp)))
+        self.end_headers()
+
+        self.wfile.write(resp)
+
+    def config(self) -> JsonObject:
+        port = self.server.socket.getsockname()[1]
+
+        issuer = f"http://localhost:{port}"
+        if self._alt_issuer:
+            issuer += "/alternate"
+        elif self._parameterized:
+            issuer += "/param"
+
+        return {
+            "issuer": issuer,
+            "token_endpoint": issuer + "/token",
+            "device_authorization_endpoint": issuer + "/authorize",
+            "response_types_supported": ["token"],
+            "subject_types_supported": ["public"],
+            "id_token_signing_alg_values_supported": ["RS256"],
+            "grant_types_supported": [
+                "authorization_code",
+                "urn:ietf:params:oauth:grant-type:device_code",
+            ],
+        }
+
+    @property
+    def _token_state(self):
+        """
+        A cached _TokenState object for the connected client (as determined by
+        the request's client_id), or a new one if it doesn't already exist.
+
+        This relies on the existence of a defaultdict attached to the server;
+        see main() below.
+        """
+        return self.server.token_state[self.client_id]
+
+    def _remove_token_state(self):
+        """
+        Removes any cached _TokenState for the current client_id. Call this
+        after the token exchange ends to get rid of unnecessary state.
+        """
+        if self.client_id in self.server.token_state:
+            del self.server.token_state[self.client_id]
+
+    def authorization(self) -> JsonObject:
+        uri = "https://example.com/"
+        if self._alt_issuer:
+            uri = "https://example.org/"
+
+        resp = {
+            "device_code": "postgres",
+            "user_code": "postgresuser",
+            self._uri_spelling: uri,
+            "expires-in": 5,
+            **self._response_padding,
+        }
+
+        interval = self._interval
+        if interval is not None:
+            resp["interval"] = interval
+            self._token_state.min_delay = interval
+        else:
+            self._token_state.min_delay = 5  # default
+
+        # Check the scope.
+        if "scope" in self._params:
+            assert self._params["scope"][0], "empty scopes should be omitted"
+
+        return resp
+
+    def token(self) -> JsonObject:
+        if err := self._get_param("error_code", None):
+            self._response_code = self._get_param("error_status", 400)
+
+            resp = {"error": err}
+            if desc := self._get_param("error_desc", ""):
+                resp["error_description"] = desc
+
+            return resp
+
+        if self._should_modify() and "retries" in self._test_params:
+            retries = self._test_params["retries"]
+
+            # Check to make sure the token interval is being respected.
+            now = time.monotonic()
+            if self._token_state.last_try is not None:
+                delay = now - self._token_state.last_try
+                assert (
+                    delay > self._token_state.min_delay
+                ), f"client waited only {delay} seconds between token requests (expected {self._token_state.min_delay})"
+
+            self._token_state.last_try = now
+
+            # If we haven't reached the required number of retries yet, return a
+            # "pending" response.
+            if self._token_state.retries < retries:
+                self._token_state.retries += 1
+
+                self._response_code = 400
+                return {"error": self._retry_code}
+
+        # Clean up any retry tracking state now that the exchange is ending.
+        self._remove_token_state()
+
+        return {
+            "access_token": self._access_token,
+            "token_type": "bearer",
+            **self._response_padding,
+        }
+
+
+def main():
+    """
+    Starts the authorization server on localhost. The ephemeral port in use will
+    be printed to stdout.
+    """
+
+    s = http.server.HTTPServer(("127.0.0.1", 0), OAuthHandler)
+
+    # Attach a "cache" dictionary to the server to allow the OAuthHandlers to
+    # track state across token requests. The use of defaultdict ensures that new
+    # entries will be created automatically.
+    class _TokenState:
+        retries = 0
+        min_delay = None
+        last_try = None
+
+    s.token_state = defaultdict(_TokenState)
+
+    # Give the parent the port number to contact (this is also the signal that
+    # we're ready to receive requests).
+    port = s.socket.getsockname()[1]
+    print(port)
+
+    # stdout is closed to allow the parent to just "read to the end".
+    stdout = sys.stdout.fileno()
+    sys.stdout.close()
+    os.close(stdout)
+
+    s.serve_forever()  # we expect our parent to send a termination signal
+
+
+if __name__ == "__main__":
+    main()
diff --git a/src/test/modules/oauth_validator/validator.c b/src/test/modules/oauth_validator/validator.c
new file mode 100644
index 0000000000..bf94f091de
--- /dev/null
+++ b/src/test/modules/oauth_validator/validator.c
@@ -0,0 +1,135 @@
+/*-------------------------------------------------------------------------
+ *
+ * validator.c
+ *	  Test module for serverside OAuth token validation callbacks
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/test/modules/oauth_validator/validator.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "fmgr.h"
+#include "libpq/oauth.h"
+#include "miscadmin.h"
+#include "utils/guc.h"
+#include "utils/memutils.h"
+
+PG_MODULE_MAGIC;
+
+static void validator_startup(ValidatorModuleState *state);
+static void validator_shutdown(ValidatorModuleState *state);
+static ValidatorModuleResult *validate_token(ValidatorModuleState *state,
+											 const char *token,
+											 const char *role);
+
+/* Callback implementations (exercise all three) */
+static const OAuthValidatorCallbacks validator_callbacks = {
+	.startup_cb = validator_startup,
+	.shutdown_cb = validator_shutdown,
+	.validate_cb = validate_token
+};
+
+/* GUCs */
+static char *authn_id = NULL;
+static bool authorize_tokens = true;
+
+/*---
+ * Extension entry point. Sets up GUCs for use by tests:
+ *
+ * - oauth_validator.authn_id	Sets the user identifier to return during token
+ *								validation. Defaults to the username in the
+ *								startup packet.
+ *
+ * - oauth_validator.authorize_tokens
+ *								Sets whether to successfully validate incoming
+ *								tokens. Defaults to true.
+ */
+void
+_PG_init(void)
+{
+	DefineCustomStringVariable("oauth_validator.authn_id",
+							   "Authenticated identity to use for future connections",
+							   NULL,
+							   &authn_id,
+							   NULL,
+							   PGC_SIGHUP,
+							   0,
+							   NULL, NULL, NULL);
+	DefineCustomBoolVariable("oauth_validator.authorize_tokens",
+							 "Should tokens be marked valid?",
+							 NULL,
+							 &authorize_tokens,
+							 true,
+							 PGC_SIGHUP,
+							 0,
+							 NULL, NULL, NULL);
+
+	MarkGUCPrefixReserved("oauth_validator");
+}
+
+/*
+ * Validator module entry point.
+ */
+const OAuthValidatorCallbacks *
+_PG_oauth_validator_module_init(void)
+{
+	return &validator_callbacks;
+}
+
+#define PRIVATE_COOKIE ((void *) 13579)
+
+/*
+ * Startup callback, to set up private data for the validator.
+ */
+static void
+validator_startup(ValidatorModuleState *state)
+{
+	state->private_data = PRIVATE_COOKIE;
+}
+
+/*
+ * Shutdown callback, to tear down the validator.
+ */
+static void
+validator_shutdown(ValidatorModuleState *state)
+{
+	/* Check to make sure our private state still exists. */
+	if (state->private_data != PRIVATE_COOKIE)
+		elog(ERROR, "oauth_validator: private state cookie changed to %p in shutdown",
+			 state->private_data);
+}
+
+/*
+ * Validator implementation. Logs the incoming data and authorizes the token by
+ * default; the behavior can be modified via the module's GUC settings.
+ */
+static ValidatorModuleResult *
+validate_token(ValidatorModuleState *state, const char *token, const char *role)
+{
+	ValidatorModuleResult *res;
+
+	/* Check to make sure our private state still exists. */
+	if (state->private_data != PRIVATE_COOKIE)
+		elog(ERROR, "oauth_validator: private state cookie changed to %p in validate",
+			 state->private_data);
+
+	res = palloc(sizeof(ValidatorModuleResult));
+
+	elog(LOG, "oauth_validator: token=\"%s\", role=\"%s\"", token, role);
+	elog(LOG, "oauth_validator: issuer=\"%s\", scope=\"%s\"",
+		 MyProcPort->hba->oauth_issuer,
+		 MyProcPort->hba->oauth_scope);
+
+	res->authorized = authorize_tokens;
+	if (authn_id)
+		res->authn_id = pstrdup(authn_id);
+	else
+		res->authn_id = pstrdup(role);
+
+	return res;
+}
diff --git a/src/test/perl/PostgreSQL/Test/Cluster.pm b/src/test/perl/PostgreSQL/Test/Cluster.pm
index f521ad0b12..ab7d7452ed 100644
--- a/src/test/perl/PostgreSQL/Test/Cluster.pm
+++ b/src/test/perl/PostgreSQL/Test/Cluster.pm
@@ -2515,6 +2515,11 @@ instead of the default.
 
 If this regular expression is set, matches it with the output generated.
 
+=item expected_stderr => B<value>
+
+If this regular expression is set, matches it against the standard error
+stream; otherwise the stderr must be empty.
+
 =item log_like => [ qr/required message/ ]
 
 =item log_unlike => [ qr/prohibited message/ ]
@@ -2558,7 +2563,20 @@ sub connect_ok
 		like($stdout, $params{expected_stdout}, "$test_name: stdout matches");
 	}
 
-	is($stderr, "", "$test_name: no stderr");
+	if (defined($params{expected_stderr}))
+	{
+		if (like($stderr, $params{expected_stderr}, "$test_name: stderr matches")
+			&& ($ret != 0))
+		{
+			# In this case (failing test but matching stderr) we'll have
+			# swallowed the output needed to debug. Put it back into the logs.
+			diag("$test_name: full stderr:\n" . $stderr);
+		}
+	}
+	else
+	{
+		is($stderr, "", "$test_name: no stderr");
+	}
 
 	$self->log_check($test_name, $log_location, %params);
 }
diff --git a/src/tools/pgindent/pgindent b/src/tools/pgindent/pgindent
index d8acce7e92..7dccf4614a 100755
--- a/src/tools/pgindent/pgindent
+++ b/src/tools/pgindent/pgindent
@@ -242,6 +242,14 @@ sub pre_indent
 	# Protect wrapping in CATALOG()
 	$source =~ s!^(CATALOG\(.*)$!/*$1*/!gm;
 
+	# Treat a CURL_IGNORE_DEPRECATION() as braces for the purposes of
+	# indentation. (The recursive regex comes from the perlre documentation; it
+	# matches balanced parentheses as group $1 and the contents as group $2.)
+	my $curlopen = '{ /* CURL_IGNORE_DEPRECATION */';
+	my $curlclose = '} /* CURL_IGNORE_DEPRECATION */';
+	$source =~
+	  s!^[ \t]+CURL_IGNORE_DEPRECATION(\(((?:(?>[^()]+)|(?1))*)\))!$curlopen$2$curlclose!gms;
+
 	return $source;
 }
 
@@ -256,6 +264,12 @@ sub post_indent
 	$source =~ s!^/\* Open extern "C" \*/$!{!gm;
 	$source =~ s!^/\* Close extern "C" \*/$!}!gm;
 
+	# Restore the CURL_IGNORE_DEPRECATION() macro, keeping in mind that our
+	# markers may have been re-indented.
+	$source =~
+	  s!{[ \t]+/\* CURL_IGNORE_DEPRECATION \*/!CURL_IGNORE_DEPRECATION(!gm;
+	$source =~ s!}[ \t]+/\* CURL_IGNORE_DEPRECATION \*/!)!gm;
+
 	## Comments
 
 	# Undo change of dash-protected block comments
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index a2644a2e65..f5e29b2cc9 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -371,6 +371,9 @@ CState
 CTECycleClause
 CTEMaterialize
 CTESearchClause
+CURL
+CURLM
+CURLoption
 CV
 CachedExpression
 CachedPlan
@@ -1724,6 +1727,7 @@ NumericDigit
 NumericSortSupport
 NumericSumAccum
 NumericVar
+OAuthValidatorCallbacks
 OM_uint32
 OP
 OSAPerGroupState
@@ -1832,6 +1836,7 @@ PGVerbosity
 PG_Locale_Strategy
 PG_Lock_Status
 PG_init_t
+PGauthData
 PGcancel
 PGcancelConn
 PGcmdQueueEntry
@@ -1839,7 +1844,9 @@ PGconn
 PGdataValue
 PGlobjfuncs
 PGnotify
+PGoauthBearerRequest
 PGpipelineStatus
+PGpromptOAuthDevice
 PGresAttDesc
 PGresAttValue
 PGresParamDesc
@@ -1951,6 +1958,7 @@ PQArgBlock
 PQEnvironmentOption
 PQExpBuffer
 PQExpBufferData
+PQauthDataHook_type
 PQcommMethods
 PQconninfoOption
 PQnoticeProcessor
@@ -3089,6 +3097,8 @@ VacuumRelation
 VacuumStmt
 ValidIOData
 ValidateIndexState
+ValidatorModuleState
+ValidatorModuleResult
 ValuesScan
 ValuesScanState
 Var
@@ -3483,6 +3493,7 @@ explain_get_index_name_hook_type
 f_smgr
 fasthash_state
 fd_set
+fe_oauth_state
 fe_scram_state
 fe_scram_state_enum
 fetch_range_request
-- 
2.39.3 (Apple Git-146)



  [application/octet-stream] v45-0001-libpq-handle-asynchronous-actions-during-SASL.patch (18.1K, ../../[email protected]/5-v45-0001-libpq-handle-asynchronous-actions-during-SASL.patch)
  download | inline diff:
From 3b34da567c98c856dec4c6f736749799b2f679e7 Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Wed, 8 Jan 2025 09:30:05 -0800
Subject: [PATCH v45 1/4] libpq: handle asynchronous actions during SASL

This adds the ability for a SASL mechanism to signal to PQconnectPoll()
that some arbitrary work must be done, external to the Postgres
connection, before authentication can continue. The intent is for the
upcoming OAUTHBEARER mechanism to make use of this functionality.

To ensure that threads are not blocked waiting for the SASL mechanism to
make long-running calls, the mechanism communicates with the top-level
client via the "altsock": a file or socket descriptor, opaque to this
layer of libpq, which is signaled when work is ready to be done again.
This socket temporarily takes the place of the standard connection
descriptor, so PQsocket() clients should continue to operate correctly
using their existing polling implementations.

A mechanism should set an authentication callback (conn->async_auth())
and a cleanup callback (conn->cleanup_async_auth()), return SASL_ASYNC
during the exchange, and assign conn->altsock during the first call to
async_auth(). When the cleanup callback is called, either because
authentication has succeeded or because the connection is being
dropped, the altsock must be released and disconnected from the PGconn.
---
 src/interfaces/libpq/fe-auth-sasl.h  |  11 ++-
 src/interfaces/libpq/fe-auth-scram.c |   6 +-
 src/interfaces/libpq/fe-auth.c       | 120 ++++++++++++++++++++-------
 src/interfaces/libpq/fe-auth.h       |   3 +-
 src/interfaces/libpq/fe-connect.c    |  93 ++++++++++++++++++++-
 src/interfaces/libpq/fe-misc.c       |  35 +++++---
 src/interfaces/libpq/libpq-fe.h      |   2 +
 src/interfaces/libpq/libpq-int.h     |   6 ++
 8 files changed, 227 insertions(+), 49 deletions(-)

diff --git a/src/interfaces/libpq/fe-auth-sasl.h b/src/interfaces/libpq/fe-auth-sasl.h
index f0c6213909..f06f547c07 100644
--- a/src/interfaces/libpq/fe-auth-sasl.h
+++ b/src/interfaces/libpq/fe-auth-sasl.h
@@ -30,6 +30,7 @@ typedef enum
 	SASL_COMPLETE = 0,
 	SASL_FAILED,
 	SASL_CONTINUE,
+	SASL_ASYNC,
 } SASLStatus;
 
 /*
@@ -77,6 +78,8 @@ typedef struct pg_fe_sasl_mech
 	 *
 	 *	state:	   The opaque mechanism state returned by init()
 	 *
+	 *	final:	   true if the server has sent a final exchange outcome
+	 *
 	 *	input:	   The challenge data sent by the server, or NULL when
 	 *			   generating a client-first initial response (that is, when
 	 *			   the server expects the client to send a message to start
@@ -101,12 +104,18 @@ typedef struct pg_fe_sasl_mech
 	 *
 	 *	SASL_CONTINUE:	The output buffer is filled with a client response.
 	 *					Additional server challenge is expected
+	 *	SASL_ASYNC:		Some asynchronous processing external to the
+	 *					connection needs to be done before a response can be
+	 *					generated. The mechanism is responsible for setting up
+	 *					conn->async_auth/cleanup_async_auth appropriately
+	 *					before returning.
 	 *	SASL_COMPLETE:	The SASL exchange has completed successfully.
 	 *	SASL_FAILED:	The exchange has failed and the connection should be
 	 *					dropped.
 	 *--------
 	 */
-	SASLStatus	(*exchange) (void *state, char *input, int inputlen,
+	SASLStatus	(*exchange) (void *state, bool final,
+							 char *input, int inputlen,
 							 char **output, int *outputlen);
 
 	/*--------
diff --git a/src/interfaces/libpq/fe-auth-scram.c b/src/interfaces/libpq/fe-auth-scram.c
index 557e9c568b..fe18615197 100644
--- a/src/interfaces/libpq/fe-auth-scram.c
+++ b/src/interfaces/libpq/fe-auth-scram.c
@@ -24,7 +24,8 @@
 /* The exported SCRAM callback mechanism. */
 static void *scram_init(PGconn *conn, const char *password,
 						const char *sasl_mechanism);
-static SASLStatus scram_exchange(void *opaq, char *input, int inputlen,
+static SASLStatus scram_exchange(void *opaq, bool final,
+								 char *input, int inputlen,
 								 char **output, int *outputlen);
 static bool scram_channel_bound(void *opaq);
 static void scram_free(void *opaq);
@@ -205,7 +206,8 @@ scram_free(void *opaq)
  * Exchange a SCRAM message with backend.
  */
 static SASLStatus
-scram_exchange(void *opaq, char *input, int inputlen,
+scram_exchange(void *opaq, bool final,
+			   char *input, int inputlen,
 			   char **output, int *outputlen)
 {
 	fe_scram_state *state = (fe_scram_state *) opaq;
diff --git a/src/interfaces/libpq/fe-auth.c b/src/interfaces/libpq/fe-auth.c
index 70753d8ec2..761ee8f88f 100644
--- a/src/interfaces/libpq/fe-auth.c
+++ b/src/interfaces/libpq/fe-auth.c
@@ -430,7 +430,7 @@ pg_SSPI_startup(PGconn *conn, int use_negotiate, int payloadlen)
  * Initialize SASL authentication exchange.
  */
 static int
-pg_SASL_init(PGconn *conn, int payloadlen)
+pg_SASL_init(PGconn *conn, int payloadlen, bool *async)
 {
 	char	   *initialresponse = NULL;
 	int			initialresponselen;
@@ -448,7 +448,7 @@ pg_SASL_init(PGconn *conn, int payloadlen)
 		goto error;
 	}
 
-	if (conn->sasl_state)
+	if (conn->sasl_state && !conn->async_auth)
 	{
 		libpq_append_conn_error(conn, "duplicate SASL authentication request");
 		goto error;
@@ -607,26 +607,54 @@ pg_SASL_init(PGconn *conn, int payloadlen)
 
 	Assert(conn->sasl);
 
-	/*
-	 * Initialize the SASL state information with all the information gathered
-	 * during the initial exchange.
-	 *
-	 * Note: Only tls-unique is supported for the moment.
-	 */
-	conn->sasl_state = conn->sasl->init(conn,
-										password,
-										selected_mechanism);
 	if (!conn->sasl_state)
-		goto oom_error;
+	{
+		/*
+		 * Initialize the SASL state information with all the information
+		 * gathered during the initial exchange.
+		 *
+		 * Note: Only tls-unique is supported for the moment.
+		 */
+		conn->sasl_state = conn->sasl->init(conn,
+											password,
+											selected_mechanism);
+		if (!conn->sasl_state)
+			goto oom_error;
+	}
+	else
+	{
+		/*
+		 * This is only possible if we're returning from an async loop.
+		 * Disconnect it now.
+		 */
+		Assert(conn->async_auth);
+		conn->async_auth = NULL;
+	}
 
 	/* Get the mechanism-specific Initial Client Response, if any */
-	status = conn->sasl->exchange(conn->sasl_state,
+	status = conn->sasl->exchange(conn->sasl_state, false,
 								  NULL, -1,
 								  &initialresponse, &initialresponselen);
 
 	if (status == SASL_FAILED)
 		goto error;
 
+	if (status == SASL_ASYNC)
+	{
+		/*
+		 * The mechanism should have set up the necessary callbacks; all we
+		 * need to do is signal the caller.
+		 *
+		 * In non-assertion builds, this postcondition is enforced at time of
+		 * use in PQconnectPoll().
+		 */
+		Assert(conn->async_auth);
+		Assert(conn->cleanup_async_auth);
+
+		*async = true;
+		return STATUS_OK;
+	}
+
 	/*
 	 * Build a SASLInitialResponse message, and send it.
 	 */
@@ -671,7 +699,7 @@ oom_error:
  * the protocol.
  */
 static int
-pg_SASL_continue(PGconn *conn, int payloadlen, bool final)
+pg_SASL_continue(PGconn *conn, int payloadlen, bool final, bool *async)
 {
 	char	   *output;
 	int			outputlen;
@@ -701,11 +729,25 @@ pg_SASL_continue(PGconn *conn, int payloadlen, bool final)
 	/* For safety and convenience, ensure the buffer is NULL-terminated. */
 	challenge[payloadlen] = '\0';
 
-	status = conn->sasl->exchange(conn->sasl_state,
+	status = conn->sasl->exchange(conn->sasl_state, final,
 								  challenge, payloadlen,
 								  &output, &outputlen);
 	free(challenge);			/* don't need the input anymore */
 
+	if (status == SASL_ASYNC)
+	{
+		/*
+		 * The mechanism should have set up the necessary callbacks; all we
+		 * need to do is signal the caller.
+		 */
+		*async = true;
+
+		/*
+		 * The mechanism may optionally generate some output to send before
+		 * switching over to async auth, so continue onwards.
+		 */
+	}
+
 	if (final && status == SASL_CONTINUE)
 	{
 		if (outputlen != 0)
@@ -1013,12 +1055,18 @@ check_expected_areq(AuthRequest areq, PGconn *conn)
  * it. We are responsible for reading any remaining extra data, specific
  * to the authentication method. 'payloadlen' is the remaining length in
  * the message.
+ *
+ * If *async is set to true on return, the client doesn't yet have enough
+ * information to respond, and the caller must temporarily switch to
+ * conn->async_auth() to continue driving the exchange.
  */
 int
-pg_fe_sendauth(AuthRequest areq, int payloadlen, PGconn *conn)
+pg_fe_sendauth(AuthRequest areq, int payloadlen, PGconn *conn, bool *async)
 {
 	int			oldmsglen;
 
+	*async = false;
+
 	if (!check_expected_areq(areq, conn))
 		return STATUS_ERROR;
 
@@ -1176,7 +1224,7 @@ pg_fe_sendauth(AuthRequest areq, int payloadlen, PGconn *conn)
 			 * The request contains the name (as assigned by IANA) of the
 			 * authentication mechanism.
 			 */
-			if (pg_SASL_init(conn, payloadlen) != STATUS_OK)
+			if (pg_SASL_init(conn, payloadlen, async) != STATUS_OK)
 			{
 				/* pg_SASL_init already set the error message */
 				return STATUS_ERROR;
@@ -1185,23 +1233,33 @@ pg_fe_sendauth(AuthRequest areq, int payloadlen, PGconn *conn)
 
 		case AUTH_REQ_SASL_CONT:
 		case AUTH_REQ_SASL_FIN:
-			if (conn->sasl_state == NULL)
 			{
-				appendPQExpBufferStr(&conn->errorMessage,
-									 "fe_sendauth: invalid authentication request from server: AUTH_REQ_SASL_CONT without AUTH_REQ_SASL\n");
-				return STATUS_ERROR;
-			}
-			oldmsglen = conn->errorMessage.len;
-			if (pg_SASL_continue(conn, payloadlen,
-								 (areq == AUTH_REQ_SASL_FIN)) != STATUS_OK)
-			{
-				/* Use this message if pg_SASL_continue didn't supply one */
-				if (conn->errorMessage.len == oldmsglen)
+				bool		final = false;
+
+				if (conn->sasl_state == NULL)
+				{
 					appendPQExpBufferStr(&conn->errorMessage,
-										 "fe_sendauth: error in SASL authentication\n");
-				return STATUS_ERROR;
+										 "fe_sendauth: invalid authentication request from server: AUTH_REQ_SASL_CONT without AUTH_REQ_SASL\n");
+					return STATUS_ERROR;
+				}
+				oldmsglen = conn->errorMessage.len;
+
+				if (areq == AUTH_REQ_SASL_FIN)
+					final = true;
+
+				if (pg_SASL_continue(conn, payloadlen, final, async) != STATUS_OK)
+				{
+					/*
+					 * Append a generic error message unless pg_SASL_continue
+					 * did set a more specific one already.
+					 */
+					if (conn->errorMessage.len == oldmsglen)
+						appendPQExpBufferStr(&conn->errorMessage,
+											 "fe_sendauth: error in SASL authentication\n");
+					return STATUS_ERROR;
+				}
+				break;
 			}
-			break;
 
 		default:
 			libpq_append_conn_error(conn, "authentication method %u not supported", areq);
diff --git a/src/interfaces/libpq/fe-auth.h b/src/interfaces/libpq/fe-auth.h
index df0a68b0b2..1d4991f899 100644
--- a/src/interfaces/libpq/fe-auth.h
+++ b/src/interfaces/libpq/fe-auth.h
@@ -19,7 +19,8 @@
 
 
 /* Prototypes for functions in fe-auth.c */
-extern int	pg_fe_sendauth(AuthRequest areq, int payloadlen, PGconn *conn);
+extern int	pg_fe_sendauth(AuthRequest areq, int payloadlen, PGconn *conn,
+						   bool *async);
 extern char *pg_fe_getusername(uid_t user_id, PQExpBuffer errorMessage);
 extern char *pg_fe_getauthname(PQExpBuffer errorMessage);
 
diff --git a/src/interfaces/libpq/fe-connect.c b/src/interfaces/libpq/fe-connect.c
index e1cea790f9..85d1ca2864 100644
--- a/src/interfaces/libpq/fe-connect.c
+++ b/src/interfaces/libpq/fe-connect.c
@@ -507,6 +507,19 @@ pqDropConnection(PGconn *conn, bool flushInput)
 	conn->cmd_queue_recycle = NULL;
 
 	/* Free authentication/encryption state */
+	if (conn->cleanup_async_auth)
+	{
+		/*
+		 * Any in-progress async authentication should be torn down first so
+		 * that cleanup_async_auth() can depend on the other authentication
+		 * state if necessary.
+		 */
+		conn->cleanup_async_auth(conn);
+		conn->cleanup_async_auth = NULL;
+	}
+	conn->async_auth = NULL;
+	/* cleanup_async_auth() should have done this, but make sure */
+	conn->altsock = PGINVALID_SOCKET;
 #ifdef ENABLE_GSS
 	{
 		OM_uint32	min_s;
@@ -2853,6 +2866,7 @@ PQconnectPoll(PGconn *conn)
 		case CONNECTION_NEEDED:
 		case CONNECTION_GSS_STARTUP:
 		case CONNECTION_CHECK_TARGET:
+		case CONNECTION_AUTHENTICATING:
 			break;
 
 		default:
@@ -3888,6 +3902,7 @@ keep_going:						/* We will come back to here until there is
 				int			avail;
 				AuthRequest areq;
 				int			res;
+				bool		async;
 
 				/*
 				 * Scan the message from current point (note that if we find
@@ -4076,7 +4091,17 @@ keep_going:						/* We will come back to here until there is
 				 * Note that conn->pghost must be non-NULL if we are going to
 				 * avoid the Kerberos code doing a hostname look-up.
 				 */
-				res = pg_fe_sendauth(areq, msgLength, conn);
+				res = pg_fe_sendauth(areq, msgLength, conn, &async);
+
+				if (async && (res == STATUS_OK))
+				{
+					/*
+					 * We'll come back later once we're ready to respond.
+					 * Don't consume the request yet.
+					 */
+					conn->status = CONNECTION_AUTHENTICATING;
+					goto keep_going;
+				}
 
 				/*
 				 * OK, we have processed the message; mark data consumed.  We
@@ -4113,6 +4138,69 @@ keep_going:						/* We will come back to here until there is
 				goto keep_going;
 			}
 
+		case CONNECTION_AUTHENTICATING:
+			{
+				PostgresPollingStatusType status;
+
+				if (!conn->async_auth || !conn->cleanup_async_auth)
+				{
+					/* programmer error; should not happen */
+					libpq_append_conn_error(conn,
+											"internal error: async authentication has no handler");
+					goto error_return;
+				}
+
+				/* Drive some external authentication work. */
+				status = conn->async_auth(conn);
+
+				if (status == PGRES_POLLING_FAILED)
+					goto error_return;
+
+				if (status == PGRES_POLLING_OK)
+				{
+					/* Done. Tear down the async implementation. */
+					conn->cleanup_async_auth(conn);
+					conn->cleanup_async_auth = NULL;
+
+					/*
+					 * Cleanup must unset altsock, both as an indication that
+					 * it's been released, and to stop pqSocketCheck from
+					 * looking at the wrong socket after async auth is done.
+					 */
+					if (conn->altsock != PGINVALID_SOCKET)
+					{
+						Assert(false);
+						libpq_append_conn_error(conn,
+												"internal error: async cleanup did not release polling socket");
+						goto error_return;
+					}
+
+					/*
+					 * Reenter the authentication exchange with the server. We
+					 * didn't consume the message that started external
+					 * authentication, so it'll be reprocessed as if we just
+					 * received it.
+					 */
+					conn->status = CONNECTION_AWAITING_RESPONSE;
+
+					goto keep_going;
+				}
+
+				/*
+				 * Caller needs to poll some more. conn->async_auth() should
+				 * have assigned an altsock to poll on.
+				 */
+				if (conn->altsock == PGINVALID_SOCKET)
+				{
+					Assert(false);
+					libpq_append_conn_error(conn,
+											"internal error: async authentication did not set a socket for polling");
+					goto error_return;
+				}
+
+				return status;
+			}
+
 		case CONNECTION_AUTH_OK:
 			{
 				/*
@@ -4794,6 +4882,7 @@ pqMakeEmptyPGconn(void)
 	conn->verbosity = PQERRORS_DEFAULT;
 	conn->show_context = PQSHOW_CONTEXT_ERRORS;
 	conn->sock = PGINVALID_SOCKET;
+	conn->altsock = PGINVALID_SOCKET;
 	conn->Pfdebug = NULL;
 
 	/*
@@ -7445,6 +7534,8 @@ PQsocket(const PGconn *conn)
 {
 	if (!conn)
 		return -1;
+	if (conn->altsock != PGINVALID_SOCKET)
+		return conn->altsock;
 	return (conn->sock != PGINVALID_SOCKET) ? conn->sock : -1;
 }
 
diff --git a/src/interfaces/libpq/fe-misc.c b/src/interfaces/libpq/fe-misc.c
index 2c60eb5b56..d78445c70a 100644
--- a/src/interfaces/libpq/fe-misc.c
+++ b/src/interfaces/libpq/fe-misc.c
@@ -1049,34 +1049,43 @@ pqWriteReady(PGconn *conn)
  * or both.  Returns >0 if one or more conditions are met, 0 if it timed
  * out, -1 if an error occurred.
  *
- * If SSL is in use, the SSL buffer is checked prior to checking the socket
- * for read data directly.
+ * If an altsock is set for asynchronous authentication, that will be used in
+ * preference to the "server" socket. Otherwise, if SSL is in use, the SSL
+ * buffer is checked prior to checking the socket for read data directly.
  */
 static int
 pqSocketCheck(PGconn *conn, int forRead, int forWrite, pg_usec_time_t end_time)
 {
 	int			result;
+	pgsocket	sock;
 
 	if (!conn)
 		return -1;
-	if (conn->sock == PGINVALID_SOCKET)
+
+	if (conn->altsock != PGINVALID_SOCKET)
+		sock = conn->altsock;
+	else
 	{
-		libpq_append_conn_error(conn, "invalid socket");
-		return -1;
-	}
+		sock = conn->sock;
+		if (sock == PGINVALID_SOCKET)
+		{
+			libpq_append_conn_error(conn, "invalid socket");
+			return -1;
+		}
 
 #ifdef USE_SSL
-	/* Check for SSL library buffering read bytes */
-	if (forRead && conn->ssl_in_use && pgtls_read_pending(conn))
-	{
-		/* short-circuit the select */
-		return 1;
-	}
+		/* Check for SSL library buffering read bytes */
+		if (forRead && conn->ssl_in_use && pgtls_read_pending(conn))
+		{
+			/* short-circuit the select */
+			return 1;
+		}
 #endif
+	}
 
 	/* We will retry as long as we get EINTR */
 	do
-		result = PQsocketPoll(conn->sock, forRead, forWrite, end_time);
+		result = PQsocketPoll(sock, forRead, forWrite, end_time);
 	while (result < 0 && SOCK_ERRNO == EINTR);
 
 	if (result < 0)
diff --git a/src/interfaces/libpq/libpq-fe.h b/src/interfaces/libpq/libpq-fe.h
index cce9ce60c5..a3491faf0c 100644
--- a/src/interfaces/libpq/libpq-fe.h
+++ b/src/interfaces/libpq/libpq-fe.h
@@ -103,6 +103,8 @@ typedef enum
 	CONNECTION_CHECK_STANDBY,	/* Checking if server is in standby mode. */
 	CONNECTION_ALLOCATED,		/* Waiting for connection attempt to be
 								 * started.  */
+	CONNECTION_AUTHENTICATING,	/* Authentication is in progress with some
+								 * external system. */
 } ConnStatusType;
 
 typedef enum
diff --git a/src/interfaces/libpq/libpq-int.h b/src/interfaces/libpq/libpq-int.h
index e0d5b5fe0b..2546f9f8a5 100644
--- a/src/interfaces/libpq/libpq-int.h
+++ b/src/interfaces/libpq/libpq-int.h
@@ -513,6 +513,12 @@ struct pg_conn
 										 * know which auth response we're
 										 * sending */
 
+	/* Callbacks for external async authentication */
+	PostgresPollingStatusType (*async_auth) (PGconn *conn);
+	void		(*cleanup_async_auth) (PGconn *conn);
+	pgsocket	altsock;		/* alternative socket for client to poll */
+
+
 	/* Transient state needed while establishing connection */
 	PGTargetServerType target_server_type;	/* desired session properties */
 	PGLoadBalanceType load_balance_type;	/* desired load balancing
-- 
2.39.3 (Apple Git-146)



^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-02-06 22:02  Daniel Gustafsson <[email protected]>
  parent: Daniel Gustafsson <[email protected]>
  0 siblings, 1 reply; 101+ messages in thread

From: Daniel Gustafsson @ 2025-02-06 22:02 UTC (permalink / raw)
  To: Jacob Champion <[email protected]>; +Cc: Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

> On 31 Jan 2025, at 17:23, Daniel Gustafsson <[email protected]> wrote:
> 
>> On 28 Jan 2025, at 01:59, Jacob Champion <[email protected]> wrote:
>> On Mon, Jan 27, 2025 at 2:50 PM Daniel Gustafsson <[email protected]> wrote:
>>> Unless there are objections I aim at committing these patches reasonably soon
>>> to lower the barrier for getting OAuth support committed.
> 
> After staring at the patchset even more I committed patches 0001 and 0002 today
> as a preparatory step for getting OAuth in.  I will work on the 0003 (which is
> now 0001) next.

After more staring and commitmessage tweaking I pushed the v45-0001 patch and
it has so far built green in a number of BF animals (as well as in CI).

This to pave the way for the main OAUTHBEARER patch in this set, which I hope
we can reach a final version of very soon.

Attached is a v46 which is v45 minus the now committed patch. 

--
Daniel Gustafsson



Attachments:

  [application/octet-stream] v46-0003-DO-NOT-MERGE-Add-pytest-suite-for-OAuth.patch (212.2K, ../../[email protected]/2-v46-0003-DO-NOT-MERGE-Add-pytest-suite-for-OAuth.patch)
  download | inline diff:
From 0a748be718000156a8380733134d8029ba9b7654 Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Fri, 4 Jun 2021 09:06:38 -0700
Subject: [PATCH v46 3/3] DO NOT MERGE: Add pytest suite for OAuth

Requires Python 3. On the first run of `make installcheck` or `meson
test` the dependencies will be installed into a local virtualenv for
you. See the README for more details.

Cirrus has been updated to build OAuth support on Debian and FreeBSD.

The suite contains a --temp-instance option, analogous to pg_regress's
option of the same name, which allows an ephemeral server to be spun up
during a test run.

TODOs:
- The --tap-stream option to pytest-tap is slightly broken during test
  failures (it suppresses error information), which impedes debugging.
- pyca/cryptography is pinned at an old version. Since we use it for
  testing and not security, this isn't a critical problem yet, but it's
  not ideal. Newer versions require a Rust compiler to build, and while
  many platforms have precompiled wheels, some (FreeBSD) do not. Even
  with the Rust pieces bypassed, compilation on FreeBSD takes a while.
- The with_oauth test skip logic should probably be integrated into the
  Makefile side as well...
- See if 32-bit tests can be enabled with a 32-bit Python.
---
 .cirrus.tasks.yml                     |    6 +-
 meson.build                           |  103 +
 src/test/meson.build                  |    1 +
 src/test/python/.gitignore            |    2 +
 src/test/python/Makefile              |   38 +
 src/test/python/README                |   66 +
 src/test/python/client/__init__.py    |    0
 src/test/python/client/conftest.py    |  196 ++
 src/test/python/client/test_client.py |  186 ++
 src/test/python/client/test_oauth.py  | 2659 +++++++++++++++++++++++++
 src/test/python/conftest.py           |   34 +
 src/test/python/meson.build           |   47 +
 src/test/python/pq3.py                |  740 +++++++
 src/test/python/pytest.ini            |    4 +
 src/test/python/requirements.txt      |   11 +
 src/test/python/server/__init__.py    |    0
 src/test/python/server/conftest.py    |  141 ++
 src/test/python/server/meson.build    |   18 +
 src/test/python/server/oauthtest.c    |  118 ++
 src/test/python/server/test_oauth.py  | 1080 ++++++++++
 src/test/python/server/test_server.py |   21 +
 src/test/python/test_internals.py     |  138 ++
 src/test/python/test_pq3.py           |  574 ++++++
 src/test/python/tls.py                |  195 ++
 src/tools/make_venv                   |   56 +
 src/tools/testwrap                    |    7 +
 26 files changed, 6440 insertions(+), 1 deletion(-)
 create mode 100644 src/test/python/.gitignore
 create mode 100644 src/test/python/Makefile
 create mode 100644 src/test/python/README
 create mode 100644 src/test/python/client/__init__.py
 create mode 100644 src/test/python/client/conftest.py
 create mode 100644 src/test/python/client/test_client.py
 create mode 100644 src/test/python/client/test_oauth.py
 create mode 100644 src/test/python/conftest.py
 create mode 100644 src/test/python/meson.build
 create mode 100644 src/test/python/pq3.py
 create mode 100644 src/test/python/pytest.ini
 create mode 100644 src/test/python/requirements.txt
 create mode 100644 src/test/python/server/__init__.py
 create mode 100644 src/test/python/server/conftest.py
 create mode 100644 src/test/python/server/meson.build
 create mode 100644 src/test/python/server/oauthtest.c
 create mode 100644 src/test/python/server/test_oauth.py
 create mode 100644 src/test/python/server/test_server.py
 create mode 100644 src/test/python/test_internals.py
 create mode 100644 src/test/python/test_pq3.py
 create mode 100644 src/test/python/tls.py
 create mode 100755 src/tools/make_venv

diff --git a/.cirrus.tasks.yml b/.cirrus.tasks.yml
index 3afea832bc..06efe5f9b0 100644
--- a/.cirrus.tasks.yml
+++ b/.cirrus.tasks.yml
@@ -23,7 +23,7 @@ env:
   MTEST_ARGS: --print-errorlogs --no-rebuild -C build
   PGCTLTIMEOUT: 120 # avoids spurious failures during parallel tests
   TEMP_CONFIG: ${CIRRUS_WORKING_DIR}/src/tools/ci/pg_ci_base.conf
-  PG_TEST_EXTRA: kerberos ldap ssl libpq_encryption load_balance oauth
+  PG_TEST_EXTRA: kerberos ldap ssl libpq_encryption load_balance oauth python
 
 
 # What files to preserve in case tests fail
@@ -321,6 +321,7 @@ task:
     DEBIAN_FRONTEND=noninteractive apt-get -y install \
       libcurl4-openssl-dev \
       libcurl4-openssl-dev:i386 \
+      python3-venv \
 
   matrix:
     - name: Linux - Debian Bookworm - Autoconf
@@ -405,8 +406,11 @@ task:
       # can easily provide some here by running one of the sets of tests that
       # way. Newer versions of python insist on changing the LC_CTYPE away
       # from C, prevent that with PYTHONCOERCECLOCALE.
+      # XXX 32-bit Python tests are currently disabled, as the system's 64-bit
+      # Python modules can't link against libpq.
       test_world_32_script: |
         su postgres <<-EOF
+          export PG_TEST_EXTRA="${PG_TEST_EXTRA//python}"
           ulimit -c unlimited
           PYTHONCOERCECLOCALE=0 LANG=C meson test $MTEST_ARGS -C build-32 --num-processes ${TEST_JOBS}
         EOF
diff --git a/meson.build b/meson.build
index 80a6b1d57d..a0391a904a 100644
--- a/meson.build
+++ b/meson.build
@@ -3424,6 +3424,9 @@ else
 endif
 
 testwrap = files('src/tools/testwrap')
+make_venv = files('src/tools/make_venv')
+
+checked_working_venv = false
 
 foreach test_dir : tests
   testwrap_base = [
@@ -3592,6 +3595,106 @@ foreach test_dir : tests
         )
       endforeach
       install_suites += test_group
+    elif kind == 'pytest'
+      venv_name = test_dir['name'] + '_venv'
+      venv_path = meson.build_root() / venv_name
+
+      # The Python tests require a working venv module. This is part of the
+      # standard library, but some platforms disable it until a separate package
+      # is installed. Those same platforms don't provide an easy way to check
+      # whether the venv command will work until the first time you try it, so
+      # we decide whether or not to enable these tests on the fly.
+      if not checked_working_venv
+        cmd = run_command(python, '-m', 'venv', venv_path, check: false)
+
+        have_working_venv = (cmd.returncode() == 0)
+        if not have_working_venv
+          warning('A working Python venv module is required to run Python tests.')
+        endif
+
+        checked_working_venv = true
+      endif
+
+      if not have_working_venv
+        continue
+      endif
+
+      # Make sure the temporary installation is in PATH (necessary both for
+      # --temp-instance and for any pip modules compiling against libpq, like
+      # psycopg2).
+      env = test_env
+      env.prepend('PATH', temp_install_bindir, test_dir['bd'])
+
+      foreach name, value : t.get('env', {})
+        env.set(name, value)
+      endforeach
+
+      reqs = files(t['requirements'])
+      test('install_' + venv_name,
+        python,
+        args: [ make_venv, '--requirements', reqs, venv_path ],
+        env: env,
+        priority: setup_tests_priority - 1,  # must run after tmp_install
+        is_parallel: false,
+        suite: ['setup'],
+        timeout: 60,  # 30s is too short for the cryptography package compile
+      )
+
+      test_group = test_dir['name']
+      test_output = test_result_dir / test_group / kind
+      test_kwargs = {
+        #'protocol': 'tap',
+        'suite': test_group,
+        'timeout': 1000,
+        'depends': test_deps,
+        'env': env,
+      } + t.get('test_kwargs', {})
+
+      if fs.is_dir(venv_path / 'Scripts')
+        # Windows virtualenv layout
+        pytest = venv_path / 'Scripts' / 'py.test'
+      else
+        pytest = venv_path / 'bin' / 'py.test'
+      endif
+
+      test_command = [
+        pytest,
+        # Avoid running these tests against an existing database.
+        '--temp-instance', test_output / 'data',
+
+        # FIXME pytest-tap's stream feature accidentally suppresses errors that
+        # are critical for debugging:
+        #     https://github.com/python-tap/pytest-tap/issues/30
+        # Don't use the meson TAP protocol for now...
+        #'--tap-stream',
+      ]
+
+      foreach pyt : t['tests']
+        # Similarly to TAP, strip ./ and .py to make the names prettier
+        pyt_p = pyt
+        if pyt_p.startswith('./')
+          pyt_p = pyt_p.split('./')[1]
+        endif
+        if pyt_p.endswith('.py')
+          pyt_p = fs.stem(pyt_p)
+        endif
+
+        testwrap_pytest = testwrap_base + [
+          '--testgroup', test_group,
+          '--testname', pyt_p,
+          '--skip-without-extra', 'python',
+        ]
+
+        test(test_group / pyt_p,
+          python,
+          kwargs: test_kwargs,
+          args: testwrap_pytest + [
+            '--', test_command,
+            test_dir['sd'] / pyt,
+          ],
+        )
+      endforeach
+      install_suites += test_group
     else
       error('unknown kind @0@ of test in @1@'.format(kind, test_dir['sd']))
     endif
diff --git a/src/test/meson.build b/src/test/meson.build
index ccc31d6a86..236057cd99 100644
--- a/src/test/meson.build
+++ b/src/test/meson.build
@@ -8,6 +8,7 @@ subdir('postmaster')
 subdir('recovery')
 subdir('subscription')
 subdir('modules')
+subdir('python')
 
 if ssl.found()
   subdir('ssl')
diff --git a/src/test/python/.gitignore b/src/test/python/.gitignore
new file mode 100644
index 0000000000..0e8f027b2e
--- /dev/null
+++ b/src/test/python/.gitignore
@@ -0,0 +1,2 @@
+__pycache__/
+/venv/
diff --git a/src/test/python/Makefile b/src/test/python/Makefile
new file mode 100644
index 0000000000..b0695b6287
--- /dev/null
+++ b/src/test/python/Makefile
@@ -0,0 +1,38 @@
+#
+# Copyright 2021 VMware, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+# Only Python 3 is supported, but if it's named something different on your
+# system you can override it with the PYTHON3 variable.
+PYTHON3 := python3
+
+# All dependencies are placed into this directory. The default is .gitignored
+# for you, but you can override it if you'd like.
+VENV := ./venv
+
+override VBIN   := $(VENV)/bin
+override PIP    := $(VBIN)/pip
+override PYTEST := $(VBIN)/py.test
+override ISORT  := $(VBIN)/isort
+override BLACK  := $(VBIN)/black
+
+.PHONY: installcheck indent
+
+installcheck: $(PYTEST)
+	$(PYTEST) -v -rs
+
+indent: $(ISORT) $(BLACK)
+	$(ISORT) --profile black *.py client/*.py server/*.py
+	$(BLACK) *.py client/*.py server/*.py
+
+$(PYTEST) $(ISORT) $(BLACK) &: requirements.txt | $(PIP)
+	$(PIP) install --force-reinstall -r $<
+
+$(PIP):
+	$(PYTHON3) -m venv $(VENV)
+
+# A convenience recipe to rebuild psycopg2 against the local libpq.
+.PHONY: rebuild-psycopg2
+rebuild-psycopg2: | $(PIP)
+	$(PIP) install --force-reinstall --no-binary :all: $(shell grep psycopg2 requirements.txt)
diff --git a/src/test/python/README b/src/test/python/README
new file mode 100644
index 0000000000..acf339a589
--- /dev/null
+++ b/src/test/python/README
@@ -0,0 +1,66 @@
+A test suite for exercising both the libpq client and the server backend at the
+protocol level, based on pytest and Construct.
+
+WARNING! This suite takes superuser-level control of the cluster under test,
+writing to the server config, creating and destroying databases, etc. It also
+spins up various ephemeral TCP services. This is not safe for production servers
+and therefore must be explicitly opted into by setting PG_TEST_EXTRA=python in
+the environment.
+
+The test suite currently assumes that the standard PG* environment variables
+point to the database under test and are sufficient to log in a superuser on
+that system. In other words, a bare `psql` needs to Just Work before the test
+suite can do its thing. For a newly built dev cluster, typically all that I need
+to do is a
+
+    export PGDATABASE=postgres
+
+but you can adjust as needed for your setup. See also 'Advanced Usage' below.
+
+## Requirements
+
+A supported version (3.6+) of Python.
+
+The first run of
+
+    make installcheck PG_TEST_EXTRA=python
+
+will install a local virtual environment and all needed dependencies. During
+development, if libpq changes incompatibly, you can issue
+
+    $ make rebuild-psycopg2
+
+to force a rebuild of the client library.
+
+## Hacking
+
+The code style is enforced by a _very_ opinionated autoformatter. Running the
+
+    make indent
+
+recipe will invoke it for you automatically. Don't fight the tool; part of the
+zen is in knowing that if the formatter makes your code ugly, there's probably a
+cleaner way to write your code.
+
+## Advanced Usage
+
+The Makefile is there for convenience, but you don't have to use it. Activate
+the virtualenv to be able to use pytest directly:
+
+    $ export PG_TEST_EXTRA=python
+    $ source venv/bin/activate
+    $ py.test -k oauth
+    ...
+    $ py.test ./server/test_server.py
+    ...
+    $ deactivate  # puts the PATH et al back the way it was before
+
+To make quick smoke tests possible, slow tests have been marked explicitly. You
+can skip them by saying e.g.
+
+    $ py.test -m 'not slow'
+
+If you'd rather not test against an existing server, you can have the suite spin
+up a temporary one using whatever pg_ctl it finds in PATH:
+
+    $ py.test --temp-instance=./tmp_check
diff --git a/src/test/python/client/__init__.py b/src/test/python/client/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/test/python/client/conftest.py b/src/test/python/client/conftest.py
new file mode 100644
index 0000000000..20e72a404a
--- /dev/null
+++ b/src/test/python/client/conftest.py
@@ -0,0 +1,196 @@
+#
+# Copyright 2021 VMware, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import contextlib
+import datetime
+import functools
+import ipaddress
+import os
+import socket
+import sys
+import threading
+
+import psycopg2
+import psycopg2.extras
+import pytest
+from cryptography import x509
+from cryptography.hazmat.primitives import hashes, serialization
+from cryptography.hazmat.primitives.asymmetric import rsa
+from cryptography.x509.oid import NameOID
+
+import pq3
+
+BLOCKING_TIMEOUT = 2  # the number of seconds to wait for blocking calls
+
+
[email protected]
+def server_socket(unused_tcp_port_factory):
+    """
+    Returns a listening socket bound to an ephemeral port.
+    """
+    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+        s.bind(("127.0.0.1", unused_tcp_port_factory()))
+        s.listen(1)
+        s.settimeout(BLOCKING_TIMEOUT)
+        yield s
+
+
+class ClientHandshake(threading.Thread):
+    """
+    A thread that connects to a local Postgres server using psycopg2. Once the
+    opening handshake completes, the connection will be immediately closed.
+    """
+
+    def __init__(self, *, port, **kwargs):
+        super().__init__()
+
+        kwargs["port"] = port
+        self._kwargs = kwargs
+
+        self.exception = None
+
+    def run(self):
+        try:
+            conn = psycopg2.connect(host="127.0.0.1", **self._kwargs)
+            with contextlib.closing(conn):
+                self._pump_async(conn)
+        except Exception as e:
+            self.exception = e
+
+    def check_completed(self, timeout=BLOCKING_TIMEOUT):
+        """
+        Joins the client thread. Raises an exception if the thread could not be
+        joined, or if it threw an exception itself. (The exception will be
+        cleared, so future calls to check_completed will succeed.)
+        """
+        self.join(timeout)
+
+        if self.is_alive():
+            raise TimeoutError("client thread did not handshake within the timeout")
+        elif self.exception:
+            e = self.exception
+            self.exception = None
+            raise e
+
+    def _pump_async(self, conn):
+        """
+        Polls a psycopg2 connection until it's completed. (Synchronous
+        connections will work here too; they'll just immediately return OK.)
+        """
+        psycopg2.extras.wait_select(conn)
+
+
[email protected]
+def accept(server_socket):
+    """
+    Returns a factory function that, when called, returns a pair (sock, client)
+    where sock is a server socket that has accepted a connection from client,
+    and client is an instance of ClientHandshake. Clients will complete their
+    handshakes and cleanly disconnect.
+
+    The default connstring options may be extended or overridden by passing
+    arbitrary keyword arguments. Keep in mind that you generally should not
+    override the host or port, since they point to the local test server.
+
+    For situations where a client needs to connect more than once to complete a
+    handshake, the accept function may be called more than once. (The client
+    returned for subsequent calls will always be the same client that was
+    returned for the first call.)
+
+    Tests must either complete the handshake so that the client thread can be
+    automatically joined during teardown, or else call client.check_completed()
+    and manually handle any expected errors.
+    """
+    _, port = server_socket.getsockname()
+
+    client = None
+    default_opts = dict(
+        port=port,
+        user=pq3.pguser(),
+        sslmode="disable",
+    )
+
+    def factory(**kwargs):
+        nonlocal client
+
+        if client is None:
+            opts = dict(default_opts)
+            opts.update(kwargs)
+
+            # The server_socket is already listening, so the client thread can
+            # be safely started; it'll block on the connection until we accept.
+            client = ClientHandshake(**opts)
+            client.start()
+
+        sock, _ = server_socket.accept()
+        sock.settimeout(BLOCKING_TIMEOUT)
+        return sock, client
+
+    yield factory
+
+    if client is not None:
+        client.check_completed()
+
+
[email protected]
+def conn(accept):
+    """
+    Returns an accepted, wrapped pq3 connection to a psycopg2 client. The socket
+    will be closed when the test finishes, and the client will be checked for a
+    cleanly completed handshake.
+    """
+    sock, client = accept()
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            yield conn
+
+
[email protected](scope="session")
+def certpair(tmp_path_factory):
+    """
+    Yields a (cert, key) pair of file paths that can be used by a TLS server.
+    The certificate is issued for "localhost" and its standard IPv4/6 addresses.
+    """
+
+    tmpdir = tmp_path_factory.mktemp("certs")
+    now = datetime.datetime.now(datetime.timezone.utc)
+
+    # https://cryptography.io/en/latest/x509/tutorial/#creating-a-self-signed-certificate
+    key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
+
+    subject = issuer = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "localhost")])
+    altNames = [
+        x509.DNSName("localhost"),
+        x509.IPAddress(ipaddress.IPv4Address("127.0.0.1")),
+        x509.IPAddress(ipaddress.IPv6Address("::1")),
+    ]
+    cert = (
+        x509.CertificateBuilder()
+        .subject_name(subject)
+        .issuer_name(issuer)
+        .public_key(key.public_key())
+        .serial_number(x509.random_serial_number())
+        .not_valid_before(now)
+        .not_valid_after(now + datetime.timedelta(minutes=10))
+        .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
+        .add_extension(x509.SubjectAlternativeName(altNames), critical=False)
+    ).sign(key, hashes.SHA256())
+
+    # Writing the key with mode 0600 lets us use this from the server side, too.
+    keypath = str(tmpdir / "key.pem")
+    with open(keypath, "wb", opener=functools.partial(os.open, mode=0o600)) as f:
+        f.write(
+            key.private_bytes(
+                encoding=serialization.Encoding.PEM,
+                format=serialization.PrivateFormat.PKCS8,
+                encryption_algorithm=serialization.NoEncryption(),
+            )
+        )
+
+    certpath = str(tmpdir / "cert.pem")
+    with open(certpath, "wb") as f:
+        f.write(cert.public_bytes(serialization.Encoding.PEM))
+
+    return certpath, keypath
diff --git a/src/test/python/client/test_client.py b/src/test/python/client/test_client.py
new file mode 100644
index 0000000000..8372376ede
--- /dev/null
+++ b/src/test/python/client/test_client.py
@@ -0,0 +1,186 @@
+#
+# Copyright 2021 VMware, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import base64
+import sys
+
+import psycopg2
+import pytest
+from cryptography.hazmat.primitives import hashes, hmac
+
+import pq3
+
+from .test_oauth import alt_patterns
+
+
+def finish_handshake(conn):
+    """
+    Sends the AuthenticationOK message and the standard opening salvo of server
+    messages, then asserts that the client immediately sends a Terminate message
+    to close the connection cleanly.
+    """
+    pq3.send(conn, pq3.types.AuthnRequest, type=pq3.authn.OK)
+    pq3.send(conn, pq3.types.ParameterStatus, name=b"client_encoding", value=b"UTF-8")
+    pq3.send(conn, pq3.types.ParameterStatus, name=b"DateStyle", value=b"ISO, MDY")
+    pq3.send(conn, pq3.types.ReadyForQuery, status=b"I")
+
+    pkt = pq3.recv1(conn)
+    assert pkt.type == pq3.types.Terminate
+
+
+def test_handshake(conn):
+    startup = pq3.recv1(conn, cls=pq3.Startup)
+    assert startup.proto == pq3.protocol(3, 0)
+
+    finish_handshake(conn)
+
+
+def test_aborted_connection(accept):
+    """
+    Make sure the client correctly reports an early close during handshakes.
+    """
+    sock, client = accept()
+    sock.close()
+
+    expected = alt_patterns(
+        "server closed the connection unexpectedly",
+        # On some platforms, ECONNABORTED gets set instead.
+        "Software caused connection abort",
+    )
+    with pytest.raises(psycopg2.OperationalError, match=expected):
+        client.check_completed()
+
+
+#
+# SCRAM-SHA-256 (see RFC 5802: https://tools.ietf.org/html/rfc5802)
+#
+
+
[email protected]
+def password():
+    """
+    Returns a password for use by both client and server.
+    """
+    # TODO: parameterize this with passwords that require SASLprep.
+    return "secret"
+
+
[email protected]
+def pwconn(accept, password):
+    """
+    Like the conn fixture, but uses a password in the connection.
+    """
+    sock, client = accept(password=password)
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            yield conn
+
+
+def sha256(data):
+    """The H(str) function from Section 2.2."""
+    digest = hashes.Hash(hashes.SHA256())
+    digest.update(data)
+    return digest.finalize()
+
+
+def hmac_256(key, data):
+    """The HMAC(key, str) function from Section 2.2."""
+    h = hmac.HMAC(key, hashes.SHA256())
+    h.update(data)
+    return h.finalize()
+
+
+def xor(a, b):
+    """The XOR operation from Section 2.2."""
+    res = bytearray(a)
+    for i, byte in enumerate(b):
+        res[i] ^= byte
+    return bytes(res)
+
+
+def h_i(data, salt, i):
+    """The Hi(str, salt, i) function from Section 2.2."""
+    assert i > 0
+
+    acc = hmac_256(data, salt + b"\x00\x00\x00\x01")
+    last = acc
+    i -= 1
+
+    while i:
+        u = hmac_256(data, last)
+        acc = xor(acc, u)
+
+        last = u
+        i -= 1
+
+    return acc
+
+
+def test_scram(pwconn, password):
+    startup = pq3.recv1(pwconn, cls=pq3.Startup)
+    assert startup.proto == pq3.protocol(3, 0)
+
+    pq3.send(
+        pwconn,
+        pq3.types.AuthnRequest,
+        type=pq3.authn.SASL,
+        body=[b"SCRAM-SHA-256", b""],
+    )
+
+    # Get the client-first-message.
+    pkt = pq3.recv1(pwconn)
+    assert pkt.type == pq3.types.PasswordMessage
+
+    initial = pq3.SASLInitialResponse.parse(pkt.payload)
+    assert initial.name == b"SCRAM-SHA-256"
+
+    c_bind, authzid, c_name, c_nonce = initial.data.split(b",")
+    assert c_bind == b"n"  # no channel bindings on a plaintext connection
+    assert authzid == b""  # we don't support authzid currently
+    assert c_name == b"n="  # libpq doesn't honor the GS2 username
+    assert c_nonce.startswith(b"r=")
+
+    # Send the server-first-message.
+    salt = b"12345"
+    iterations = 2
+
+    s_nonce = c_nonce + b"somenonce"
+    s_salt = b"s=" + base64.b64encode(salt)
+    s_iterations = b"i=%d" % iterations
+
+    msg = b",".join([s_nonce, s_salt, s_iterations])
+    pq3.send(pwconn, pq3.types.AuthnRequest, type=pq3.authn.SASLContinue, body=msg)
+
+    # Get the client-final-message.
+    pkt = pq3.recv1(pwconn)
+    assert pkt.type == pq3.types.PasswordMessage
+
+    c_bind_final, c_nonce_final, c_proof = pkt.payload.split(b",")
+    assert c_bind_final == b"c=" + base64.b64encode(c_bind + b"," + authzid + b",")
+    assert c_nonce_final == s_nonce
+
+    # Calculate what the client proof should be.
+    salted_password = h_i(password.encode("ascii"), salt, iterations)
+    client_key = hmac_256(salted_password, b"Client Key")
+    stored_key = sha256(client_key)
+
+    auth_message = b",".join(
+        [c_name, c_nonce, s_nonce, s_salt, s_iterations, c_bind_final, c_nonce_final]
+    )
+    client_signature = hmac_256(stored_key, auth_message)
+    client_proof = xor(client_key, client_signature)
+
+    expected = b"p=" + base64.b64encode(client_proof)
+    assert c_proof == expected
+
+    # Send the correct server signature.
+    server_key = hmac_256(salted_password, b"Server Key")
+    server_signature = hmac_256(server_key, auth_message)
+
+    s_verify = b"v=" + base64.b64encode(server_signature)
+    pq3.send(pwconn, pq3.types.AuthnRequest, type=pq3.authn.SASLFinal, body=s_verify)
+
+    # Done!
+    finish_handshake(pwconn)
diff --git a/src/test/python/client/test_oauth.py b/src/test/python/client/test_oauth.py
new file mode 100644
index 0000000000..ea1aeaed48
--- /dev/null
+++ b/src/test/python/client/test_oauth.py
@@ -0,0 +1,2659 @@
+#
+# Copyright 2021 VMware, Inc.
+# Portions Copyright 2023 Timescale, Inc.
+# Portions Copyright 2024 PostgreSQL Global Development Group
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import base64
+import collections
+import contextlib
+import ctypes
+import http.server
+import json
+import logging
+import os
+import platform
+import secrets
+import socket
+import ssl
+import sys
+import threading
+import time
+import traceback
+import types
+import urllib.parse
+from numbers import Number
+
+import psycopg2
+import pytest
+
+import pq3
+
+from .conftest import BLOCKING_TIMEOUT
+
+# The client tests need libpq to have been compiled with OAuth support; skip
+# them otherwise.
+pytestmark = pytest.mark.skipif(
+    os.getenv("with_libcurl") != "yes",
+    reason="OAuth client tests require --with-libcurl support",
+)
+
+if platform.system() == "Darwin":
+    libpq = ctypes.cdll.LoadLibrary("libpq.5.dylib")
+elif platform.system() == "Windows":
+    pass  # TODO
+else:
+    libpq = ctypes.cdll.LoadLibrary("libpq.so.5")
+
+
+def finish_handshake(conn):
+    """
+    Sends the AuthenticationOK message and the standard opening salvo of server
+    messages, then asserts that the client immediately sends a Terminate message
+    to close the connection cleanly.
+    """
+    pq3.send(conn, pq3.types.AuthnRequest, type=pq3.authn.OK)
+    pq3.send(conn, pq3.types.ParameterStatus, name=b"client_encoding", value=b"UTF-8")
+    pq3.send(conn, pq3.types.ParameterStatus, name=b"DateStyle", value=b"ISO, MDY")
+    pq3.send(conn, pq3.types.ReadyForQuery, status=b"I")
+
+    pkt = pq3.recv1(conn)
+    assert pkt.type == pq3.types.Terminate
+
+
+#
+# OAUTHBEARER (see RFC 7628: https://tools.ietf.org/html/rfc7628)
+#
+
+
+def start_oauth_handshake(conn):
+    """
+    Negotiates an OAUTHBEARER SASL challenge. Returns the client's initial
+    response data.
+    """
+    startup = pq3.recv1(conn, cls=pq3.Startup)
+    assert startup.proto == pq3.protocol(3, 0)
+
+    pq3.send(
+        conn, pq3.types.AuthnRequest, type=pq3.authn.SASL, body=[b"OAUTHBEARER", b""]
+    )
+
+    pkt = pq3.recv1(conn)
+    assert pkt.type == pq3.types.PasswordMessage
+
+    initial = pq3.SASLInitialResponse.parse(pkt.payload)
+    assert initial.name == b"OAUTHBEARER"
+
+    return initial.data
+
+
+def get_auth_value(initial):
+    """
+    Finds the auth value (e.g. "Bearer somedata..." in the client's initial SASL
+    response.
+    """
+    kvpairs = initial.split(b"\x01")
+    assert kvpairs[0] == b"n,,"  # no channel binding or authzid
+    assert kvpairs[2] == b""  # ends with an empty kvpair
+    assert kvpairs[3] == b""  # ...and there's nothing after it
+    assert len(kvpairs) == 4
+
+    key, value = kvpairs[1].split(b"=", 2)
+    assert key == b"auth"
+
+    return value
+
+
+def fail_oauth_handshake(conn, sasl_resp, *, errmsg="doesn't matter"):
+    """
+    Sends a failure response via the OAUTHBEARER mechanism, consumes the
+    client's dummy response, and issues a FATAL error to end the exchange.
+
+    sasl_resp is a dictionary which will be serialized as the OAUTHBEARER JSON
+    response. If provided, errmsg is used in the FATAL ErrorResponse.
+    """
+    resp = json.dumps(sasl_resp)
+    pq3.send(
+        conn,
+        pq3.types.AuthnRequest,
+        type=pq3.authn.SASLContinue,
+        body=resp.encode("utf-8"),
+    )
+
+    # Per RFC, the client is required to send a dummy ^A response.
+    pkt = pq3.recv1(conn)
+    assert pkt.type == pq3.types.PasswordMessage
+    assert pkt.payload == b"\x01"
+
+    # Now fail the SASL exchange.
+    pq3.send(
+        conn,
+        pq3.types.ErrorResponse,
+        fields=[
+            b"SFATAL",
+            b"C28000",
+            b"M" + errmsg.encode("utf-8"),
+            b"",
+        ],
+    )
+
+
+def handle_discovery_connection(sock, discovery=None, *, response=None):
+    """
+    Helper for all tests that expect an initial discovery connection from the
+    client. The provided discovery URI will be used in a standard error response
+    from the server (or response may be set, to provide a custom dictionary),
+    and the SASL exchange will be failed.
+
+    By default, the client is expected to complete the entire handshake. Set
+    finish to False if the client should immediately disconnect when it receives
+    the error response.
+    """
+    if response is None:
+        response = {"status": "invalid_token"}
+        if discovery is not None:
+            response["openid-configuration"] = discovery
+
+    with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+        # Initiate a handshake.
+        initial = start_oauth_handshake(conn)
+
+        # For discovery, the client should send an empty auth header. See RFC
+        # 7628, Sec. 4.3.
+        auth = get_auth_value(initial)
+        assert auth == b""
+
+        # The discovery handshake is doomed to fail.
+        fail_oauth_handshake(conn, response)
+
+
+class RawResponse(str):
+    """
+    Returned by registered endpoint callbacks to take full control of the
+    response. Usually, return values are converted to JSON; a RawResponse body
+    will be passed to the client as-is, allowing endpoint implementations to
+    issue invalid JSON.
+    """
+
+    pass
+
+
+class RawBytes(bytes):
+    """
+    Like RawResponse, but bypasses the UTF-8 encoding step as well, allowing
+    implementations to issue invalid encodings.
+    """
+
+    pass
+
+
+class OpenIDProvider(threading.Thread):
+    """
+    A thread that runs a mock OpenID provider server on an SSL-enabled socket.
+    """
+
+    def __init__(self, ssl_socket):
+        super().__init__()
+
+        self.exception = None
+
+        _, port = ssl_socket.getsockname()
+
+        oauth = self._OAuthState()
+        oauth.host = f"localhost:{port}"
+        oauth.issuer = f"https://localhost:{port}"
+
+        # The following endpoints are required to be advertised by providers,
+        # even though our chosen client implementation does not actually make
+        # use of them.
+        oauth.register_endpoint(
+            "authorization_endpoint", "POST", "/authorize", self._authorization_handler
+        )
+        oauth.register_endpoint("jwks_uri", "GET", "/keys", self._jwks_handler)
+
+        self.server = self._HTTPSServer(ssl_socket, self._Handler)
+        self.server.oauth = oauth
+
+    def run(self):
+        try:
+            # XXX socketserver.serve_forever() has a serious architectural
+            # issue: its select loop wakes up every `poll_interval` seconds to
+            # see if the server is shutting down. The default, 500 ms, only lets
+            # us run two tests every second. But the faster we go, the more CPU
+            # we burn unnecessarily...
+            self.server.serve_forever(poll_interval=0.01)
+        except Exception as e:
+            self.exception = e
+
+    def stop(self, timeout=BLOCKING_TIMEOUT):
+        """
+        Shuts down the server and joins its thread. Raises an exception if the
+        thread could not be joined, or if it threw an exception itself. Must
+        only be called once, after start().
+        """
+        self.server.shutdown()
+        self.join(timeout)
+
+        if self.is_alive():
+            raise TimeoutError("client thread did not handshake within the timeout")
+        elif self.exception:
+            e = self.exception
+            raise e
+
+    class _OAuthState(object):
+        def __init__(self):
+            self.endpoint_paths = {}
+            self._endpoints = {}
+
+            # Provide a standard discovery document by default; tests can
+            # override it.
+            self.register_endpoint(
+                None,
+                "GET",
+                "/.well-known/openid-configuration",
+                self._default_discovery_handler,
+            )
+
+            # Default content type unless overridden.
+            self.content_type = "application/json"
+
+        @property
+        def discovery_uri(self):
+            return f"{self.issuer}/.well-known/openid-configuration"
+
+        def register_endpoint(self, name, method, path, func):
+            if method not in self._endpoints:
+                self._endpoints[method] = {}
+
+            self._endpoints[method][path] = func
+
+            if name is not None:
+                self.endpoint_paths[name] = path
+
+        def endpoint(self, method, path):
+            if method not in self._endpoints:
+                return None
+
+            return self._endpoints[method].get(path)
+
+        def _default_discovery_handler(self, headers, params):
+            doc = {
+                "issuer": self.issuer,
+                "response_types_supported": ["token"],
+                "subject_types_supported": ["public"],
+                "id_token_signing_alg_values_supported": ["RS256"],
+                "grant_types_supported": [
+                    "authorization_code",
+                    "urn:ietf:params:oauth:grant-type:device_code",
+                ],
+            }
+
+            for name, path in self.endpoint_paths.items():
+                doc[name] = self.issuer + path
+
+            return 200, doc
+
+    class _HTTPSServer(http.server.HTTPServer):
+        def __init__(self, ssl_socket, handler_cls):
+            # Attach the SSL socket to the server. We don't bind/activate since
+            # the socket is already listening.
+            super().__init__(None, handler_cls, bind_and_activate=False)
+            self.socket = ssl_socket
+            self.server_address = self.socket.getsockname()
+
+        def shutdown_request(self, request):
+            # Cleanly unwrap the SSL socket before shutting down the connection;
+            # otherwise careful clients will complain about truncation.
+            try:
+                request = request.unwrap()
+            except (ssl.SSLEOFError, ConnectionResetError, BrokenPipeError):
+                # The client already closed (or aborted) the connection without
+                # a clean shutdown. This is seen on some platforms during tests
+                # that break the HTTP protocol. Just return and have the server
+                # close the socket.
+                return
+            except ssl.SSLError as err:
+                # FIXME OpenSSL 3.4 introduced an incompatibility with Python's
+                # TLS error handling, resulting in a bogus "[SYS] unknown error"
+                # on some platforms. Hopefully this is fixed in 2025's set of
+                # maintenance releases and this case can be removed.
+                #
+                #     https://github.com/python/cpython/issues/127257
+                #
+                if "[SYS] unknown error" in str(err):
+                    return
+                raise
+
+            super().shutdown_request(request)
+
+        def handle_error(self, request, addr):
+            self.shutdown_request(request)
+            raise
+
+    @staticmethod
+    def _jwks_handler(headers, params):
+        return 200, {"keys": []}
+
+    @staticmethod
+    def _authorization_handler(headers, params):
+        # We don't actually want this to be called during these tests -- we
+        # should be using the device authorization endpoint instead.
+        assert (
+            False
+        ), "authorization handler called instead of device authorization handler"
+
+    class _Handler(http.server.BaseHTTPRequestHandler):
+        timeout = BLOCKING_TIMEOUT
+
+        def _handle(self, *, params=None, handler=None):
+            oauth = self.server.oauth
+            assert self.headers["Host"] == oauth.host
+
+            # XXX: BaseHTTPRequestHandler collapses leading slashes in the path
+            # to work around an open redirection vuln (gh-87389) in
+            # SimpleHTTPServer. But we're not using SimpleHTTPServer, and we
+            # want to test repeating leading slashes, so that's not very
+            # helpful. Put them back.
+            orig_path = self.raw_requestline.split()[1]
+            orig_path = str(orig_path, "iso-8859-1")
+            assert orig_path.endswith(self.path)  # sanity check
+            self.path = orig_path
+
+            if handler is None:
+                handler = oauth.endpoint(self.command, self.path)
+                assert (
+                    handler is not None
+                ), f"no registered endpoint for {self.command} {self.path}"
+
+            result = handler(self.headers, params)
+
+            if len(result) == 2:
+                headers = {"Content-Type": oauth.content_type}
+                code, resp = result
+            else:
+                code, headers, resp = result
+
+            self.send_response(code)
+            for h, v in headers.items():
+                self.send_header(h, v)
+            self.end_headers()
+
+            if resp is not None:
+                if not isinstance(resp, RawBytes):
+                    if not isinstance(resp, RawResponse):
+                        resp = json.dumps(resp)
+                    resp = resp.encode("utf-8")
+                self.wfile.write(resp)
+
+            self.close_connection = True
+
+        def do_GET(self):
+            self._handle()
+
+        def _request_body(self):
+            length = self.headers["Content-Length"]
+
+            # Handle only an explicit content-length.
+            assert length is not None
+            length = int(length)
+
+            return self.rfile.read(length).decode("utf-8")
+
+        def do_POST(self):
+            assert self.headers["Content-Type"] == "application/x-www-form-urlencoded"
+
+            body = self._request_body()
+            if body:
+                # parse_qs() is understandably fairly lax when it comes to
+                # acceptable characters, but we're stricter. Spaces must be
+                # encoded, and they must use the '+' encoding rather than "%20".
+                assert " " not in body
+                assert "%20" not in body
+
+                params = urllib.parse.parse_qs(
+                    body,
+                    keep_blank_values=True,
+                    strict_parsing=True,
+                    encoding="utf-8",
+                    errors="strict",
+                )
+            else:
+                params = {}
+
+            self._handle(params=params)
+
+
[email protected](autouse=True)
+def enable_client_oauth_debugging(monkeypatch):
+    """
+    HTTP providers aren't allowed by default; enable them via envvar.
+    """
+    monkeypatch.setenv("PGOAUTHDEBUG", "UNSAFE")
+
+
[email protected](autouse=True)
+def trust_certpair_in_client(monkeypatch, certpair):
+    """
+    Set a trusted CA file for OAuth client connections.
+    """
+    monkeypatch.setenv("PGOAUTHCAFILE", certpair[0])
+
+
[email protected](scope="session")
+def ssl_socket(certpair):
+    """
+    A listening server-side socket for SSL connections, using the certpair
+    fixture.
+    """
+    sock = socket.create_server(("", 0))
+
+    # The TLS connections we're making are incredibly sensitive to delayed ACKs
+    # from the client. (Without TCP_NODELAY, test performance degrades 4-5x.)
+    sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
+
+    with contextlib.closing(sock):
+        # Wrap the server socket for TLS.
+        ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
+        ctx.load_cert_chain(*certpair)
+
+        yield ctx.wrap_socket(sock, server_side=True)
+
+
[email protected]
+def openid_provider(ssl_socket):
+    """
+    A fixture that returns the OAuth state of a running OpenID provider server. The
+    server will be stopped when the fixture is torn down.
+    """
+    thread = OpenIDProvider(ssl_socket)
+    thread.start()
+
+    try:
+        yield thread.server.oauth
+    finally:
+        thread.stop()
+
+
+#
+# PQAuthDataHook implementation, matching libpq.h
+#
+
+
+PQAUTHDATA_PROMPT_OAUTH_DEVICE = 0
+PQAUTHDATA_OAUTH_BEARER_TOKEN = 1
+
+PGRES_POLLING_FAILED = 0
+PGRES_POLLING_READING = 1
+PGRES_POLLING_WRITING = 2
+PGRES_POLLING_OK = 3
+
+
+class PGPromptOAuthDevice(ctypes.Structure):
+    _fields_ = [
+        ("verification_uri", ctypes.c_char_p),
+        ("user_code", ctypes.c_char_p),
+    ]
+
+
+class PGOAuthBearerRequest(ctypes.Structure):
+    pass
+
+
+PGOAuthBearerRequest._fields_ = [
+    ("openid_configuration", ctypes.c_char_p),
+    ("scope", ctypes.c_char_p),
+    (
+        "async_",
+        ctypes.CFUNCTYPE(
+            ctypes.c_int,
+            ctypes.c_void_p,
+            ctypes.POINTER(PGOAuthBearerRequest),
+            ctypes.POINTER(ctypes.c_int),
+        ),
+    ),
+    (
+        "cleanup",
+        ctypes.CFUNCTYPE(None, ctypes.c_void_p, ctypes.POINTER(PGOAuthBearerRequest)),
+    ),
+    ("token", ctypes.c_char_p),
+    ("user", ctypes.c_void_p),
+]
+
+
[email protected]
+def auth_data_cb():
+    """
+    Tracks calls to the libpq authdata hook. The yielded object contains a calls
+    member that records the data sent to the hook. If a test needs to perform
+    custom actions during a call, it can set the yielded object's impl callback;
+    beware that the callback takes place on a different thread.
+
+    This is done differently from the other callback implementations on purpose.
+    For the others, we can declare test-specific callbacks and have them perform
+    direct assertions on the data they receive. But that won't work for a C
+    callback, because there's no way for us to bubble up the assertion through
+    libpq. Instead, this mock-style approach is taken, where we just record the
+    calls and let the test examine them later.
+    """
+
+    class _Call:
+        pass
+
+    class _cb(object):
+        def __init__(self):
+            self.calls = []
+
+    cb = _cb()
+    cb.impl = None
+
+    # The callback will occur on a different thread, so protect the cb object.
+    cb_lock = threading.Lock()
+
+    @ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_byte, ctypes.c_void_p, ctypes.c_void_p)
+    def auth_data_cb(typ, pgconn, data):
+        handle_by_default = 0  # does an implementation have to be provided?
+
+        if typ == PQAUTHDATA_PROMPT_OAUTH_DEVICE:
+            cls = PGPromptOAuthDevice
+            handle_by_default = 1
+        elif typ == PQAUTHDATA_OAUTH_BEARER_TOKEN:
+            cls = PGOAuthBearerRequest
+        else:
+            return 0
+
+        call = _Call()
+        call.type = typ
+
+        # The lifetime of the underlying data being pointed to doesn't
+        # necessarily match the lifetime of the Python object, so we can't
+        # reference a Structure's fields after returning. Explicitly copy the
+        # contents over, field by field.
+        data = ctypes.cast(data, ctypes.POINTER(cls))
+        for name, _ in cls._fields_:
+            setattr(call, name, getattr(data.contents, name))
+
+        with cb_lock:
+            cb.calls.append(call)
+
+        if cb.impl:
+            # Pass control back to the test.
+            try:
+                return cb.impl(typ, pgconn, data.contents)
+            except Exception:
+                # This can't escape into the C stack, but we can fail the flow
+                # and hope the traceback gives us enough detail.
+                logging.error(
+                    "Exception during authdata hook callback:\n"
+                    + traceback.format_exc()
+                )
+                return -1
+
+        return handle_by_default
+
+    libpq.PQsetAuthDataHook(auth_data_cb)
+    try:
+        yield cb
+    finally:
+        # The callback is about to go out of scope, so make sure libpq is
+        # disconnected from it. (We wouldn't want to accidentally influence
+        # later tests anyway.)
+        libpq.PQsetAuthDataHook(None)
+
+
[email protected](
+    "success, abnormal_failure",
+    [
+        pytest.param(True, False, id="success"),
+        pytest.param(False, False, id="normal failure"),
+        pytest.param(False, True, id="abnormal failure"),
+    ],
+)
[email protected]("secret", [None, "", "hunter2"])
[email protected]("scope", [None, "", "openid email"])
[email protected]("retries", [0, 1])
[email protected](
+    "content_type",
+    [
+        pytest.param("application/json", id="standard"),
+        pytest.param("application/json;charset=utf-8", id="charset"),
+        pytest.param("application/json \t;\t charset=utf-8", id="charset (whitespace)"),
+    ],
+)
[email protected]("uri_spelling", ["verification_url", "verification_uri"])
[email protected](
+    "asynchronous",
+    [
+        pytest.param(False, id="synchronous"),
+        pytest.param(True, id="asynchronous"),
+    ],
+)
+def test_oauth_with_explicit_discovery_uri(
+    accept,
+    openid_provider,
+    asynchronous,
+    uri_spelling,
+    content_type,
+    retries,
+    scope,
+    secret,
+    auth_data_cb,
+    success,
+    abnormal_failure,
+):
+    client_id = secrets.token_hex()
+    openid_provider.content_type = content_type
+
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id=client_id,
+        oauth_client_secret=secret,
+        oauth_scope=scope,
+        async_=asynchronous,
+    )
+
+    device_code = secrets.token_hex()
+    user_code = f"{secrets.token_hex(2)}-{secrets.token_hex(2)}"
+    verification_url = "https://example.com/device"
+
+    access_token = secrets.token_urlsafe()
+
+    def check_client_authn(headers, params):
+        if secret is None:
+            assert "Authorization" not in headers
+            assert params["client_id"] == [client_id]
+            return
+
+        # Require the client to use Basic authn; request-body credentials are
+        # NOT RECOMMENDED (RFC 6749, Sec. 2.3.1).
+        assert "Authorization" in headers
+        assert "client_id" not in params
+
+        method, creds = headers["Authorization"].split()
+        assert method == "Basic"
+
+        expected = f"{client_id}:{secret}"
+        assert base64.b64decode(creds) == expected.encode("ascii")
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        check_client_authn(headers, params)
+
+        if scope:
+            assert params["scope"] == [scope]
+        else:
+            assert "scope" not in params
+
+        resp = {
+            "device_code": device_code,
+            "user_code": user_code,
+            "interval": 0,
+            uri_spelling: verification_url,
+            "expires_in": 5,
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    attempts = 0
+    retry_lock = threading.Lock()
+
+    def token_endpoint(headers, params):
+        check_client_authn(headers, params)
+
+        assert params["grant_type"] == ["urn:ietf:params:oauth:grant-type:device_code"]
+        assert params["device_code"] == [device_code]
+
+        now = time.monotonic()
+
+        with retry_lock:
+            nonlocal attempts
+
+            # If the test wants to force the client to retry, return an
+            # authorization_pending response and decrement the retry count.
+            if attempts < retries:
+                attempts += 1
+                return 400, {"error": "authorization_pending"}
+
+        # Successfully finish the request by sending the access bearer token.
+        resp = {
+            "access_token": access_token,
+            "token_type": "bearer",
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    # First connection is a discovery request, which should result in the above
+    # endpoints being called.
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    # Client should reconnect.
+    sock, _ = accept()
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            initial = start_oauth_handshake(conn)
+
+            # Validate and accept the token.
+            auth = get_auth_value(initial)
+            assert auth == f"Bearer {access_token}".encode("ascii")
+
+            if success:
+                finish_handshake(conn)
+
+            elif abnormal_failure:
+                # Send an empty error response, which should result in a
+                # mechanism-level failure in the client. This test ensures that
+                # the client doesn't try a third connection for this case.
+                expected_error = "server sent error response without a status"
+                fail_oauth_handshake(conn, {})
+
+            else:
+                # Simulate token validation failure.
+                resp = {
+                    "status": "invalid_token",
+                    "openid-configuration": openid_provider.discovery_uri,
+                }
+                expected_error = "test token validation failure"
+                fail_oauth_handshake(conn, resp, errmsg=expected_error)
+
+    if retries:
+        # Finally, make sure that the client prompted the user once with the
+        # expected authorization URL and user code.
+        assert len(auth_data_cb.calls) == 2
+
+        # First call should have been for a custom flow, which we ignored.
+        assert auth_data_cb.calls[0].type == PQAUTHDATA_OAUTH_BEARER_TOKEN
+
+        # Second call is for our user prompt.
+        call = auth_data_cb.calls[1]
+        assert call.type == PQAUTHDATA_PROMPT_OAUTH_DEVICE
+        assert call.verification_uri.decode() == verification_url
+        assert call.user_code.decode() == user_code
+
+    if not success:
+        # The client should not try to connect again.
+        with pytest.raises(psycopg2.OperationalError, match=expected_error):
+            client.check_completed()
+
+
[email protected](
+    "server_discovery",
+    [
+        pytest.param(True, id="server discovery"),
+        pytest.param(False, id="direct discovery"),
+    ],
+)
[email protected](
+    "issuer, path",
+    [
+        pytest.param(
+            "{issuer}",
+            "/.well-known/oauth-authorization-server",
+            id="oauth",
+        ),
+        pytest.param(
+            "{issuer}/alt",
+            "/.well-known/oauth-authorization-server/alt",
+            id="oauth with path, IETF style",
+        ),
+        pytest.param(
+            "{issuer}/alt",
+            "/alt/.well-known/oauth-authorization-server",
+            id="oauth with path, broken OIDC style",
+        ),
+        pytest.param(
+            "{issuer}/alt",
+            "/alt/.well-known/openid-configuration",
+            id="openid with path, OIDC style",
+        ),
+        pytest.param(
+            "{issuer}/alt",
+            "/.well-known/openid-configuration/alt",
+            id="openid with path, IETF style",
+        ),
+        pytest.param(
+            "{issuer}/",
+            "//.well-known/openid-configuration",
+            id="empty path segment, OIDC style",
+        ),
+        pytest.param(
+            "{issuer}/",
+            "/.well-known/openid-configuration/",
+            id="empty path segment, IETF style",
+        ),
+    ],
+)
+def test_alternate_well_known_paths(
+    accept, openid_provider, issuer, path, server_discovery
+):
+    issuer = issuer.format(issuer=openid_provider.issuer)
+    discovery_uri = openid_provider.issuer + path
+
+    client_id = secrets.token_hex()
+    access_token = secrets.token_urlsafe()
+
+    def discovery_handler(*args):
+        """
+        Pass-through implementation of the discovery handler. Modifies the
+        default document to contain this test's issuer identifier.
+        """
+        code, doc = openid_provider._default_discovery_handler(*args)
+        doc["issuer"] = issuer
+        return code, doc
+
+    openid_provider.register_endpoint(None, "GET", path, discovery_handler)
+
+    def authorization_endpoint(headers, params):
+        resp = {
+            "device_code": "12345",
+            "user_code": "ABCDE",
+            "interval": 0,
+            "verification_url": "https://example.com/device",
+            "expires_in": 5,
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    def token_endpoint(headers, params):
+        # Successfully finish the request by sending the access bearer token.
+        resp = {
+            "access_token": access_token,
+            "token_type": "bearer",
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    kwargs = dict(oauth_client_id=client_id)
+    if server_discovery:
+        kwargs.update(oauth_issuer=issuer)
+    else:
+        kwargs.update(oauth_issuer=discovery_uri)
+
+    sock, client = accept(**kwargs)
+
+    with sock:
+        handle_discovery_connection(sock, discovery_uri)
+
+    # Expect the client to connect again.
+    sock, _ = accept()
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            initial = start_oauth_handshake(conn)
+
+            # Validate the token.
+            auth = get_auth_value(initial)
+            assert auth == f"Bearer {access_token}".encode("ascii")
+
+            finish_handshake(conn)
+
+
[email protected](
+    "server_discovery",
+    [
+        pytest.param(True, id="server discovery"),
+        pytest.param(False, id="direct discovery"),
+    ],
+)
[email protected](
+    "issuer, path, expected_error",
+    [
+        pytest.param(
+            "{issuer}",
+            "/.well-known/oauth-authorization-server/",
+            None,
+            id="extra empty segment (no path)",
+        ),
+        pytest.param(
+            "{issuer}/path",
+            "/.well-known/oauth-authorization-server/path/",
+            None,
+            id="extra empty segment (with path)",
+        ),
+        pytest.param(
+            "{issuer}",
+            "?/.well-known/oauth-authorization-server",
+            r'OAuth discovery URI ".*" must not contain query or fragment components',
+            id="query",
+        ),
+        pytest.param(
+            "{issuer}",
+            "#/.well-known/oauth-authorization-server",
+            r'OAuth discovery URI ".*" must not contain query or fragment components',
+            id="fragment",
+        ),
+        pytest.param(
+            "{issuer}/sub/path",
+            "/sub/.well-known/oauth-authorization-server/path",
+            r'OAuth discovery URI ".*" uses an invalid format',
+            id="sandwiched prefix",
+        ),
+        pytest.param(
+            "{issuer}/path",
+            "/path/openid-configuration",
+            r'OAuth discovery URI ".*" is not a .well-known URI',
+            id="not .well-known",
+        ),
+        pytest.param(
+            "{issuer}",
+            "https://.well-known/oauth-authorization-server",
+            r'OAuth discovery URI ".*" is not a .well-known URI',
+            id=".well-known prefix buried in the authority",
+        ),
+        pytest.param(
+            "{issuer}",
+            "/.well-known/oauth-protected-resource",
+            r'OAuth discovery URI ".*" uses an unsupported .well-known suffix',
+            id="unknown well-known suffix",
+        ),
+        pytest.param(
+            "{issuer}/path",
+            "/path/.well-known/openid-configuration-2",
+            r'OAuth discovery URI ".*" uses an unsupported .well-known suffix',
+            id="unknown well-known suffix, OIDC style",
+        ),
+        pytest.param(
+            "{issuer}/path",
+            "/.well-known/oauth-authorization-server-2/path",
+            r'OAuth discovery URI ".*" uses an unsupported .well-known suffix',
+            id="unknown well-known suffix, IETF style",
+        ),
+        pytest.param(
+            "{issuer}",
+            "file:///.well-known/oauth-authorization-server",
+            r'OAuth discovery URI ".*" must use HTTPS',
+            id="unsupported scheme",
+        ),
+    ],
+)
+def test_bad_well_known_paths(
+    accept, openid_provider, issuer, path, expected_error, server_discovery
+):
+    if not server_discovery and "/.well-known/" not in path:
+        # An oauth_issuer without a /.well-known/ path segment is just a normal
+        # issuer identifier, so this isn't an interesting test.
+        pytest.skip("not interesting: direct discovery requires .well-known")
+
+    issuer = issuer.format(issuer=openid_provider.issuer)
+    discovery_uri = urllib.parse.urljoin(openid_provider.issuer, path)
+
+    client_id = secrets.token_hex()
+
+    def discovery_handler(*args):
+        """
+        Pass-through implementation of the discovery handler. Modifies the
+        default document to contain this test's issuer identifier.
+        """
+        code, doc = openid_provider._default_discovery_handler(*args)
+        doc["issuer"] = issuer
+        return code, doc
+
+    openid_provider.register_endpoint(None, "GET", path, discovery_handler)
+
+    def fail(*args):
+        """
+        No other endpoints should be contacted; fail if the client tries.
+        """
+        assert False, "endpoint unexpectedly called"
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", fail
+    )
+    openid_provider.register_endpoint("token_endpoint", "POST", "/token", fail)
+
+    kwargs = dict(oauth_client_id=client_id)
+    if server_discovery:
+        kwargs.update(oauth_issuer=issuer)
+    else:
+        kwargs.update(oauth_issuer=discovery_uri)
+
+    sock, client = accept(**kwargs)
+    with sock:
+        if expected_error and not server_discovery:
+            # If the client already knows the URL, it should disconnect as soon
+            # as it realizes it's not valid.
+            expect_disconnected_handshake(sock)
+        else:
+            # Otherwise, it should complete the connection.
+            handle_discovery_connection(sock, discovery_uri)
+
+    # The client should not reconnect.
+
+    if expected_error is None:
+        if server_discovery:
+            expected_error = rf"server's discovery document at {discovery_uri} \(issuer \".*\"\) is incompatible with oauth_issuer \({issuer}\)"
+        else:
+            expected_error = rf"the issuer identifier \({issuer}\) does not match oauth_issuer \(.*\)"
+
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
+def expect_disconnected_handshake(sock):
+    """
+    Helper for any tests that expect the client to disconnect immediately after
+    being sent the OAUTHBEARER SASL method. Generally speaking, this requires
+    the client to have an oauth_issuer set so that it doesn't try to go through
+    discovery.
+    """
+    with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+        # Initiate a handshake.
+        startup = pq3.recv1(conn, cls=pq3.Startup)
+        assert startup.proto == pq3.protocol(3, 0)
+
+        pq3.send(
+            conn,
+            pq3.types.AuthnRequest,
+            type=pq3.authn.SASL,
+            body=[b"OAUTHBEARER", b""],
+        )
+
+        # The client should disconnect at this point.
+        assert not conn.read(1), "client sent unexpected data"
+
+
[email protected](
+    "missing",
+    [
+        pytest.param(["oauth_issuer"], id="missing oauth_issuer"),
+        pytest.param(["oauth_client_id"], id="missing oauth_client_id"),
+        pytest.param(["oauth_client_id", "oauth_issuer"], id="missing both"),
+    ],
+)
+def test_oauth_requires_issuer_and_client_id(accept, openid_provider, missing):
+    params = dict(
+        oauth_issuer=openid_provider.issuer,
+        oauth_client_id="some-id",
+    )
+
+    # Remove required parameters. This should cause a client error after the
+    # server asks for OAUTHBEARER and the client tries to contact the issuer.
+    for k in missing:
+        del params[k]
+
+    sock, client = accept(**params)
+    with sock:
+        expect_disconnected_handshake(sock)
+
+    expected_error = "oauth_issuer and oauth_client_id are not both set"
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
+# See https://datatracker.ietf.org/doc/html/rfc6749#appendix-A for character
+# class definitions.
+all_vschars = "".join([chr(c) for c in range(0x20, 0x7F)])
+all_nqchars = "".join([chr(c) for c in range(0x21, 0x7F) if c not in (0x22, 0x5C)])
+
+
[email protected]("client_id", ["", ":", " + ", r'+=&"\/~', all_vschars])
[email protected]("secret", [None, "", ":", " + ", r'+=&"\/~', all_vschars])
[email protected]("device_code", ["", " + ", r'+=&"\/~', all_vschars])
[email protected]("scope", ["&", r"+=&/", all_nqchars])
+def test_url_encoding(accept, openid_provider, client_id, secret, device_code, scope):
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id=client_id,
+        oauth_client_secret=secret,
+        oauth_scope=scope,
+    )
+
+    user_code = f"{secrets.token_hex(2)}-{secrets.token_hex(2)}"
+    verification_url = "https://example.com/device"
+
+    access_token = secrets.token_urlsafe()
+
+    def check_client_authn(headers, params):
+        if secret is None:
+            assert "Authorization" not in headers
+            assert params["client_id"] == [client_id]
+            return
+
+        # Require the client to use Basic authn; request-body credentials are
+        # NOT RECOMMENDED (RFC 6749, Sec. 2.3.1).
+        assert "Authorization" in headers
+        assert "client_id" not in params
+
+        method, creds = headers["Authorization"].split()
+        assert method == "Basic"
+
+        decoded = base64.b64decode(creds).decode("utf-8")
+        username, password = decoded.split(":", 1)
+
+        expected_username = urllib.parse.quote_plus(client_id)
+        expected_password = urllib.parse.quote_plus(secret)
+
+        assert [username, password] == [expected_username, expected_password]
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        check_client_authn(headers, params)
+
+        if scope:
+            assert params["scope"] == [scope]
+        else:
+            assert "scope" not in params
+
+        resp = {
+            "device_code": device_code,
+            "user_code": user_code,
+            "interval": 0,
+            "verification_url": verification_url,
+            "expires_in": 5,
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    def token_endpoint(headers, params):
+        check_client_authn(headers, params)
+
+        assert params["grant_type"] == ["urn:ietf:params:oauth:grant-type:device_code"]
+        assert params["device_code"] == [device_code]
+
+        # Successfully finish the request by sending the access bearer token.
+        resp = {
+            "access_token": access_token,
+            "token_type": "bearer",
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    # First connection is a discovery request, which should result in the above
+    # endpoints being called.
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    # Second connection sends the token.
+    sock, _ = accept()
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            initial = start_oauth_handshake(conn)
+
+            # Validate and accept the token.
+            auth = get_auth_value(initial)
+            assert auth == f"Bearer {access_token}".encode("ascii")
+
+            finish_handshake(conn)
+
+
[email protected]
[email protected]("error_code", ["authorization_pending", "slow_down"])
[email protected]("retries", [1, 2])
[email protected]("omit_interval", [True, False])
+def test_oauth_retry_interval(
+    accept, openid_provider, omit_interval, retries, error_code
+):
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id="some-id",
+    )
+
+    expected_retry_interval = 5 if omit_interval else 1
+    access_token = secrets.token_urlsafe()
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        resp = {
+            "device_code": "my-device-code",
+            "user_code": "my-user-code",
+            "verification_uri": "https://example.com",
+            "expires_in": 5,
+        }
+
+        if not omit_interval:
+            resp["interval"] = expected_retry_interval
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    attempts = 0
+    last_retry = None
+    retry_lock = threading.Lock()
+    token_sent = threading.Event()
+
+    def token_endpoint(headers, params):
+        now = time.monotonic()
+
+        with retry_lock:
+            nonlocal attempts, last_retry, expected_retry_interval
+
+            # Make sure the retry interval is being respected by the client.
+            if last_retry is not None:
+                interval = now - last_retry
+                assert interval >= expected_retry_interval
+
+            last_retry = now
+
+            # If the test wants to force the client to retry, return the desired
+            # error response and decrement the retry count.
+            if attempts < retries:
+                attempts += 1
+
+                # A slow_down code requires the client to additionally increase
+                # its interval by five seconds.
+                if error_code == "slow_down":
+                    expected_retry_interval += 5
+
+                return 400, {"error": error_code}
+
+        # Successfully finish the request by sending the access bearer token,
+        # and signal the main thread to continue.
+        resp = {
+            "access_token": access_token,
+            "token_type": "bearer",
+        }
+        token_sent.set()
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    # First connection is a discovery request, which should result in the above
+    # endpoints being called.
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    # At this point the client is talking to the authorization server. Wait for
+    # that to succeed so we don't run into the accept() timeout.
+    token_sent.wait()
+
+    # Client should reconnect and send the token.
+    sock, _ = accept()
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            initial = start_oauth_handshake(conn)
+
+            # Validate and accept the token.
+            auth = get_auth_value(initial)
+            assert auth == f"Bearer {access_token}".encode("ascii")
+
+            finish_handshake(conn)
+
+
[email protected]
+def self_pipe():
+    """
+    Yields a pipe fd pair.
+    """
+
+    class _Pipe:
+        pass
+
+    p = _Pipe()
+    p.readfd, p.writefd = os.pipe()
+
+    try:
+        yield p
+    finally:
+        os.close(p.readfd)
+        os.close(p.writefd)
+
+
[email protected]("scope", [None, "", "openid email"])
[email protected](
+    "retries",
+    [
+        -1,  # no async callback
+        0,  # async callback immediately returns token
+        1,  # async callback waits on altsock once
+        2,  # async callback waits on altsock twice
+    ],
+)
[email protected](
+    "asynchronous",
+    [
+        pytest.param(False, id="synchronous"),
+        pytest.param(True, id="asynchronous"),
+    ],
+)
+def test_user_defined_flow(
+    accept, auth_data_cb, self_pipe, scope, retries, asynchronous
+):
+    issuer = "http://localhost"
+    discovery_uri = issuer + "/.well-known/openid-configuration"
+    access_token = secrets.token_urlsafe()
+
+    sock, client = accept(
+        oauth_issuer=discovery_uri,
+        oauth_client_id="some-id",
+        oauth_scope=scope,
+        async_=asynchronous,
+    )
+
+    # Track callbacks.
+    attempts = 0
+    wakeup_called = False
+    cleanup_calls = 0
+    lock = threading.Lock()
+
+    def wakeup():
+        """Writes a byte to the wakeup pipe."""
+        nonlocal wakeup_called
+        with lock:
+            wakeup_called = True
+            os.write(self_pipe.writefd, b"\0")
+
+    def get_token(pgconn, request, p_altsock):
+        """
+        Async token callback. While attempts < retries, libpq will be instructed
+        to wait on the self_pipe. When attempts == retries, the token will be
+        set.
+
+        Note that assertions and exceptions raised here are allowed but not very
+        helpful, since they can't bubble through the libpq stack to be collected
+        by the test suite. Try not to rely too heavily on them.
+        """
+        # Make sure libpq passed our user data through.
+        assert request.user == 42
+
+        with lock:
+            nonlocal attempts, wakeup_called
+
+            if attempts:
+                # If we've already started the timer, we shouldn't get a
+                # call back before it trips.
+                assert wakeup_called, "authdata hook was called before the timer"
+
+                # Drain the wakeup byte.
+                os.read(self_pipe.readfd, 1)
+
+            if attempts < retries:
+                attempts += 1
+
+                # Wake up the client in a little bit of time.
+                wakeup_called = False
+                threading.Timer(0.1, wakeup).start()
+
+                # Tell libpq to wait on the other end of the wakeup pipe.
+                p_altsock[0] = self_pipe.readfd
+                return PGRES_POLLING_READING
+
+        # Done!
+        request.token = access_token.encode()
+        return PGRES_POLLING_OK
+
+    @ctypes.CFUNCTYPE(
+        ctypes.c_int,
+        ctypes.c_void_p,
+        ctypes.POINTER(PGOAuthBearerRequest),
+        ctypes.POINTER(ctypes.c_int),
+    )
+    def get_token_wrapper(pgconn, p_request, p_altsock):
+        """
+        Translation layer between C and Python for the async callback.
+        Assertions and exceptions will be swallowed at the boundary, so make
+        sure they don't escape here.
+        """
+        try:
+            return get_token(pgconn, p_request.contents, p_altsock)
+        except Exception:
+            logging.error("Exception during async callback:\n" + traceback.format_exc())
+            return PGRES_POLLING_FAILED
+
+    @ctypes.CFUNCTYPE(None, ctypes.c_void_p, ctypes.POINTER(PGOAuthBearerRequest))
+    def cleanup(pgconn, p_request):
+        """
+        Should be called exactly once per connection.
+        """
+        nonlocal cleanup_calls
+        with lock:
+            cleanup_calls += 1
+
+    def bearer_hook(typ, pgconn, request):
+        """
+        Implementation of the PQAuthDataHook, which either sets up an async
+        callback or returns the token directly, depending on the value of
+        retries.
+
+        As above, try not to rely too much on assertions/exceptions here.
+        """
+        assert typ == PQAUTHDATA_OAUTH_BEARER_TOKEN
+        request.cleanup = cleanup
+
+        if retries < 0:
+            # Special case: return a token immediately without a callback.
+            request.token = access_token.encode()
+            return 1
+
+        # Tell libpq to call us back.
+        request.async_ = get_token_wrapper
+        request.user = ctypes.c_void_p(42)  # will be checked in the callback
+        return 1
+
+    auth_data_cb.impl = bearer_hook
+
+    # Now drive the server side.
+    if retries >= 0:
+        # First connection is a discovery request, which should result in the
+        # hook being invoked.
+        with sock:
+            handle_discovery_connection(sock, discovery_uri)
+
+        # Client should reconnect to send the token.
+        sock, _ = accept()
+
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            # Initiate a handshake, which should result in our custom callback
+            # being invoked to fetch the token.
+            initial = start_oauth_handshake(conn)
+
+            # Validate and accept the token.
+            auth = get_auth_value(initial)
+            assert auth == f"Bearer {access_token}".encode("ascii")
+
+            finish_handshake(conn)
+
+    # Check the data provided to the hook.
+    assert len(auth_data_cb.calls) == 1
+
+    call = auth_data_cb.calls[0]
+    assert call.type == PQAUTHDATA_OAUTH_BEARER_TOKEN
+    assert call.openid_configuration.decode() == discovery_uri
+    assert call.scope == (None if scope is None else scope.encode())
+
+    # Make sure we clean up after ourselves when the connection is finished.
+    client.check_completed()
+    assert cleanup_calls == 1
+
+
+def alt_patterns(*patterns):
+    """
+    Just combines multiple alternative regexes into one. It's not very efficient
+    but IMO it's easier to read and maintain.
+    """
+    pat = ""
+
+    for p in patterns:
+        if pat:
+            pat += "|"
+        pat += f"({p})"
+
+    return pat
+
+
[email protected](
+    "failure_mode, error_pattern",
+    [
+        pytest.param(
+            (
+                401,
+                {
+                    "error": "invalid_client",
+                    "error_description": "client authentication failed",
+                },
+            ),
+            r"failed to obtain device authorization: client authentication failed \(invalid_client\)",
+            id="authentication failure with description",
+        ),
+        pytest.param(
+            (400, {"error": "invalid_request"}),
+            r"failed to obtain device authorization: \(invalid_request\)",
+            id="invalid request without description",
+        ),
+        pytest.param(
+            (400, {"error": "invalid_request", "padding": "x" * 1024 * 1024}),
+            r"failed to obtain device authorization: response is too large",
+            id="gigantic authz response",
+        ),
+        pytest.param(
+            (400, {}),
+            r'failed to parse token error response: field "error" is missing',
+            id="broken error response",
+        ),
+        pytest.param(
+            (401, {"error": "invalid_client"}),
+            r"failed to obtain device authorization: provider requires client authentication, and no oauth_client_secret is set \(invalid_client\)",
+            id="failed authentication without description",
+        ),
+        pytest.param(
+            (200, RawResponse(r'{ "interval": 3.5.8 }')),
+            r"failed to parse device authorization: Token .* is invalid",
+            id="non-numeric interval",
+        ),
+        pytest.param(
+            (200, RawResponse(r'{ "interval": 08 }')),
+            r"failed to parse device authorization: Token .* is invalid",
+            id="invalid numeric interval",
+        ),
+    ],
+)
+def test_oauth_device_authorization_failures(
+    accept, openid_provider, failure_mode, error_pattern
+):
+    client_id = secrets.token_hex()
+
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id=client_id,
+    )
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        return failure_mode
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    def token_endpoint(headers, params):
+        assert False, "token endpoint was invoked unexpectedly"
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    # Now make sure the client correctly failed.
+    with pytest.raises(psycopg2.OperationalError, match=error_pattern):
+        client.check_completed()
+
+
+Missing = object()  # sentinel for test_oauth_device_authorization_bad_json()
+
+
[email protected](
+    "bad_value",
+    [
+        pytest.param({"device_code": 3}, id="object"),
+        pytest.param([1, 2, 3], id="array"),
+        pytest.param("some string", id="string"),
+        pytest.param(4, id="numeric"),
+        pytest.param(False, id="boolean"),
+        pytest.param(None, id="null"),
+        pytest.param(Missing, id="missing"),
+    ],
+)
[email protected](
+    "field_name,ok_type,required",
+    [
+        ("device_code", str, True),
+        ("user_code", str, True),
+        ("verification_uri", str, True),
+        ("interval", int, False),
+    ],
+)
+def test_oauth_device_authorization_bad_json_schema(
+    accept, openid_provider, field_name, ok_type, required, bad_value
+):
+    # To make the test matrix easy, just skip the tests that aren't actually
+    # interesting (field of the correct type, missing optional field).
+    if bad_value is Missing and not required:
+        pytest.skip("not interesting: optional field")
+    elif type(bad_value) == ok_type:  # not isinstance(), because bool is an int
+        pytest.skip("not interesting: correct type")
+
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id=secrets.token_hex(),
+    )
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        # Begin with an acceptable base response...
+        resp = {
+            "device_code": "my-device-code",
+            "user_code": "my-user-code",
+            "interval": 0,
+            "verification_uri": "https://example.com",
+            "expires_in": 5,
+        }
+
+        # ...then tweak it so the client fails.
+        if bad_value is Missing:
+            del resp[field_name]
+        else:
+            resp[field_name] = bad_value
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    def token_endpoint(headers, params):
+        assert False, "token endpoint was invoked unexpectedly"
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    # Now make sure the client correctly failed.
+    if bad_value is Missing:
+        error_pattern = f'field "{field_name}" is missing'
+    elif ok_type == str:
+        error_pattern = f'field "{field_name}" must be a string'
+    elif ok_type == int:
+        error_pattern = f'field "{field_name}" must be a number'
+    else:
+        assert False, "update error_pattern for new failure mode"
+
+    with pytest.raises(psycopg2.OperationalError, match=error_pattern):
+        client.check_completed()
+
+
[email protected](
+    "failure_mode, error_pattern",
+    [
+        pytest.param(
+            (
+                400,
+                {
+                    "error": "expired_token",
+                    "error_description": "the device code has expired",
+                },
+            ),
+            r"failed to obtain access token: the device code has expired \(expired_token\)",
+            id="expired token with description",
+        ),
+        pytest.param(
+            (400, {"error": "access_denied"}),
+            r"failed to obtain access token: \(access_denied\)",
+            id="access denied without description",
+        ),
+        pytest.param(
+            (400, {"error": "access_denied", "padding": "x" * 1024 * 1024}),
+            r"failed to obtain access token: response is too large",
+            id="gigantic token response",
+        ),
+        pytest.param(
+            (400, {}),
+            r'failed to parse token error response: field "error" is missing',
+            id="empty error response",
+        ),
+        pytest.param(
+            (401, {"error": "invalid_client"}),
+            r"failed to obtain access token: provider requires client authentication, and no oauth_client_secret is set \(invalid_client\)",
+            id="authentication failure without description",
+        ),
+        pytest.param(
+            (200, {}, {}),
+            r"failed to parse access token response: no content type was provided",
+            id="missing content type",
+        ),
+        pytest.param(
+            (200, {"Content-Type": "text/plain"}, {}),
+            r"failed to parse access token response: unexpected content type",
+            id="wrong content type",
+        ),
+        pytest.param(
+            (200, {"Content-Type": "application/jsonx"}, {}),
+            r"failed to parse access token response: unexpected content type",
+            id="wrong content type (correct prefix)",
+        ),
+    ],
+)
[email protected]("retries", [0, 1])
+def test_oauth_token_failures(
+    accept, openid_provider, retries, failure_mode, error_pattern
+):
+    client_id = secrets.token_hex()
+
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id=client_id,
+    )
+
+    device_code = secrets.token_hex()
+    user_code = f"{secrets.token_hex(2)}-{secrets.token_hex(2)}"
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        assert params["client_id"] == [client_id]
+
+        resp = {
+            "device_code": device_code,
+            "user_code": user_code,
+            "interval": 0,
+            "verification_uri": "https://example.com/device",
+            "expires_in": 5,
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    retry_lock = threading.Lock()
+    final_sent = False
+
+    def token_endpoint(headers, params):
+        with retry_lock:
+            nonlocal retries, final_sent
+
+            # If the test wants to force the client to retry, return an
+            # authorization_pending response and decrement the retry count.
+            if retries > 0:
+                retries -= 1
+                return 400, {"error": "authorization_pending"}
+
+            # We should only return our failure_mode response once; any further
+            # requests indicate that the client isn't correctly bailing out.
+            assert not final_sent, "client continued after token error"
+
+            final_sent = True
+
+        return failure_mode
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    # Now make sure the client correctly failed.
+    with pytest.raises(psycopg2.OperationalError, match=error_pattern):
+        client.check_completed()
+
+
[email protected](
+    "bad_value",
+    [
+        pytest.param({"device_code": 3}, id="object"),
+        pytest.param([1, 2, 3], id="array"),
+        pytest.param("some string", id="string"),
+        pytest.param(4, id="numeric"),
+        pytest.param(False, id="boolean"),
+        pytest.param(None, id="null"),
+        pytest.param(Missing, id="missing"),
+    ],
+)
[email protected](
+    "field_name,ok_type,required",
+    [
+        ("access_token", str, True),
+        ("token_type", str, True),
+    ],
+)
+def test_oauth_token_bad_json_schema(
+    accept, openid_provider, field_name, ok_type, required, bad_value
+):
+    # To make the test matrix easy, just skip the tests that aren't actually
+    # interesting (field of the correct type, missing optional field).
+    if bad_value is Missing and not required:
+        pytest.skip("not interesting: optional field")
+    elif type(bad_value) == ok_type:  # not isinstance(), because bool is an int
+        pytest.skip("not interesting: correct type")
+
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id=secrets.token_hex(),
+    )
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        resp = {
+            "device_code": "my-device-code",
+            "user_code": "my-user-code",
+            "interval": 0,
+            "verification_uri": "https://example.com",
+            "expires_in": 5,
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    def token_endpoint(headers, params):
+        # Begin with an acceptable base response...
+        resp = {
+            "access_token": secrets.token_urlsafe(),
+            "token_type": "bearer",
+        }
+
+        # ...then tweak it so the client fails.
+        if bad_value is Missing:
+            del resp[field_name]
+        else:
+            resp[field_name] = bad_value
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    # Now make sure the client correctly failed.
+    error_pattern = "failed to parse access token response: "
+    if bad_value is Missing:
+        error_pattern += f'field "{field_name}" is missing'
+    elif ok_type == str:
+        error_pattern += f'field "{field_name}" must be a string'
+    elif ok_type == int:
+        error_pattern += f'field "{field_name}" must be a number'
+    else:
+        assert False, "update error_pattern for new failure mode"
+
+    with pytest.raises(psycopg2.OperationalError, match=error_pattern):
+        client.check_completed()
+
+
[email protected]("success", [True, False])
[email protected]("scope", [None, "openid email"])
[email protected](
+    "base_response",
+    [
+        {"status": "invalid_token"},
+        {"extra_object": {"key": "value"}, "status": "invalid_token"},
+        {"extra_object": {"status": 1}, "status": "invalid_token"},
+    ],
+)
+def test_oauth_discovery(accept, openid_provider, base_response, scope, success):
+    sock, client = accept(
+        oauth_issuer=openid_provider.issuer,
+        oauth_client_id=secrets.token_hex(),
+    )
+
+    device_code = secrets.token_hex()
+    user_code = f"{secrets.token_hex(2)}-{secrets.token_hex(2)}"
+    verification_url = "https://example.com/device"
+
+    access_token = secrets.token_urlsafe()
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        if scope:
+            assert params["scope"] == [scope]
+        else:
+            assert "scope" not in params
+
+        resp = {
+            "device_code": device_code,
+            "user_code": user_code,
+            "interval": 0,
+            "verification_uri": verification_url,
+            "expires_in": 5,
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    def token_endpoint(headers, params):
+        assert params["grant_type"] == ["urn:ietf:params:oauth:grant-type:device_code"]
+        assert params["device_code"] == [device_code]
+
+        # Successfully finish the request by sending the access bearer token.
+        resp = {
+            "access_token": access_token,
+            "token_type": "bearer",
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    # Construct the response to use when failing the SASL exchange. Return a
+    # link to the discovery document, pointing to the test provider server.
+    fail_resp = {
+        **base_response,
+        "openid-configuration": openid_provider.discovery_uri,
+    }
+
+    if scope:
+        fail_resp["scope"] = scope
+
+    with sock:
+        handle_discovery_connection(sock, response=fail_resp)
+
+    # The client will connect to us a second time, using the parameters we sent
+    # it.
+    sock, _ = accept()
+
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            initial = start_oauth_handshake(conn)
+
+            # Validate the token.
+            auth = get_auth_value(initial)
+            assert auth == f"Bearer {access_token}".encode("ascii")
+
+            if success:
+                finish_handshake(conn)
+
+            else:
+                # Simulate token validation failure.
+                expected_error = "test token validation failure"
+                fail_oauth_handshake(conn, fail_resp, errmsg=expected_error)
+
+    if not success:
+        # The client should not try to connect again.
+        with pytest.raises(psycopg2.OperationalError, match=expected_error):
+            client.check_completed()
+
+
[email protected](
+    "response,expected_error",
+    [
+        pytest.param(
+            "abcde",
+            'Token "abcde" is invalid',
+            id="bad JSON: invalid syntax",
+        ),
+        pytest.param(
+            b"\xFF\xFF\xFF\xFF",
+            "server's error response is not valid UTF-8",
+            id="bad JSON: invalid encoding",
+        ),
+        pytest.param(
+            '"abcde"',
+            "top-level element must be an object",
+            id="bad JSON: top-level element is a string",
+        ),
+        pytest.param(
+            "[]",
+            "top-level element must be an object",
+            id="bad JSON: top-level element is an array",
+        ),
+        pytest.param(
+            "{}",
+            "server sent error response without a status",
+            id="bad JSON: no status member",
+        ),
+        pytest.param(
+            '{ "status": null }',
+            'field "status" must be a string',
+            id="bad JSON: null status member",
+        ),
+        pytest.param(
+            '{ "status": 0 }',
+            'field "status" must be a string',
+            id="bad JSON: int status member",
+        ),
+        pytest.param(
+            '{ "status": [ "bad" ] }',
+            'field "status" must be a string',
+            id="bad JSON: array status member",
+        ),
+        pytest.param(
+            '{ "status": { "bad": "bad" } }',
+            'field "status" must be a string',
+            id="bad JSON: object status member",
+        ),
+        pytest.param(
+            '{ "nested": { "status": "bad" } }',
+            "server sent error response without a status",
+            id="bad JSON: nested status",
+        ),
+        pytest.param(
+            '{ "status": "invalid_token" ',
+            "The input string ended unexpectedly",
+            id="bad JSON: unterminated object",
+        ),
+        pytest.param(
+            '{ "status": "invalid_token" } { }',
+            'Expected end of input, but found "{"',
+            id="bad JSON: trailing data",
+        ),
+        pytest.param(
+            '{ "status": "invalid_token", "openid-configuration": 1 }',
+            'field "openid-configuration" must be a string',
+            id="bad JSON: int openid-configuration member",
+        ),
+        pytest.param(
+            '{ "status": "invalid_token", "openid-configuration": 1 }',
+            'field "openid-configuration" must be a string',
+            id="bad JSON: int openid-configuration member",
+        ),
+        pytest.param(
+            '{ "status": "invalid_token", "openid-configuration": "", "openid-configuration": "" }',
+            'field "openid-configuration" is duplicated',
+            id="bad JSON: duplicated field",
+        ),
+        pytest.param(
+            '{ "status": "invalid_token", "scope": 1 }',
+            'field "scope" must be a string',
+            id="bad JSON: int scope member",
+        ),
+    ],
+)
+def test_oauth_discovery_server_error(accept, response, expected_error):
+    sock, client = accept(
+        oauth_issuer="https://example.com",
+        oauth_client_id=secrets.token_hex(),
+    )
+
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            initial = start_oauth_handshake(conn)
+
+            if isinstance(response, str):
+                response = response.encode("utf-8")
+
+            # Fail the SASL exchange with an invalid JSON response.
+            pq3.send(
+                conn,
+                pq3.types.AuthnRequest,
+                type=pq3.authn.SASLContinue,
+                body=response,
+            )
+
+            # The client should disconnect, so the socket is closed here. (If
+            # the client doesn't disconnect, it will report a different error
+            # below and the test will fail.)
+
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
+# All of these tests are expected to fail before libpq tries to actually attempt
+# a connection to any endpoint. To avoid hitting the network in the event that a
+# test fails, an invalid IPv4 address (256.256.256.256) is used as a hostname.
[email protected](
+    "bad_response,expected_error",
+    [
+        pytest.param(
+            (200, {"Content-Type": "text/plain"}, {}),
+            r'failed to parse OpenID discovery document: unexpected content type: "text/plain"',
+            id="not JSON",
+        ),
+        pytest.param(
+            (200, {}, {}),
+            r"failed to parse OpenID discovery document: no content type was provided",
+            id="no Content-Type",
+        ),
+        pytest.param(
+            (204, {}, None),
+            r"failed to fetch OpenID discovery document: unexpected response code 204",
+            id="no content",
+        ),
+        pytest.param(
+            (301, {"Location": "https://localhost/"}, None),
+            r"failed to fetch OpenID discovery document: unexpected response code 301",
+            id="redirection",
+        ),
+        pytest.param(
+            (404, {}),
+            r"failed to fetch OpenID discovery document: unexpected response code 404",
+            id="not found",
+        ),
+        pytest.param(
+            (200, RawResponse("blah\x00blah")),
+            r"failed to parse OpenID discovery document: response contains embedded NULLs",
+            id="NULL bytes in document",
+        ),
+        pytest.param(
+            (200, RawBytes(b"blah\xFFblah")),
+            r"failed to parse OpenID discovery document: response is not valid UTF-8",
+            id="document is not UTF-8",
+        ),
+        pytest.param(
+            (200, 123),
+            r"failed to parse OpenID discovery document: top-level element must be an object",
+            id="scalar at top level",
+        ),
+        pytest.param(
+            (200, []),
+            r"failed to parse OpenID discovery document: top-level element must be an object",
+            id="array at top level",
+        ),
+        pytest.param(
+            (200, RawResponse("{")),
+            r"failed to parse OpenID discovery document.* input string ended unexpectedly",
+            id="unclosed object",
+        ),
+        pytest.param(
+            (200, RawResponse(r'{ "hello": ] }')),
+            r"failed to parse OpenID discovery document.* Expected JSON value",
+            id="bad array",
+        ),
+        pytest.param(
+            (200, {"issuer": 123}),
+            r'failed to parse OpenID discovery document: field "issuer" must be a string',
+            id="non-string issuer",
+        ),
+        pytest.param(
+            (200, {"issuer": ["something"]}),
+            r'failed to parse OpenID discovery document: field "issuer" must be a string',
+            id="issuer array",
+        ),
+        pytest.param(
+            (200, {"issuer": {}}),
+            r'failed to parse OpenID discovery document: field "issuer" must be a string',
+            id="issuer object",
+        ),
+        pytest.param(
+            (200, {"grant_types_supported": 123}),
+            r'failed to parse OpenID discovery document: field "grant_types_supported" must be an array of strings',
+            id="numeric grant types field",
+        ),
+        pytest.param(
+            (
+                200,
+                {
+                    "grant_types_supported": "urn:ietf:params:oauth:grant-type:device_code"
+                },
+            ),
+            r'failed to parse OpenID discovery document: field "grant_types_supported" must be an array of strings',
+            id="string grant types field",
+        ),
+        pytest.param(
+            (200, {"grant_types_supported": {}}),
+            r'failed to parse OpenID discovery document: field "grant_types_supported" must be an array of strings',
+            id="object grant types field",
+        ),
+        pytest.param(
+            (200, {"grant_types_supported": [123]}),
+            r'failed to parse OpenID discovery document: field "grant_types_supported" must be an array of strings',
+            id="non-string grant types",
+        ),
+        pytest.param(
+            (200, {"grant_types_supported": ["something", 123]}),
+            r'failed to parse OpenID discovery document: field "grant_types_supported" must be an array of strings',
+            id="non-string grant types later in the list",
+        ),
+        pytest.param(
+            (200, {"grant_types_supported": ["something", {}]}),
+            r'failed to parse OpenID discovery document: field "grant_types_supported" must be an array of strings',
+            id="object grant types later in the list",
+        ),
+        pytest.param(
+            (200, {"grant_types_supported": ["something", ["something"]]}),
+            r'failed to parse OpenID discovery document: field "grant_types_supported" must be an array of strings',
+            id="embedded array grant types later in the list",
+        ),
+        pytest.param(
+            (
+                200,
+                {
+                    "grant_types_supported": ["something"],
+                    "token_endpoint": "https://256.256.256.256/",
+                    "issuer": 123,
+                },
+            ),
+            r'failed to parse OpenID discovery document: field "issuer" must be a string',
+            id="non-string issuer after other valid fields",
+        ),
+        pytest.param(
+            (
+                200,
+                {
+                    "ignored": {"grant_types_supported": 123, "token_endpoint": 123},
+                    "issuer": 123,
+                },
+            ),
+            r'failed to parse OpenID discovery document: field "issuer" must be a string',
+            id="non-string issuer after other ignored fields",
+        ),
+        pytest.param(
+            (200, {"token_endpoint": "https://256.256.256.256/"}),
+            r'failed to parse OpenID discovery document: field "issuer" is missing',
+            id="missing issuer",
+        ),
+        pytest.param(
+            (200, {"issuer": "{issuer}"}),
+            r'failed to parse OpenID discovery document: field "token_endpoint" is missing',
+            id="missing token endpoint",
+        ),
+        pytest.param(
+            (
+                200,
+                {
+                    "issuer": "{issuer}",
+                    "token_endpoint": "https://256.256.256.256/token",
+                    "grant_types_supported": [
+                        "urn:ietf:params:oauth:grant-type:device_code"
+                    ],
+                },
+            ),
+            r'cannot run OAuth device authorization: issuer "https://.*" does not provide a device authorization endpoint',
+            id="missing device_authorization_endpoint",
+        ),
+        pytest.param(
+            (
+                200,
+                {
+                    "issuer": "{issuer}",
+                    "token_endpoint": "https://256.256.256.256/token",
+                    "grant_types_supported": [
+                        "urn:ietf:params:oauth:grant-type:device_code"
+                    ],
+                    "device_authorization_endpoint": "https://256.256.256.256/dev",
+                    "filler": "x" * 1024 * 1024,
+                },
+            ),
+            r"failed to fetch OpenID discovery document: response is too large",
+            id="gigantic discovery response",
+        ),
+        pytest.param(
+            (
+                200,
+                {
+                    "issuer": "{issuer}/path",
+                    "token_endpoint": "https://256.256.256.256/token",
+                    "grant_types_supported": [
+                        "urn:ietf:params:oauth:grant-type:device_code"
+                    ],
+                    "device_authorization_endpoint": "https://256.256.256.256/dev",
+                },
+            ),
+            r"failed to parse OpenID discovery document: the issuer identifier \(https://.*/path\) does not match oauth_issuer \(https://.*\)",
+            id="mismatched issuer identifier",
+        ),
+        pytest.param(
+            (
+                200,
+                RawResponse(
+                    """{
+                        "issuer": "https://256.256.256.256/path",
+                        "token_endpoint": "https://256.256.256.256/token",
+                        "grant_types_supported": [
+                            "urn:ietf:params:oauth:grant-type:device_code"
+                        ],
+                        "device_authorization_endpoint": "https://256.256.256.256/dev",
+                        "device_authorization_endpoint": "https://256.256.256.256/dev"
+                    }"""
+                ),
+            ),
+            r'failed to parse OpenID discovery document: field "device_authorization_endpoint" is duplicated',
+            id="duplicated field",
+        ),
+        #
+        # Exercise HTTP-level failures by breaking the protocol. Note that the
+        # error messages here are implementation-dependent.
+        #
+        pytest.param(
+            (1000, {}),
+            r"failed to fetch OpenID discovery document: Unsupported protocol \(.*\)",
+            id="invalid HTTP response code",
+        ),
+        pytest.param(
+            (200, {"Content-Length": -1}, {}),
+            r"failed to fetch OpenID discovery document: Weird server reply \(.*Content-Length.*\)",
+            id="bad HTTP Content-Length",
+        ),
+    ],
+)
+def test_oauth_discovery_provider_failure(
+    accept, openid_provider, bad_response, expected_error
+):
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id=secrets.token_hex(),
+    )
+
+    def failing_discovery_handler(headers, params):
+        try:
+            # Insert the correct issuer value if the test wants to.
+            resp = bad_response[1]
+            iss = resp["issuer"]
+            resp["issuer"] = iss.format(issuer=openid_provider.issuer)
+        except (AttributeError, KeyError, TypeError):
+            pass
+
+        return bad_response
+
+    openid_provider.register_endpoint(
+        None,
+        "GET",
+        "/.well-known/openid-configuration",
+        failing_discovery_handler,
+    )
+
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
[email protected](
+    "sasl_err,resp_type,resp_payload,expected_error",
+    [
+        pytest.param(
+            {"status": "invalid_request"},
+            pq3.types.ErrorResponse,
+            dict(
+                fields=[b"SFATAL", b"C28000", b"Mexpected error message", b""],
+            ),
+            "server rejected OAuth bearer token: invalid_request",
+            id="standard server error: invalid_request",
+        ),
+        pytest.param(
+            {"status": "invalid_token"},
+            pq3.types.ErrorResponse,
+            dict(
+                fields=[b"SFATAL", b"C28000", b"Mexpected error message", b""],
+            ),
+            "expected error message",
+            id="standard server error: invalid_token without discovery URI",
+        ),
+        pytest.param(
+            {"status": "invalid_token", "openid-configuration": ""},
+            pq3.types.AuthnRequest,
+            dict(type=pq3.authn.SASLContinue, body=b""),
+            "server sent additional OAuth data",
+            id="broken server: additional challenge after error",
+        ),
+        pytest.param(
+            {"status": "invalid_token", "openid-configuration": ""},
+            pq3.types.AuthnRequest,
+            dict(type=pq3.authn.SASLFinal),
+            "server sent additional OAuth data",
+            id="broken server: SASL success after error",
+        ),
+        pytest.param(
+            {"status": "invalid_token", "openid-configuration": ""},
+            pq3.types.AuthnRequest,
+            dict(type=pq3.authn.SASL, body=[b"OAUTHBEARER", b""]),
+            "duplicate SASL authentication request",
+            id="broken server: SASL reinitialization after error",
+        ),
+    ],
+)
+def test_oauth_server_error(
+    accept, auth_data_cb, sasl_err, resp_type, resp_payload, expected_error
+):
+    wkuri = f"https://256.256.256.256/.well-known/openid-configuration"
+    sock, client = accept(
+        oauth_issuer=wkuri,
+        oauth_client_id="some-id",
+    )
+
+    def bearer_hook(typ, pgconn, request):
+        """
+        Implementation of the PQAuthDataHook, which returns a token directly so
+        we don't need an openid_provider instance.
+        """
+        assert typ == PQAUTHDATA_OAUTH_BEARER_TOKEN
+        request.token = secrets.token_urlsafe().encode()
+        return 1
+
+    auth_data_cb.impl = bearer_hook
+
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            start_oauth_handshake(conn)
+
+            # Ignore the client data. Return an error "challenge".
+            if "openid-configuration" in sasl_err:
+                sasl_err["openid-configuration"] = wkuri
+
+            resp = json.dumps(sasl_err)
+            resp = resp.encode("utf-8")
+
+            pq3.send(
+                conn, pq3.types.AuthnRequest, type=pq3.authn.SASLContinue, body=resp
+            )
+
+            # Per RFC, the client is required to send a dummy ^A response.
+            pkt = pq3.recv1(conn)
+            assert pkt.type == pq3.types.PasswordMessage
+            assert pkt.payload == b"\x01"
+
+            # Now fail the SASL exchange (in either a valid way, or an
+            # invalid one, depending on the test).
+            pq3.send(conn, resp_type, **resp_payload)
+
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
+def test_oauth_interval_overflow(accept, openid_provider):
+    """
+    A really badly behaved server could send a huge interval and then
+    immediately tell us to slow_down; ensure we handle this without breaking.
+    """
+    # (should be equivalent to the INT_MAX in limits.h)
+    int_max = ctypes.c_uint(-1).value // 2
+
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id=secrets.token_hex(),
+    )
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        resp = {
+            "device_code": "my-device-code",
+            "user_code": "my-user-code",
+            "verification_uri": "https://example.com",
+            "expires_in": 5,
+            "interval": int_max,
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    def token_endpoint(headers, params):
+        return 400, {"error": "slow_down"}
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    expected_error = "slow_down interval overflow"
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
+def test_oauth_refuses_http(accept, openid_provider, monkeypatch):
+    """
+    HTTP must be refused without PGOAUTHDEBUG.
+    """
+    monkeypatch.delenv("PGOAUTHDEBUG")
+
+    def to_http(uri):
+        """Swaps out a URI's scheme for http."""
+        parts = urllib.parse.urlparse(uri)
+        parts = parts._replace(scheme="http")
+        return urllib.parse.urlunparse(parts)
+
+    sock, client = accept(
+        oauth_issuer=to_http(openid_provider.issuer),
+        oauth_client_id=secrets.token_hex(),
+    )
+
+    # No provider callbacks necessary; we should fail immediately.
+
+    with sock:
+        handle_discovery_connection(sock, to_http(openid_provider.discovery_uri))
+
+    expected_error = r'OAuth discovery URI ".*" must use HTTPS'
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
[email protected]("auth_type", [pq3.authn.OK, pq3.authn.SASLFinal])
+def test_discovery_incorrectly_permits_connection(accept, auth_type):
+    """
+    Incorrectly responds to a client's discovery request with AuthenticationOK
+    or AuthenticationSASLFinal. require_auth=oauth should catch the former, and
+    the mechanism itself should catch the latter.
+    """
+    issuer = "https://256.256.256.256"
+    sock, client = accept(
+        oauth_issuer=issuer,
+        oauth_client_id=secrets.token_hex(),
+        require_auth="oauth",
+    )
+
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            initial = start_oauth_handshake(conn)
+
+            auth = get_auth_value(initial)
+            assert auth == b""
+
+            # Incorrectly log the client in. It should immediately disconnect.
+            pq3.send(conn, pq3.types.AuthnRequest, type=auth_type)
+            assert not conn.read(1), "client sent unexpected data"
+
+    if auth_type == pq3.authn.OK:
+        expected_error = "server did not complete authentication"
+    else:
+        expected_error = "server sent unexpected additional OAuth data"
+
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
+def test_no_discovery_url_provided(accept):
+    """
+    Tests what happens when the client doesn't know who to contact and the
+    server doesn't tell it.
+    """
+    issuer = "https://256.256.256.256"
+    sock, client = accept(
+        oauth_issuer=issuer,
+        oauth_client_id=secrets.token_hex(),
+    )
+
+    with sock:
+        handle_discovery_connection(sock, discovery=None)
+
+    expected_error = "no discovery metadata was provided"
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
[email protected]("change_between_connections", [False, True])
+def test_discovery_url_changes(accept, openid_provider, change_between_connections):
+    """
+    Ensures that the client complains if the server agrees on the issuer, but
+    disagrees on the discovery URL to be used.
+    """
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        resp = {
+            "device_code": "DEV",
+            "user_code": "USER",
+            "interval": 0,
+            "verification_uri": "https://example.org",
+            "expires_in": 5,
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    def token_endpoint(headers, params):
+        resp = {
+            "access_token": secrets.token_urlsafe(),
+            "token_type": "bearer",
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    # Have the client connect.
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id="some-id",
+    )
+
+    other_wkuri = f"{openid_provider.issuer}/.well-known/oauth-authorization-server"
+
+    if not change_between_connections:
+        # Immediately respond with the wrong URL.
+        with sock:
+            handle_discovery_connection(sock, other_wkuri)
+
+    else:
+        # First connection; use the right URL to begin with.
+        with sock:
+            handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+        # Second connection. Reject the token and switch the URL.
+        sock, _ = accept()
+        with sock:
+            with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+                initial = start_oauth_handshake(conn)
+                get_auth_value(initial)
+
+                # Ignore the token; fail with a different discovery URL.
+                resp = {
+                    "status": "invalid_token",
+                    "openid-configuration": other_wkuri,
+                }
+                fail_oauth_handshake(conn, resp)
+
+    expected_error = rf"server's discovery document has moved to {other_wkuri} \(previous location was {openid_provider.discovery_uri}\)"
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
diff --git a/src/test/python/conftest.py b/src/test/python/conftest.py
new file mode 100644
index 0000000000..1a73865ee4
--- /dev/null
+++ b/src/test/python/conftest.py
@@ -0,0 +1,34 @@
+#
+# Copyright 2023 Timescale, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import os
+
+import pytest
+
+
+def pytest_addoption(parser):
+    """
+    Adds custom command line options to py.test. We add one to signal temporary
+    Postgres instance creation for the server tests.
+
+    Per pytest documentation, this must live in the top level test directory.
+    """
+    parser.addoption(
+        "--temp-instance",
+        metavar="DIR",
+        help="create a temporary Postgres instance in DIR",
+    )
+
+
[email protected](scope="session", autouse=True)
+def _check_PG_TEST_EXTRA(request):
+    """
+    Automatically skips the whole suite if PG_TEST_EXTRA doesn't contain
+    'python'. pytestmark doesn't seem to work in a top-level conftest.py, so
+    I've made this an autoused fixture instead.
+    """
+    extra_tests = os.getenv("PG_TEST_EXTRA", "").split()
+    if "python" not in extra_tests:
+        pytest.skip("Potentially unsafe test 'python' not enabled in PG_TEST_EXTRA")
diff --git a/src/test/python/meson.build b/src/test/python/meson.build
new file mode 100644
index 0000000000..e137df852e
--- /dev/null
+++ b/src/test/python/meson.build
@@ -0,0 +1,47 @@
+# Copyright (c) 2023, PostgreSQL Global Development Group
+
+subdir('server')
+
+pytest_env = {
+  'with_libcurl': libcurl.found() ? 'yes' : 'no',
+
+  # Point to the default database; the tests will create their own databases as
+  # needed.
+  'PGDATABASE': 'postgres',
+
+  # Avoid the need for a Rust compiler on platforms without prebuilt wheels for
+  # pyca/cryptography.
+  'CRYPTOGRAPHY_DONT_BUILD_RUST': '1',
+}
+
+# Some modules (psycopg2) need OpenSSL at compile time; for platforms where we
+# might have multiple implementations installed (macOS+brew), try to use the
+# same one that libpq is using.
+if ssl.found()
+  pytest_incdir = ssl.get_variable(pkgconfig: 'includedir', default_value: '')
+  if pytest_incdir != ''
+    pytest_env += { 'CPPFLAGS': '-I@0@'.format(pytest_incdir) }
+  endif
+
+  pytest_libdir = ssl.get_variable(pkgconfig: 'libdir', default_value: '')
+  if pytest_libdir != ''
+    pytest_env += { 'LDFLAGS': '-L@0@'.format(pytest_libdir) }
+  endif
+endif
+
+tests += {
+  'name': 'python',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'pytest': {
+	'requirements': meson.current_source_dir() / 'requirements.txt',
+    'tests': [
+      './client',
+      './server',
+      './test_internals.py',
+      './test_pq3.py',
+    ],
+    'env': pytest_env,
+    'test_kwargs': {'priority': 50}, # python tests are slow, start early
+  },
+}
diff --git a/src/test/python/pq3.py b/src/test/python/pq3.py
new file mode 100644
index 0000000000..ef809e288a
--- /dev/null
+++ b/src/test/python/pq3.py
@@ -0,0 +1,740 @@
+#
+# Copyright 2021 VMware, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import contextlib
+import getpass
+import io
+import os
+import platform
+import ssl
+import sys
+import textwrap
+
+from construct import *
+
+import tls
+
+
+def protocol(major, minor):
+    """
+    Returns the protocol version, in integer format, corresponding to the given
+    major and minor version numbers.
+    """
+    return (major << 16) | minor
+
+
+# Startup
+
+StringList = GreedyRange(NullTerminated(GreedyBytes))
+
+
+class KeyValueAdapter(Adapter):
+    """
+    Turns a key-value store into a null-terminated list of null-terminated
+    strings, as presented on the wire in the startup packet.
+    """
+
+    def _encode(self, obj, context, path):
+        if isinstance(obj, list):
+            return obj
+
+        l = []
+
+        for k, v in obj.items():
+            if isinstance(k, str):
+                k = k.encode("utf-8")
+            l.append(k)
+
+            if isinstance(v, str):
+                v = v.encode("utf-8")
+            l.append(v)
+
+        l.append(b"")
+        return l
+
+    def _decode(self, obj, context, path):
+        # TODO: turn a list back into a dict
+        return obj
+
+
+KeyValues = KeyValueAdapter(StringList)
+
+_startup_payload = Switch(
+    this.proto,
+    {
+        protocol(3, 0): KeyValues,
+    },
+    default=GreedyBytes,
+)
+
+
+def _default_protocol(this):
+    try:
+        if isinstance(this.payload, (list, dict)):
+            return protocol(3, 0)
+    except AttributeError:
+        pass  # no payload passed during build
+
+    return 0
+
+
+def _startup_payload_len(this):
+    """
+    The payload field has a fixed size based on the length of the packet. But
+    if the caller hasn't supplied an explicit length at build time, we have to
+    build the payload to figure out how long it is, which requires us to know
+    the length first... This function exists solely to break the cycle.
+    """
+    assert this._building, "_startup_payload_len() cannot be called during parsing"
+
+    try:
+        payload = this.payload
+    except AttributeError:
+        return 0  # no payload
+
+    if isinstance(payload, bytes):
+        # already serialized; just use the given length
+        return len(payload)
+
+    try:
+        proto = this.proto
+    except AttributeError:
+        proto = _default_protocol(this)
+
+    data = _startup_payload.build(payload, proto=proto)
+    return len(data)
+
+
+Startup = Struct(
+    "len" / Default(Int32sb, lambda this: _startup_payload_len(this) + 8),
+    "proto" / Default(Hex(Int32sb), _default_protocol),
+    "payload" / FixedSized(this.len - 8, Default(_startup_payload, b"")),
+)
+
+# Pq3
+
+
+# Adapted from construct.core.EnumIntegerString
+class EnumNamedByte:
+    def __init__(self, val, name):
+        self._val = val
+        self._name = name
+
+    def __int__(self):
+        return ord(self._val)
+
+    def __str__(self):
+        return "(enum) %s %r" % (self._name, self._val)
+
+    def __repr__(self):
+        return "EnumNamedByte(%r)" % self._val
+
+    def __eq__(self, other):
+        if isinstance(other, EnumNamedByte):
+            other = other._val
+        if not isinstance(other, bytes):
+            return NotImplemented
+
+        return self._val == other
+
+    def __hash__(self):
+        return hash(self._val)
+
+
+# Adapted from construct.core.Enum
+class ByteEnum(Adapter):
+    def __init__(self, **mapping):
+        super(ByteEnum, self).__init__(Byte)
+        self.namemapping = {k: EnumNamedByte(v, k) for k, v in mapping.items()}
+        self.decmapping = {v: EnumNamedByte(v, k) for k, v in mapping.items()}
+
+    def __getattr__(self, name):
+        if name in self.namemapping:
+            return self.decmapping[self.namemapping[name]]
+        raise AttributeError
+
+    def _decode(self, obj, context, path):
+        b = bytes([obj])
+        try:
+            return self.decmapping[b]
+        except KeyError:
+            return EnumNamedByte(b, "(unknown)")
+
+    def _encode(self, obj, context, path):
+        if isinstance(obj, int):
+            return obj
+        elif isinstance(obj, bytes):
+            return ord(obj)
+        return int(obj)
+
+
+types = ByteEnum(
+    ErrorResponse=b"E",
+    ReadyForQuery=b"Z",
+    Query=b"Q",
+    EmptyQueryResponse=b"I",
+    AuthnRequest=b"R",
+    PasswordMessage=b"p",
+    BackendKeyData=b"K",
+    CommandComplete=b"C",
+    ParameterStatus=b"S",
+    DataRow=b"D",
+    Terminate=b"X",
+)
+
+
+authn = Enum(
+    Int32ub,
+    OK=0,
+    SASL=10,
+    SASLContinue=11,
+    SASLFinal=12,
+)
+
+
+_authn_body = Switch(
+    this.type,
+    {
+        authn.OK: Terminated,
+        authn.SASL: StringList,
+    },
+    default=GreedyBytes,
+)
+
+
+def _data_len(this):
+    assert this._building, "_data_len() cannot be called during parsing"
+
+    if not hasattr(this, "data") or this.data is None:
+        return -1
+
+    return len(this.data)
+
+
+# The protocol reuses the PasswordMessage for several authentication response
+# types, and there's no good way to figure out which is which without keeping
+# state for the entire stream. So this is a separate Construct that can be
+# explicitly parsed/built by code that knows it's needed.
+SASLInitialResponse = Struct(
+    "name" / NullTerminated(GreedyBytes),
+    "len" / Default(Int32sb, lambda this: _data_len(this)),
+    "data"
+    / IfThenElse(
+        # Allow tests to explicitly pass an incorrect length during testing, by
+        # not enforcing a FixedSized during build. (The len calculation above
+        # defaults to the correct size.)
+        this._building,
+        Optional(GreedyBytes),
+        If(this.len != -1, Default(FixedSized(this.len, GreedyBytes), b"")),
+    ),
+    Terminated,  # make sure the entire response is consumed
+)
+
+
+_column = FocusedSeq(
+    "data",
+    "len" / Default(Int32sb, lambda this: _data_len(this)),
+    "data" / If(this.len != -1, FixedSized(this.len, GreedyBytes)),
+)
+
+
+_payload_map = {
+    types.ErrorResponse: Struct("fields" / StringList),
+    types.ReadyForQuery: Struct("status" / Bytes(1)),
+    types.Query: Struct("query" / NullTerminated(GreedyBytes)),
+    types.EmptyQueryResponse: Terminated,
+    types.AuthnRequest: Struct("type" / authn, "body" / Default(_authn_body, b"")),
+    types.BackendKeyData: Struct("pid" / Int32ub, "key" / Hex(Int32ub)),
+    types.CommandComplete: Struct("tag" / NullTerminated(GreedyBytes)),
+    types.ParameterStatus: Struct(
+        "name" / NullTerminated(GreedyBytes), "value" / NullTerminated(GreedyBytes)
+    ),
+    types.DataRow: Struct("columns" / Default(PrefixedArray(Int16sb, _column), b"")),
+    types.Terminate: Terminated,
+}
+
+
+_payload = FocusedSeq(
+    "_payload",
+    "_payload"
+    / Switch(
+        this._.type,
+        _payload_map,
+        default=GreedyBytes,
+    ),
+    Terminated,  # make sure every payload consumes the entire packet
+)
+
+
+def _payload_len(this):
+    """
+    See _startup_payload_len() for an explanation.
+    """
+    assert this._building, "_payload_len() cannot be called during parsing"
+
+    try:
+        payload = this.payload
+    except AttributeError:
+        return 0  # no payload
+
+    if isinstance(payload, bytes):
+        # already serialized; just use the given length
+        return len(payload)
+
+    data = _payload.build(payload, type=this.type)
+    return len(data)
+
+
+Pq3 = Struct(
+    "type" / types,
+    "len" / Default(Int32ub, lambda this: _payload_len(this) + 4),
+    "payload"
+    / IfThenElse(
+        # Allow tests to explicitly pass an incorrect length during testing, by
+        # not enforcing a FixedSized during build. (The len calculation above
+        # defaults to the correct size.)
+        this._building,
+        Optional(_payload),
+        FixedSized(this.len - 4, Default(_payload, b"")),
+    ),
+)
+
+
+# Environment
+
+
+def pghost():
+    return os.environ.get("PGHOST", default="localhost")
+
+
+def pgport():
+    return int(os.environ.get("PGPORT", default=5432))
+
+
+def pguser():
+    try:
+        return os.environ["PGUSER"]
+    except KeyError:
+        if platform.system() == "Windows":
+            # libpq defaults to GetUserName() on Windows.
+            return os.getlogin()
+        return getpass.getuser()
+
+
+def pgdatabase():
+    return os.environ.get("PGDATABASE", default="postgres")
+
+
+# Connections
+
+
+def _hexdump_translation_map():
+    """
+    For hexdumps. Translates any unprintable or non-ASCII bytes into '.'.
+    """
+    input = bytearray()
+
+    for i in range(128):
+        c = chr(i)
+
+        if not c.isprintable():
+            input += bytes([i])
+
+    input += bytes(range(128, 256))
+
+    return bytes.maketrans(input, b"." * len(input))
+
+
+class _DebugStream(object):
+    """
+    Wraps a file-like object and adds hexdumps of the read and write data. Call
+    end_packet() on a _DebugStream to write the accumulated hexdumps to the
+    output stream, along with the packet that was sent.
+    """
+
+    _translation_map = _hexdump_translation_map()
+
+    def __init__(self, stream, out=sys.stdout):
+        """
+        Creates a new _DebugStream wrapping the given stream (which must have
+        been created by wrap()). All attributes not provided by the _DebugStream
+        are delegated to the wrapped stream. out is the text stream to which
+        hexdumps are written.
+        """
+        self.raw = stream
+        self._out = out
+        self._rbuf = io.BytesIO()
+        self._wbuf = io.BytesIO()
+
+    def __getattr__(self, name):
+        return getattr(self.raw, name)
+
+    def __setattr__(self, name, value):
+        if name in ("raw", "_out", "_rbuf", "_wbuf"):
+            return object.__setattr__(self, name, value)
+
+        setattr(self.raw, name, value)
+
+    def read(self, *args, **kwargs):
+        buf = self.raw.read(*args, **kwargs)
+
+        self._rbuf.write(buf)
+        return buf
+
+    def write(self, b):
+        self._wbuf.write(b)
+        return self.raw.write(b)
+
+    def recv(self, *args):
+        buf = self.raw.recv(*args)
+
+        self._rbuf.write(buf)
+        return buf
+
+    def _flush(self, buf, prefix):
+        width = 16
+        hexwidth = width * 3 - 1
+
+        count = 0
+        buf.seek(0)
+
+        while True:
+            line = buf.read(16)
+
+            if not line:
+                if count:
+                    self._out.write("\n")  # separate the output block with a newline
+                return
+
+            self._out.write("%s %04X:\t" % (prefix, count))
+            self._out.write("%*s\t" % (-hexwidth, line.hex(" ")))
+            self._out.write(line.translate(self._translation_map).decode("ascii"))
+            self._out.write("\n")
+
+            count += 16
+
+    def print_debug(self, obj, *, prefix=""):
+        contents = ""
+        if obj is not None:
+            contents = str(obj)
+
+        for line in contents.splitlines():
+            self._out.write("%s%s\n" % (prefix, line))
+
+        self._out.write("\n")
+
+    def flush_debug(self, *, prefix=""):
+        self._flush(self._rbuf, prefix + "<")
+        self._rbuf = io.BytesIO()
+
+        self._flush(self._wbuf, prefix + ">")
+        self._wbuf = io.BytesIO()
+
+    def end_packet(self, pkt, *, read=False, prefix="", indent="  "):
+        """
+        Marks the end of a logical "packet" of data. A string representation of
+        pkt will be printed, and the debug buffers will be flushed with an
+        indent. All lines can be optionally prefixed.
+
+        If read is True, the packet representation is written after the debug
+        buffers; otherwise the default of False (meaning write) causes the
+        packet representation to be dumped first. This is meant to capture the
+        logical flow of layer translation.
+        """
+        write = not read
+
+        if write:
+            self.print_debug(pkt, prefix=prefix + "> ")
+
+        self.flush_debug(prefix=prefix + indent)
+
+        if read:
+            self.print_debug(pkt, prefix=prefix + "< ")
+
+
[email protected]
+def wrap(socket, *, debug_stream=None):
+    """
+    Transforms a raw socket into a connection that can be used for Construct
+    building and parsing. The return value is a context manager and can be used
+    in a with statement.
+    """
+    # It is critical that buffering be disabled here, so that we can still
+    # manipulate the raw socket without desyncing the stream.
+    with socket.makefile("rwb", buffering=0) as sfile:
+        # Expose the original socket's recv() on the SocketIO object we return.
+        def recv(self, *args):
+            return socket.recv(*args)
+
+        sfile.recv = recv.__get__(sfile)
+
+        conn = sfile
+        if debug_stream:
+            conn = _DebugStream(conn, debug_stream)
+
+        try:
+            yield conn
+        finally:
+            if debug_stream:
+                conn.flush_debug(prefix="? ")
+
+
+def _send(stream, cls, obj):
+    debugging = hasattr(stream, "flush_debug")
+    out = io.BytesIO()
+
+    # Ideally we would build directly to the passed stream, but because we need
+    # to reparse the generated output for the debugging case, build to an
+    # intermediate BytesIO and send it instead.
+    cls.build_stream(obj, out)
+    buf = out.getvalue()
+
+    stream.write(buf)
+    if debugging:
+        pkt = cls.parse(buf)
+        stream.end_packet(pkt)
+
+    stream.flush()
+
+
+def send(stream, packet_type, payload_data=None, **payloadkw):
+    """
+    Sends a packet on the given pq3 connection. type is the pq3.types member
+    that should be assigned to the packet. If payload_data is given, it will be
+    used as the packet payload; otherwise the key/value pairs in payloadkw will
+    be the payload contents.
+    """
+    data = payloadkw
+
+    if payload_data is not None:
+        if payloadkw:
+            raise ValueError(
+                "payload_data and payload keywords may not be used simultaneously"
+            )
+
+        data = payload_data
+
+    _send(stream, Pq3, dict(type=packet_type, payload=data))
+
+
+def send_startup(stream, proto=None, **kwargs):
+    """
+    Sends a startup packet on the given pq3 connection. In most cases you should
+    use the handshake functions instead, which will do this for you.
+
+    By default, a protocol version 3 packet will be sent. This can be overridden
+    with the proto parameter.
+    """
+    pkt = {}
+
+    if proto is not None:
+        pkt["proto"] = proto
+    if kwargs:
+        pkt["payload"] = kwargs
+
+    _send(stream, Startup, pkt)
+
+
+def recv1(stream, *, cls=Pq3):
+    """
+    Receives a single pq3 packet from the given stream and returns it.
+    """
+    resp = cls.parse_stream(stream)
+
+    debugging = hasattr(stream, "flush_debug")
+    if debugging:
+        stream.end_packet(resp, read=True)
+
+    return resp
+
+
+def handshake(stream, **kwargs):
+    """
+    Performs a libpq v3 startup handshake. kwargs should contain the key/value
+    parameters to send to the server in the startup packet.
+    """
+    # Send our startup parameters.
+    send_startup(stream, **kwargs)
+
+    # Receive and dump packets until the server indicates it's ready for our
+    # first query.
+    while True:
+        resp = recv1(stream)
+        if resp is None:
+            raise RuntimeError("server closed connection during handshake")
+
+        if resp.type == types.ReadyForQuery:
+            return
+        elif resp.type == types.ErrorResponse:
+            raise RuntimeError(
+                f"received error response from peer: {resp.payload.fields!r}"
+            )
+
+
+# TLS
+
+
+class _TLSStream(object):
+    """
+    A file-like object that performs TLS encryption/decryption on a wrapped
+    stream. Differs from ssl.SSLSocket in that we have full visibility and
+    control over the TLS layer.
+    """
+
+    def __init__(self, stream, context):
+        self._stream = stream
+        self._debugging = hasattr(stream, "flush_debug")
+
+        self._in = ssl.MemoryBIO()
+        self._out = ssl.MemoryBIO()
+        self._ssl = context.wrap_bio(self._in, self._out)
+
+    def handshake(self):
+        try:
+            self._pump(lambda: self._ssl.do_handshake())
+        finally:
+            self._flush_debug(prefix="? ")
+
+    def read(self, *args):
+        return self._pump(lambda: self._ssl.read(*args))
+
+    def write(self, *args):
+        return self._pump(lambda: self._ssl.write(*args))
+
+    def _decode(self, buf):
+        """
+        Attempts to decode a buffer of TLS data into a packet representation
+        that can be printed.
+
+        TODO: handle buffers (and record fragments) that don't align with packet
+        boundaries.
+        """
+        end = len(buf)
+        bio = io.BytesIO(buf)
+
+        ret = io.StringIO()
+
+        while bio.tell() < end:
+            record = tls.Plaintext.parse_stream(bio)
+
+            if ret.tell() > 0:
+                ret.write("\n")
+            ret.write("[Record] ")
+            ret.write(str(record))
+            ret.write("\n")
+
+            if record.type == tls.ContentType.handshake:
+                record_cls = tls.Handshake
+            else:
+                continue
+
+            innerlen = len(record.fragment)
+            inner = io.BytesIO(record.fragment)
+
+            while inner.tell() < innerlen:
+                msg = record_cls.parse_stream(inner)
+
+                indented = "[Message] " + str(msg)
+                indented = textwrap.indent(indented, "    ")
+
+                ret.write("\n")
+                ret.write(indented)
+                ret.write("\n")
+
+        return ret.getvalue()
+
+    def flush(self):
+        if not self._out.pending:
+            self._stream.flush()
+            return
+
+        buf = self._out.read()
+        self._stream.write(buf)
+
+        if self._debugging:
+            pkt = self._decode(buf)
+            self._stream.end_packet(pkt, prefix="  ")
+
+        self._stream.flush()
+
+    def _pump(self, operation):
+        while True:
+            try:
+                return operation()
+            except (ssl.SSLWantReadError, ssl.SSLWantWriteError) as e:
+                want = e
+            self._read_write(want)
+
+    def _recv(self, maxsize):
+        buf = self._stream.recv(4096)
+        if not buf:
+            self._in.write_eof()
+            return
+
+        self._in.write(buf)
+
+        if not self._debugging:
+            return
+
+        pkt = self._decode(buf)
+        self._stream.end_packet(pkt, read=True, prefix="  ")
+
+    def _read_write(self, want):
+        # XXX This needs work. So many corner cases yet to handle. For one,
+        # doing blocking writes in flush may lead to distributed deadlock if the
+        # peer is already blocking on its writes.
+
+        if isinstance(want, ssl.SSLWantWriteError):
+            assert self._out.pending, "SSL backend wants write without data"
+
+        self.flush()
+
+        if isinstance(want, ssl.SSLWantReadError):
+            self._recv(4096)
+
+    def _flush_debug(self, prefix):
+        if not self._debugging:
+            return
+
+        self._stream.flush_debug(prefix=prefix)
+
+
[email protected]
+def tls_handshake(stream, context):
+    """
+    Performs a TLS handshake over the given stream (which must have been created
+    via a call to wrap()), and returns a new stream which transparently tunnels
+    data over the TLS connection.
+
+    If the passed stream has debugging enabled, the returned stream will also
+    have debugging, using the same output IO.
+    """
+    debugging = hasattr(stream, "flush_debug")
+
+    # Send our startup parameters.
+    send_startup(stream, proto=protocol(1234, 5679))
+
+    # Look at the SSL response.
+    resp = stream.read(1)
+    if debugging:
+        stream.flush_debug(prefix="  ")
+
+    if resp == b"N":
+        raise RuntimeError("server does not support SSLRequest")
+    if resp != b"S":
+        raise RuntimeError(f"unexpected response of type {resp!r} during TLS startup")
+
+    tls = _TLSStream(stream, context)
+    tls.handshake()
+
+    if debugging:
+        tls = _DebugStream(tls, stream._out)
+
+    try:
+        yield tls
+        # TODO: teardown/unwrap the connection?
+    finally:
+        if debugging:
+            tls.flush_debug(prefix="? ")
diff --git a/src/test/python/pytest.ini b/src/test/python/pytest.ini
new file mode 100644
index 0000000000..ab7a6e7fb9
--- /dev/null
+++ b/src/test/python/pytest.ini
@@ -0,0 +1,4 @@
+[pytest]
+
+markers =
+    slow: mark test as slow
diff --git a/src/test/python/requirements.txt b/src/test/python/requirements.txt
new file mode 100644
index 0000000000..0dfcffb83e
--- /dev/null
+++ b/src/test/python/requirements.txt
@@ -0,0 +1,11 @@
+black
+# cryptography 35.x and later add many platform/toolchain restrictions, beware
+cryptography~=3.4.8
+# TODO: figure out why 2.10.70 broke things
+# (probably https://github.com/construct/construct/pull/1015)
+construct==2.10.69
+isort~=5.6
+# TODO: update to psycopg[c] 3.1
+psycopg2~=2.9.7
+pytest~=7.3
+pytest-asyncio~=0.21.0
diff --git a/src/test/python/server/__init__.py b/src/test/python/server/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/test/python/server/conftest.py b/src/test/python/server/conftest.py
new file mode 100644
index 0000000000..42af80c73e
--- /dev/null
+++ b/src/test/python/server/conftest.py
@@ -0,0 +1,141 @@
+#
+# Portions Copyright 2021 VMware, Inc.
+# Portions Copyright 2023 Timescale, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import collections
+import contextlib
+import os
+import shutil
+import socket
+import subprocess
+import sys
+
+import pytest
+
+import pq3
+
+BLOCKING_TIMEOUT = 2  # the number of seconds to wait for blocking calls
+
+
+def cleanup_prior_instance(datadir):
+    """
+    Clean up an existing data directory, but make sure it actually looks like a
+    data directory first. (Empty folders will remain untouched, since initdb can
+    populate them.)
+    """
+    required_entries = set(["base", "PG_VERSION", "postgresql.conf"])
+    empty = True
+
+    try:
+        with os.scandir(datadir) as entries:
+            for e in entries:
+                empty = False
+                required_entries.discard(e.name)
+
+    except FileNotFoundError:
+        return  # nothing to clean up
+
+    if empty:
+        return  # initdb can handle an empty datadir
+
+    if required_entries:
+        pytest.fail(
+            f"--temp-instance directory \"{datadir}\" is not empty and doesn't look like a data directory (missing {', '.join(required_entries)})"
+        )
+
+    # Okay, seems safe enough now.
+    shutil.rmtree(datadir)
+
+
[email protected](scope="session")
+def postgres_instance(pytestconfig, unused_tcp_port_factory):
+    """
+    If --temp-instance has been passed to pytest, this fixture runs a temporary
+    Postgres instance on an available port. Otherwise, the fixture will attempt
+    to contact a running Postgres server on (PGHOST, PGPORT); dependent tests
+    will be skipped if the connection fails.
+
+    Yields a (host, port) tuple for connecting to the server.
+    """
+    PGInstance = collections.namedtuple("PGInstance", ["addr", "temporary"])
+
+    datadir = pytestconfig.getoption("temp_instance")
+    if datadir:
+        # We were told to create a temporary instance. Use pg_ctl to set it up
+        # on an unused port.
+        cleanup_prior_instance(datadir)
+        subprocess.run(["pg_ctl", "-D", datadir, "init"], check=True)
+
+        # The CI looks for *.log files to upload, so the file name here isn't
+        # completely arbitrary.
+        log = os.path.join(datadir, "postmaster.log")
+        port = unused_tcp_port_factory()
+
+        subprocess.run(
+            [
+                "pg_ctl",
+                "-D",
+                datadir,
+                "-l",
+                log,
+                "-o",
+                " ".join(
+                    [
+                        f"-c port={port}",
+                        "-c listen_addresses=localhost",
+                        "-c log_connections=on",
+                        "-c session_preload_libraries=oauthtest",
+                        "-c oauth_validator_libraries=oauthtest",
+                    ]
+                ),
+                "start",
+            ],
+            check=True,
+        )
+
+        yield ("localhost", port)
+
+        subprocess.run(["pg_ctl", "-D", datadir, "stop"], check=True)
+
+    else:
+        # Try to contact an already running server; skip the suite if we can't
+        # find one.
+        addr = (pq3.pghost(), pq3.pgport())
+
+        try:
+            with socket.create_connection(addr, timeout=BLOCKING_TIMEOUT):
+                pass
+        except ConnectionError as e:
+            pytest.skip(f"unable to connect to Postgres server at {addr}: {e}")
+
+        yield addr
+
+
[email protected]
+def connect(postgres_instance):
+    """
+    A factory fixture that, when called, returns a socket connected to a
+    Postgres server, wrapped in a pq3 connection. Dependent tests will be
+    skipped if no server is available.
+    """
+    addr = postgres_instance
+
+    # Set up an ExitStack to handle safe cleanup of all of the moving pieces.
+    with contextlib.ExitStack() as stack:
+
+        def conn_factory():
+            sock = socket.create_connection(addr, timeout=BLOCKING_TIMEOUT)
+
+            # Have ExitStack close our socket.
+            stack.enter_context(sock)
+
+            # Wrap the connection in a pq3 layer and have ExitStack clean it up
+            # too.
+            wrap_ctx = pq3.wrap(sock, debug_stream=sys.stdout)
+            conn = stack.enter_context(wrap_ctx)
+
+            return conn
+
+        yield conn_factory
diff --git a/src/test/python/server/meson.build b/src/test/python/server/meson.build
new file mode 100644
index 0000000000..85534b9cc9
--- /dev/null
+++ b/src/test/python/server/meson.build
@@ -0,0 +1,18 @@
+# Copyright (c) 2024, PostgreSQL Global Development Group
+
+oauthtest_sources = files(
+  'oauthtest.c',
+)
+
+if host_system == 'windows'
+  oauthtest_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'oauthtest',
+    '--FILEDESC', 'passthrough module to validate OAuth tests',
+  ])
+endif
+
+oauthtest = shared_module('oauthtest',
+  oauthtest_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += oauthtest
diff --git a/src/test/python/server/oauthtest.c b/src/test/python/server/oauthtest.c
new file mode 100644
index 0000000000..415748b9a6
--- /dev/null
+++ b/src/test/python/server/oauthtest.c
@@ -0,0 +1,118 @@
+/*-------------------------------------------------------------------------
+ *
+ * oauthtest.c
+ *	  Test module for serverside OAuth token validation callbacks
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/test/python/server/oauthtest.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "fmgr.h"
+#include "libpq/oauth.h"
+#include "utils/guc.h"
+#include "utils/memutils.h"
+
+PG_MODULE_MAGIC;
+
+static void test_startup(ValidatorModuleState *state);
+static void test_shutdown(ValidatorModuleState *state);
+static ValidatorModuleResult *test_validate(ValidatorModuleState *state,
+											const char *token,
+											const char *role);
+
+static const OAuthValidatorCallbacks callbacks = {
+	.startup_cb = test_startup,
+	.shutdown_cb = test_shutdown,
+	.validate_cb = test_validate,
+};
+
+static char *expected_bearer = "";
+static bool set_authn_id = false;
+static char *authn_id = "";
+static bool reflect_role = false;
+
+void
+_PG_init(void)
+{
+	DefineCustomStringVariable("oauthtest.expected_bearer",
+							   "Expected Bearer token for future connections",
+							   NULL,
+							   &expected_bearer,
+							   "",
+							   PGC_SIGHUP,
+							   0,
+							   NULL, NULL, NULL);
+
+	DefineCustomBoolVariable("oauthtest.set_authn_id",
+							 "Whether to set an authenticated identity",
+							 NULL,
+							 &set_authn_id,
+							 false,
+							 PGC_SIGHUP,
+							 0,
+							 NULL, NULL, NULL);
+	DefineCustomStringVariable("oauthtest.authn_id",
+							   "Authenticated identity to use for future connections",
+							   NULL,
+							   &authn_id,
+							   "",
+							   PGC_SIGHUP,
+							   0,
+							   NULL, NULL, NULL);
+
+	DefineCustomBoolVariable("oauthtest.reflect_role",
+							 "Ignore the bearer token; use the requested role as the authn_id",
+							 NULL,
+							 &reflect_role,
+							 false,
+							 PGC_SIGHUP,
+							 0,
+							 NULL, NULL, NULL);
+
+	MarkGUCPrefixReserved("oauthtest");
+}
+
+const OAuthValidatorCallbacks *
+_PG_oauth_validator_module_init(void)
+{
+	return &callbacks;
+}
+
+static void
+test_startup(ValidatorModuleState *state)
+{
+}
+
+static void
+test_shutdown(ValidatorModuleState *state)
+{
+}
+
+static ValidatorModuleResult *
+test_validate(ValidatorModuleState *state, const char *token, const char *role)
+{
+	ValidatorModuleResult *res;
+
+	res = palloc0(sizeof(ValidatorModuleResult));	/* TODO: palloc context? */
+
+	if (reflect_role)
+	{
+		res->authorized = true;
+		res->authn_id = pstrdup(role);	/* TODO: constify? */
+	}
+	else
+	{
+		if (*expected_bearer && strcmp(token, expected_bearer) == 0)
+			res->authorized = true;
+		if (set_authn_id)
+			res->authn_id = authn_id;
+	}
+
+	return res;
+}
diff --git a/src/test/python/server/test_oauth.py b/src/test/python/server/test_oauth.py
new file mode 100644
index 0000000000..2839343ffa
--- /dev/null
+++ b/src/test/python/server/test_oauth.py
@@ -0,0 +1,1080 @@
+#
+# Copyright 2021 VMware, Inc.
+# Portions Copyright 2023 Timescale, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import base64
+import contextlib
+import json
+import os
+import pathlib
+import platform
+import secrets
+import shlex
+import shutil
+import socket
+import struct
+from multiprocessing import shared_memory
+
+import psycopg2
+import pytest
+from construct import Container
+from psycopg2 import sql
+
+import pq3
+
+from .conftest import BLOCKING_TIMEOUT
+
+MAX_SASL_MESSAGE_LENGTH = 65535
+
+INVALID_AUTHORIZATION_ERRCODE = b"28000"
+PROTOCOL_VIOLATION_ERRCODE = b"08P01"
+FEATURE_NOT_SUPPORTED_ERRCODE = b"0A000"
+
+SHARED_MEM_NAME = "oauth-pytest"
+MAX_UINT16 = 2**16 - 1
+
+
[email protected]
+def prepend_file(path, lines, *, suffix=".bak"):
+    """
+    A context manager that prepends a file on disk with the desired lines of
+    text. When the context manager is exited, the file will be restored to its
+    original contents.
+    """
+    # First make a backup of the original file.
+    bak = path + suffix
+    shutil.copy2(path, bak)
+
+    try:
+        # Write the new lines, followed by the original file content.
+        with open(path, "w") as new, open(bak, "r") as orig:
+            new.writelines(lines)
+            shutil.copyfileobj(orig, new)
+
+        # Return control to the calling code.
+        yield
+
+    finally:
+        # Put the backup back into place.
+        os.replace(bak, path)
+
+
[email protected](scope="module")
+def oauth_ctx(postgres_instance):
+    """
+    Creates a database and user that use the oauth auth method. The context
+    object contains the dbname and user attributes as strings to be used during
+    connection, as well as the issuer and scope that have been set in the HBA
+    configuration.
+
+    This fixture assumes that the standard PG* environment variables point to a
+    server running on a local machine, and that the PGUSER has rights to create
+    databases and roles.
+    """
+    id = secrets.token_hex(4)
+
+    class Context:
+        dbname = "oauth_test_" + id
+
+        user = "oauth_user_" + id
+        punct_user = "oauth_\"'? ;&!_user_" + id  # username w/ punctuation
+        map_user = "oauth_map_user_" + id
+        authz_user = "oauth_authz_user_" + id
+
+        issuer = "https://example.com/" + id
+        scope = "openid " + id
+
+    ctx = Context()
+    hba_lines = [
+        f'host {ctx.dbname} {ctx.map_user}   samehost oauth issuer="{ctx.issuer}" scope="{ctx.scope}" map=oauth\n',
+        f'host {ctx.dbname} {ctx.authz_user} samehost oauth issuer="{ctx.issuer}" scope="{ctx.scope}" delegate_ident_mapping=1\n',
+        f'host {ctx.dbname} all              samehost oauth issuer="{ctx.issuer}" scope="{ctx.scope}"\n',
+    ]
+    ident_lines = [r"oauth /^(.*)@example\.com$ \1"]
+
+    if platform.system() == "Windows":
+        # XXX why is 'samehost' not behaving as expected on Windows?
+        for l in list(hba_lines):
+            hba_lines.append(l.replace("samehost", "::1/128"))
+
+    host, port = postgres_instance
+    conn = psycopg2.connect(host=host, port=port)
+    conn.autocommit = True
+
+    with contextlib.closing(conn):
+        c = conn.cursor()
+
+        # Create our roles and database.
+        user = sql.Identifier(ctx.user)
+        punct_user = sql.Identifier(ctx.punct_user)
+        map_user = sql.Identifier(ctx.map_user)
+        authz_user = sql.Identifier(ctx.authz_user)
+        dbname = sql.Identifier(ctx.dbname)
+
+        c.execute(sql.SQL("CREATE ROLE {} LOGIN;").format(user))
+        c.execute(sql.SQL("CREATE ROLE {} LOGIN;").format(punct_user))
+        c.execute(sql.SQL("CREATE ROLE {} LOGIN;").format(map_user))
+        c.execute(sql.SQL("CREATE ROLE {} LOGIN;").format(authz_user))
+        c.execute(sql.SQL("CREATE DATABASE {};").format(dbname))
+
+        # Replace pg_hba and pg_ident.
+        c.execute("SHOW hba_file;")
+        hba = c.fetchone()[0]
+
+        c.execute("SHOW ident_file;")
+        ident = c.fetchone()[0]
+
+        with prepend_file(hba, hba_lines), prepend_file(ident, ident_lines):
+            c.execute("SELECT pg_reload_conf();")
+
+            # Use the new database and user.
+            yield ctx
+
+        # Put things back the way they were.
+        c.execute("SELECT pg_reload_conf();")
+
+        c.execute(sql.SQL("DROP DATABASE {};").format(dbname))
+        c.execute(sql.SQL("DROP ROLE {};").format(authz_user))
+        c.execute(sql.SQL("DROP ROLE {};").format(map_user))
+        c.execute(sql.SQL("DROP ROLE {};").format(punct_user))
+        c.execute(sql.SQL("DROP ROLE {};").format(user))
+
+
[email protected]()
+def conn(oauth_ctx, connect):
+    """
+    A convenience wrapper for connect(). The main purpose of this fixture is to
+    make sure oauth_ctx runs its setup code before the connection is made.
+    """
+    return connect()
+
+
+def bearer_token(*, size=16):
+    """
+    Generates a Bearer token using secrets.token_urlsafe(). The generated token
+    size in bytes may be specified; if unset, a small 16-byte token will be
+    generated.
+    """
+
+    if size % 4:
+        raise ValueError(f"requested token size {size} is not a multiple of 4")
+
+    token = secrets.token_urlsafe(size // 4 * 3)
+    assert len(token) == size
+
+    return token
+
+
+def begin_oauth_handshake(conn, oauth_ctx, *, user=None):
+    if user is None:
+        user = oauth_ctx.authz_user
+
+    pq3.send_startup(conn, user=user, database=oauth_ctx.dbname)
+
+    resp = pq3.recv1(conn)
+    assert resp.type == pq3.types.AuthnRequest
+
+    # The server should advertise exactly one mechanism.
+    assert resp.payload.type == pq3.authn.SASL
+    assert resp.payload.body == [b"OAUTHBEARER", b""]
+
+
+def send_initial_response(conn, *, auth=None, bearer=None):
+    """
+    Sends the OAUTHBEARER initial response on the connection, using the given
+    bearer token. Alternatively to a bearer token, the initial response's auth
+    field may be explicitly specified to test corner cases.
+    """
+    if bearer is not None and auth is not None:
+        raise ValueError("exactly one of the auth and bearer kwargs must be set")
+
+    if bearer is not None:
+        auth = b"Bearer " + bearer
+
+    if auth is None:
+        raise ValueError("exactly one of the auth and bearer kwargs must be set")
+
+    initial = pq3.SASLInitialResponse.build(
+        dict(
+            name=b"OAUTHBEARER",
+            data=b"n,,\x01auth=" + auth + b"\x01\x01",
+        )
+    )
+    pq3.send(conn, pq3.types.PasswordMessage, initial)
+
+
+def expect_handshake_success(conn):
+    """
+    Validates that the server responds with an AuthnOK message, and then drains
+    the connection until a ReadyForQuery message is received.
+    """
+    resp = pq3.recv1(conn)
+
+    assert resp.type == pq3.types.AuthnRequest
+    assert resp.payload.type == pq3.authn.OK
+    assert not resp.payload.body
+
+    receive_until(conn, pq3.types.ReadyForQuery)
+
+
+def expect_handshake_failure(conn, oauth_ctx):
+    """
+    Performs the OAUTHBEARER SASL failure "handshake" and validates the server's
+    side of the conversation, including the final ErrorResponse.
+    """
+
+    # We expect a discovery "challenge" back from the server before the authn
+    # failure message.
+    resp = pq3.recv1(conn)
+    assert resp.type == pq3.types.AuthnRequest
+
+    req = resp.payload
+    assert req.type == pq3.authn.SASLContinue
+
+    body = json.loads(req.body)
+    assert body["status"] == "invalid_token"
+    assert body["scope"] == oauth_ctx.scope
+
+    expected_config = oauth_ctx.issuer + "/.well-known/openid-configuration"
+    assert body["openid-configuration"] == expected_config
+
+    # Send the dummy response to complete the failed handshake.
+    pq3.send(conn, pq3.types.PasswordMessage, b"\x01")
+    resp = pq3.recv1(conn)
+
+    err = ExpectedError(INVALID_AUTHORIZATION_ERRCODE, "bearer authentication failed")
+    err.match(resp)
+
+
+def receive_until(conn, type):
+    """
+    receive_until pulls packets off the pq3 connection until a packet with the
+    desired type is found, or an error response is received.
+    """
+    while True:
+        pkt = pq3.recv1(conn)
+
+        if pkt.type == type:
+            return pkt
+        elif pkt.type == pq3.types.ErrorResponse:
+            raise RuntimeError(
+                f"received error response from peer: {pkt.payload.fields!r}"
+            )
+
+
[email protected]()
+def setup_validator(postgres_instance):
+    """
+    A per-test fixture that sets up the test validator with expected behavior.
+    The setting will be reverted during teardown.
+    """
+    host, port = postgres_instance
+    conn = psycopg2.connect(host=host, port=port)
+    conn.autocommit = True
+
+    with contextlib.closing(conn):
+        c = conn.cursor()
+        prev = dict()
+
+        def setter(**gucs):
+            for guc, val in gucs.items():
+                # Save the previous value.
+                c.execute(sql.SQL("SHOW oauthtest.{};").format(sql.Identifier(guc)))
+                prev[guc] = c.fetchone()[0]
+
+                c.execute(
+                    sql.SQL("ALTER SYSTEM SET oauthtest.{} TO %s;").format(
+                        sql.Identifier(guc)
+                    ),
+                    (val,),
+                )
+                c.execute("SELECT pg_reload_conf();")
+
+        yield setter
+
+        # Restore the previous values.
+        for guc, val in prev.items():
+            c.execute(
+                sql.SQL("ALTER SYSTEM SET oauthtest.{} TO %s;").format(
+                    sql.Identifier(guc)
+                ),
+                (val,),
+            )
+            c.execute("SELECT pg_reload_conf();")
+
+
[email protected]("token_len", [16, 1024, 4096])
[email protected](
+    "auth_prefix",
+    [
+        b"Bearer ",
+        b"bearer ",
+        b"Bearer    ",
+    ],
+)
+def test_oauth(setup_validator, connect, oauth_ctx, auth_prefix, token_len):
+    # Generate our bearer token with the desired length.
+    token = bearer_token(size=token_len)
+    setup_validator(expected_bearer=token)
+
+    conn = connect()
+    begin_oauth_handshake(conn, oauth_ctx)
+
+    auth = auth_prefix + token.encode("ascii")
+    send_initial_response(conn, auth=auth)
+    expect_handshake_success(conn)
+
+    # Make sure that the server has not set an authenticated ID.
+    pq3.send(conn, pq3.types.Query, query=b"SELECT system_user;")
+    resp = receive_until(conn, pq3.types.DataRow)
+
+    row = resp.payload
+    assert row.columns == [None]
+
+
[email protected](
+    "token_value",
+    [
+        "abcdzA==",
+        "123456M=",
+        "x-._~+/x",
+    ],
+)
+def test_oauth_bearer_corner_cases(setup_validator, connect, oauth_ctx, token_value):
+    setup_validator(expected_bearer=token_value)
+
+    conn = connect()
+    begin_oauth_handshake(conn, oauth_ctx)
+
+    send_initial_response(conn, bearer=token_value.encode("ascii"))
+
+    expect_handshake_success(conn)
+
+
[email protected](
+    "user,authn_id,should_succeed",
+    [
+        pytest.param(
+            lambda ctx: ctx.user,
+            lambda ctx: ctx.user,
+            True,
+            id="validator authn: succeeds when authn_id == username",
+        ),
+        pytest.param(
+            lambda ctx: ctx.user,
+            lambda ctx: None,
+            False,
+            id="validator authn: fails when authn_id is not set",
+        ),
+        pytest.param(
+            lambda ctx: ctx.user,
+            lambda ctx: "",
+            False,
+            id="validator authn: fails when authn_id is empty",
+        ),
+        pytest.param(
+            lambda ctx: ctx.user,
+            lambda ctx: ctx.authz_user,
+            False,
+            id="validator authn: fails when authn_id != username",
+        ),
+        pytest.param(
+            lambda ctx: ctx.map_user,
+            lambda ctx: ctx.map_user + "@example.com",
+            True,
+            id="validator with map: succeeds when authn_id matches map",
+        ),
+        pytest.param(
+            lambda ctx: ctx.map_user,
+            lambda ctx: None,
+            False,
+            id="validator with map: fails when authn_id is not set",
+        ),
+        pytest.param(
+            lambda ctx: ctx.map_user,
+            lambda ctx: ctx.map_user + "@example.net",
+            False,
+            id="validator with map: fails when authn_id doesn't match map",
+        ),
+        pytest.param(
+            lambda ctx: ctx.authz_user,
+            lambda ctx: None,
+            True,
+            id="validator authz: succeeds with no authn_id",
+        ),
+        pytest.param(
+            lambda ctx: ctx.authz_user,
+            lambda ctx: "",
+            True,
+            id="validator authz: succeeds with empty authn_id",
+        ),
+        pytest.param(
+            lambda ctx: ctx.authz_user,
+            lambda ctx: "postgres",
+            True,
+            id="validator authz: succeeds with basic username",
+        ),
+        pytest.param(
+            lambda ctx: ctx.authz_user,
+            lambda ctx: "[email protected]",
+            True,
+            id="validator authz: succeeds with email address",
+        ),
+    ],
+)
+def test_oauth_authn_id(
+    setup_validator, connect, oauth_ctx, user, authn_id, should_succeed
+):
+    token = bearer_token()
+    authn_id = authn_id(oauth_ctx)
+
+    # Set up the validator appropriately.
+    gucs = dict(expected_bearer=token)
+    if authn_id is not None:
+        gucs["set_authn_id"] = True
+        gucs["authn_id"] = authn_id
+    setup_validator(**gucs)
+
+    conn = connect()
+    username = user(oauth_ctx)
+    begin_oauth_handshake(conn, oauth_ctx, user=username)
+    send_initial_response(conn, bearer=token.encode("ascii"))
+
+    if not should_succeed:
+        expect_handshake_failure(conn, oauth_ctx)
+        return
+
+    expect_handshake_success(conn)
+
+    # Check the reported authn_id.
+    pq3.send(conn, pq3.types.Query, query=b"SELECT system_user;")
+    resp = receive_until(conn, pq3.types.DataRow)
+
+    expected = authn_id
+    if expected is not None:
+        expected = b"oauth:" + expected.encode("ascii")
+
+    row = resp.payload
+    assert row.columns == [expected]
+
+
+class ExpectedError(object):
+    def __init__(self, code, msg=None, detail=None):
+        self.code = code
+        self.msg = msg
+        self.detail = detail
+
+        # Protect against the footgun of an accidental empty string, which will
+        # "match" anything. If you don't want to match message or detail, just
+        # don't pass them.
+        if self.msg == "":
+            raise ValueError("msg must be non-empty or None")
+        if self.detail == "":
+            raise ValueError("detail must be non-empty or None")
+
+    def _getfield(self, resp, type):
+        """
+        Searches an ErrorResponse for a single field of the given type (e.g.
+        "M", "C", "D") and returns its value. Asserts if it doesn't find exactly
+        one field.
+        """
+        prefix = type.encode("ascii")
+        fields = [f for f in resp.payload.fields if f.startswith(prefix)]
+
+        assert len(fields) == 1
+        return fields[0][1:]  # strip off the type byte
+
+    def match(self, resp):
+        """
+        Checks that the given response matches the expected code, message, and
+        detail (if given). The error code must match exactly. The expected
+        message and detail must be contained within the actual strings.
+        """
+        assert resp.type == pq3.types.ErrorResponse
+
+        code = self._getfield(resp, "C")
+        assert code == self.code
+
+        if self.msg:
+            msg = self._getfield(resp, "M")
+            expected = self.msg.encode("utf-8")
+            assert expected in msg
+
+        if self.detail:
+            detail = self._getfield(resp, "D")
+            expected = self.detail.encode("utf-8")
+            assert expected in detail
+
+
+def test_oauth_rejected_bearer(conn, oauth_ctx):
+    begin_oauth_handshake(conn, oauth_ctx)
+
+    # Send a bearer token that doesn't match what the validator expects. It
+    # should fail the connection.
+    send_initial_response(conn, bearer=b"xxxxxx")
+
+    expect_handshake_failure(conn, oauth_ctx)
+
+
[email protected](
+    "bad_bearer",
+    [
+        b"Bearer    ",
+        b"Bearer a===b",
+        b"Bearer hello!",
+        b"Bearer trailingspace ",
+        b"Bearer trailingtab\t",
+        b"Bearer [email protected]",
+        b"Beare abcd",
+        b" Bearer leadingspace",
+        b'OAuth realm="Example"',
+        b"",
+    ],
+)
+def test_oauth_invalid_bearer(setup_validator, connect, oauth_ctx, bad_bearer):
+    # Tell the validator to accept any token. This ensures that the invalid
+    # bearer tokens are rejected before the validation step.
+    setup_validator(reflect_role=True)
+
+    conn = connect()
+    begin_oauth_handshake(conn, oauth_ctx)
+    send_initial_response(conn, auth=bad_bearer)
+
+    expect_handshake_failure(conn, oauth_ctx)
+
+
[email protected]
[email protected](
+    "resp_type,resp,err",
+    [
+        pytest.param(
+            None,
+            None,
+            None,
+            marks=pytest.mark.slow,
+            id="no response (expect timeout)",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            b"hello",
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "did not send a kvsep response",
+            ),
+            id="bad dummy response",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            b"\x01\x01",
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "did not send a kvsep response",
+            ),
+            id="multiple kvseps",
+        ),
+        pytest.param(
+            pq3.types.Query,
+            dict(query=b""),
+            ExpectedError(PROTOCOL_VIOLATION_ERRCODE, "expected SASL response"),
+            id="bad response message type",
+        ),
+    ],
+)
+def test_oauth_bad_response_to_error_challenge(conn, oauth_ctx, resp_type, resp, err):
+    begin_oauth_handshake(conn, oauth_ctx)
+
+    # Send an empty auth initial response, which will force an authn failure.
+    send_initial_response(conn, auth=b"")
+
+    # We expect a discovery "challenge" back from the server before the authn
+    # failure message.
+    pkt = pq3.recv1(conn)
+    assert pkt.type == pq3.types.AuthnRequest
+
+    req = pkt.payload
+    assert req.type == pq3.authn.SASLContinue
+
+    body = json.loads(req.body)
+    assert body["status"] == "invalid_token"
+
+    if resp_type is None:
+        # Do not send the dummy response. We should time out and not get a
+        # response from the server.
+        with pytest.raises(socket.timeout):
+            conn.read(1)
+
+        # Done with the test.
+        return
+
+    # Send the bad response.
+    pq3.send(conn, resp_type, resp)
+
+    # Make sure the server fails the connection correctly.
+    pkt = pq3.recv1(conn)
+    err.match(pkt)
+
+
[email protected](
+    "type,payload,err",
+    [
+        pytest.param(
+            pq3.types.ErrorResponse,
+            dict(fields=[b""]),
+            ExpectedError(PROTOCOL_VIOLATION_ERRCODE, "expected SASL response"),
+            id="error response in initial message",
+        ),
+        pytest.param(
+            None,
+            # Sending an actual 65k packet results in ECONNRESET on Windows, and
+            # it floods the tests' connection log uselessly, so just fake the
+            # length and send a smaller number of bytes.
+            dict(
+                type=pq3.types.PasswordMessage,
+                len=MAX_SASL_MESSAGE_LENGTH + 1,
+                payload=b"x" * 512,
+            ),
+            ExpectedError(
+                INVALID_AUTHORIZATION_ERRCODE, "bearer authentication failed"
+            ),
+            id="overlong initial response data",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"SCRAM-SHA-256")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE, "invalid SASL authentication mechanism"
+            ),
+            id="bad SASL mechanism selection",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", len=2, data=b"x")),
+            ExpectedError(PROTOCOL_VIOLATION_ERRCODE, "insufficient data"),
+            id="SASL data underflow",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", len=0, data=b"x")),
+            ExpectedError(PROTOCOL_VIOLATION_ERRCODE, "invalid message format"),
+            id="SASL data overflow",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "message is empty",
+            ),
+            id="empty",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(name=b"OAUTHBEARER", data=b"n,,\x01auth=\x01\x01\0")
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "length does not match input length",
+            ),
+            id="contains null byte",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"\x01")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "Unexpected channel-binding flag",  # XXX this is a bit strange
+            ),
+            id="initial error response",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(name=b"OAUTHBEARER", data=b"p=tls-server-end-point,,\x01")
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "server does not support channel binding",
+            ),
+            id="uses channel binding",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"x,,\x01")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "Unexpected channel-binding flag",
+            ),
+            id="invalid channel binding specifier",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"y")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "Comma expected",
+            ),
+            id="bad GS2 header: missing channel binding terminator",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"y,a")),
+            ExpectedError(
+                FEATURE_NOT_SUPPORTED_ERRCODE,
+                "client uses authorization identity",
+            ),
+            id="bad GS2 header: authzid in use",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"y,b,")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "Unexpected attribute",
+            ),
+            id="bad GS2 header: extra attribute",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"y,")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                'Unexpected attribute "0x00"',  # XXX this is a bit strange
+            ),
+            id="bad GS2 header: missing authzid terminator",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"y,,")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "Key-value separator expected",
+            ),
+            id="missing initial kvsep",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"y,,")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "Key-value separator expected",
+            ),
+            id="missing initial kvsep",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(name=b"OAUTHBEARER", data=b"y,,\x01\x01")
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "does not contain an auth value",
+            ),
+            id="missing auth value: empty key-value list",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(name=b"OAUTHBEARER", data=b"y,,\x01host=example.com\x01\x01")
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "does not contain an auth value",
+            ),
+            id="missing auth value: other keys present",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(name=b"OAUTHBEARER", data=b"y,,\x01host=example.com")
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "unterminated key/value pair",
+            ),
+            id="missing value terminator",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"y,,\x01")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "did not contain a final terminator",
+            ),
+            id="missing list terminator: empty list",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(name=b"OAUTHBEARER", data=b"y,,\x01auth=Bearer 0\x01")
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "did not contain a final terminator",
+            ),
+            id="missing list terminator: with auth value",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(name=b"OAUTHBEARER", data=b"y,,\x01auth=Bearer 0\x01\x01blah")
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "additional data after the final terminator",
+            ),
+            id="additional key after terminator",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(name=b"OAUTHBEARER", data=b"y,,\x01key\x01\x01")
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "key without a value",
+            ),
+            id="key without value",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(
+                    name=b"OAUTHBEARER",
+                    data=b"y,,\x01auth=Bearer 0\x01auth=Bearer 1\x01\x01",
+                )
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "contains multiple auth values",
+            ),
+            id="multiple auth values",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(
+                    name=b"OAUTHBEARER",
+                    data=b"y,,\x01=\x01\x01",
+                )
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "empty key name",
+            ),
+            id="empty key",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(
+                    name=b"OAUTHBEARER",
+                    data=b"y,,\x01my key= \x01\x01",
+                )
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "invalid key name",
+            ),
+            id="whitespace in key name",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(
+                    name=b"OAUTHBEARER",
+                    data=b"y,,\x01key=a\x05b\x01\x01",
+                )
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "invalid value",
+            ),
+            id="junk in value",
+        ),
+    ],
+)
+def test_oauth_bad_initial_response(conn, oauth_ctx, type, payload, err):
+    begin_oauth_handshake(conn, oauth_ctx)
+
+    # The server expects a SASL response; give it something else instead.
+    if type is not None:
+        # Build a new packet of the desired type.
+        if not isinstance(payload, dict):
+            payload = dict(payload_data=payload)
+        pq3.send(conn, type, **payload)
+    else:
+        # The test has a custom packet to send. (The only reason to do this is
+        # if the packet is corrupt or otherwise unbuildable/unparsable, so we
+        # don't use the standard pq3.send().)
+        conn.write(pq3.Pq3.build(payload))
+        conn.end_packet(Container(payload))
+
+    resp = pq3.recv1(conn)
+    err.match(resp)
+
+
+def test_oauth_empty_initial_response(setup_validator, connect, oauth_ctx):
+    token = bearer_token()
+    setup_validator(expected_bearer=token)
+
+    conn = connect()
+    begin_oauth_handshake(conn, oauth_ctx)
+
+    # Send an initial response without data.
+    initial = pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER"))
+    pq3.send(conn, pq3.types.PasswordMessage, initial)
+
+    # The server should respond with an empty challenge so we can send the data
+    # it wants.
+    pkt = pq3.recv1(conn)
+
+    assert pkt.type == pq3.types.AuthnRequest
+    assert pkt.payload.type == pq3.authn.SASLContinue
+    assert not pkt.payload.body
+
+    # Now send the initial data.
+    data = b"n,,\x01auth=Bearer " + token.encode("ascii") + b"\x01\x01"
+    pq3.send(conn, pq3.types.PasswordMessage, data)
+
+    # Server should now complete the handshake.
+    expect_handshake_success(conn)
+
+
+# TODO: see if there's a way to test this easily after the API switch
+def xtest_oauth_no_validator(setup_validator, oauth_ctx, connect):
+    # Clear out our validator command, then establish a new connection.
+    set_validator("")
+    conn = connect()
+
+    begin_oauth_handshake(conn, oauth_ctx)
+    send_initial_response(conn, bearer=bearer_token())
+
+    # The server should fail the connection.
+    expect_handshake_failure(conn, oauth_ctx)
+
+
[email protected](
+    "user",
+    [
+        pytest.param(
+            lambda ctx: ctx.user,
+            id="basic username",
+        ),
+        pytest.param(
+            lambda ctx: ctx.punct_user,
+            id="'unsafe' characters are passed through correctly",
+        ),
+    ],
+)
+def test_oauth_validator_role(setup_validator, oauth_ctx, connect, user):
+    username = user(oauth_ctx)
+
+    # Tell the validator to reflect the PGUSER as the authenticated identity.
+    setup_validator(reflect_role=True)
+    conn = connect()
+
+    # Log in. Note that reflection ignores the bearer token.
+    begin_oauth_handshake(conn, oauth_ctx, user=username)
+    send_initial_response(conn, bearer=b"dontcare")
+    expect_handshake_success(conn)
+
+    # Check the user identity.
+    pq3.send(conn, pq3.types.Query, query=b"SELECT system_user;")
+    resp = receive_until(conn, pq3.types.DataRow)
+
+    row = resp.payload
+    expected = b"oauth:" + username.encode("utf-8")
+    assert row.columns == [expected]
+
+
[email protected]
+def odd_oauth_ctx(postgres_instance, oauth_ctx):
+    """
+    Adds an HBA entry with messed up issuer/scope settings, to pin the server
+    behavior.
+
+    TODO: these should really be rejected in the HBA rather than passed through
+    by the server.
+    """
+    id = secrets.token_hex(4)
+
+    class Context:
+        user = oauth_ctx.user
+        dbname = oauth_ctx.dbname
+
+        # Both of these embedded double-quotes are invalid; they're prohibited
+        # in both URLs and OAuth scope identifiers.
+        issuer = oauth_ctx.issuer + '/"/'
+        scope = oauth_ctx.scope + ' quo"ted'
+
+    ctx = Context()
+    hba_issuer = ctx.issuer.replace('"', '""')
+    hba_scope = ctx.scope.replace('"', '""')
+    hba_lines = [
+        f'host {ctx.dbname} {ctx.user} samehost oauth issuer="{hba_issuer}" scope="{hba_scope}"\n',
+    ]
+
+    if platform.system() == "Windows":
+        # XXX why is 'samehost' not behaving as expected on Windows?
+        for l in list(hba_lines):
+            hba_lines.append(l.replace("samehost", "::1/128"))
+
+    host, port = postgres_instance
+    conn = psycopg2.connect(host=host, port=port)
+    conn.autocommit = True
+
+    with contextlib.closing(conn):
+        c = conn.cursor()
+
+        # Replace pg_hba. Note that it's already been replaced once by
+        # oauth_ctx, so use a different backup prefix in prepend_file().
+        c.execute("SHOW hba_file;")
+        hba = c.fetchone()[0]
+
+        with prepend_file(hba, hba_lines, suffix=".bak2"):
+            c.execute("SELECT pg_reload_conf();")
+
+            yield ctx
+
+        # Put things back the way they were.
+        c.execute("SELECT pg_reload_conf();")
+
+
+def test_odd_server_response(odd_oauth_ctx, connect):
+    """
+    Verifies that the server is correctly escaping the JSON in its failure
+    response.
+    """
+    conn = connect()
+    begin_oauth_handshake(conn, odd_oauth_ctx, user=odd_oauth_ctx.user)
+
+    # Send an empty auth initial response, which will force an authn failure.
+    send_initial_response(conn, auth=b"")
+
+    expect_handshake_failure(conn, odd_oauth_ctx)
diff --git a/src/test/python/server/test_server.py b/src/test/python/server/test_server.py
new file mode 100644
index 0000000000..02126dba79
--- /dev/null
+++ b/src/test/python/server/test_server.py
@@ -0,0 +1,21 @@
+#
+# Copyright 2021 VMware, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import pq3
+
+
+def test_handshake(connect):
+    """Basic sanity check."""
+    conn = connect()
+
+    pq3.handshake(conn, user=pq3.pguser(), database=pq3.pgdatabase())
+
+    pq3.send(conn, pq3.types.Query, query=b"")
+
+    resp = pq3.recv1(conn)
+    assert resp.type == pq3.types.EmptyQueryResponse
+
+    resp = pq3.recv1(conn)
+    assert resp.type == pq3.types.ReadyForQuery
diff --git a/src/test/python/test_internals.py b/src/test/python/test_internals.py
new file mode 100644
index 0000000000..dee4855fc0
--- /dev/null
+++ b/src/test/python/test_internals.py
@@ -0,0 +1,138 @@
+#
+# Copyright 2021 VMware, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import io
+
+from pq3 import _DebugStream
+
+
+def test_DebugStream_read():
+    under = io.BytesIO(b"abcdefghijklmnopqrstuvwxyz")
+    out = io.StringIO()
+
+    stream = _DebugStream(under, out)
+
+    res = stream.read(5)
+    assert res == b"abcde"
+
+    res = stream.read(16)
+    assert res == b"fghijklmnopqrstu"
+
+    stream.flush_debug()
+
+    res = stream.read()
+    assert res == b"vwxyz"
+
+    stream.flush_debug()
+
+    expected = (
+        "< 0000:\t61 62 63 64 65 66 67 68 69 6a 6b 6c 6d 6e 6f 70\tabcdefghijklmnop\n"
+        "< 0010:\t71 72 73 74 75                                 \tqrstu\n"
+        "\n"
+        "< 0000:\t76 77 78 79 7a                                 \tvwxyz\n"
+        "\n"
+    )
+    assert out.getvalue() == expected
+
+
+def test_DebugStream_write():
+    under = io.BytesIO()
+    out = io.StringIO()
+
+    stream = _DebugStream(under, out)
+
+    stream.write(b"\x00\x01\x02")
+    stream.flush()
+
+    assert under.getvalue() == b"\x00\x01\x02"
+
+    stream.write(b"\xc0\xc1\xc2")
+    stream.flush()
+
+    assert under.getvalue() == b"\x00\x01\x02\xc0\xc1\xc2"
+
+    stream.flush_debug()
+
+    expected = "> 0000:\t00 01 02 c0 c1 c2                              \t......\n\n"
+    assert out.getvalue() == expected
+
+
+def test_DebugStream_read_write():
+    under = io.BytesIO(b"abcdefghijklmnopqrstuvwxyz")
+    out = io.StringIO()
+    stream = _DebugStream(under, out)
+
+    res = stream.read(5)
+    assert res == b"abcde"
+
+    stream.write(b"xxxxx")
+    stream.flush()
+
+    assert under.getvalue() == b"abcdexxxxxklmnopqrstuvwxyz"
+
+    res = stream.read(5)
+    assert res == b"klmno"
+
+    stream.write(b"xxxxx")
+    stream.flush()
+
+    assert under.getvalue() == b"abcdexxxxxklmnoxxxxxuvwxyz"
+
+    stream.flush_debug()
+
+    expected = (
+        "< 0000:\t61 62 63 64 65 6b 6c 6d 6e 6f                  \tabcdeklmno\n"
+        "\n"
+        "> 0000:\t78 78 78 78 78 78 78 78 78 78                  \txxxxxxxxxx\n"
+        "\n"
+    )
+    assert out.getvalue() == expected
+
+
+def test_DebugStream_end_packet():
+    under = io.BytesIO(b"abcdefghijklmnopqrstuvwxyz")
+    out = io.StringIO()
+    stream = _DebugStream(under, out)
+
+    stream.read(5)
+    stream.end_packet("read description", read=True, indent=" ")
+
+    stream.write(b"xxxxx")
+    stream.flush()
+    stream.end_packet("write description", indent=" ")
+
+    stream.read(5)
+    stream.write(b"xxxxx")
+    stream.flush()
+    stream.end_packet("read/write combo for read", read=True, indent=" ")
+
+    stream.read(5)
+    stream.write(b"xxxxx")
+    stream.flush()
+    stream.end_packet("read/write combo for write", indent=" ")
+
+    expected = (
+        " < 0000:\t61 62 63 64 65                                 \tabcde\n"
+        "\n"
+        "< read description\n"
+        "\n"
+        "> write description\n"
+        "\n"
+        " > 0000:\t78 78 78 78 78                                 \txxxxx\n"
+        "\n"
+        " < 0000:\t6b 6c 6d 6e 6f                                 \tklmno\n"
+        "\n"
+        " > 0000:\t78 78 78 78 78                                 \txxxxx\n"
+        "\n"
+        "< read/write combo for read\n"
+        "\n"
+        "> read/write combo for write\n"
+        "\n"
+        " < 0000:\t75 76 77 78 79                                 \tuvwxy\n"
+        "\n"
+        " > 0000:\t78 78 78 78 78                                 \txxxxx\n"
+        "\n"
+    )
+    assert out.getvalue() == expected
diff --git a/src/test/python/test_pq3.py b/src/test/python/test_pq3.py
new file mode 100644
index 0000000000..7c6817de31
--- /dev/null
+++ b/src/test/python/test_pq3.py
@@ -0,0 +1,574 @@
+#
+# Copyright 2021 VMware, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import contextlib
+import getpass
+import io
+import os
+import platform
+import struct
+import sys
+
+import pytest
+from construct import Container, PaddingError, StreamError, TerminatedError
+
+import pq3
+
+
[email protected](
+    "raw,expected,extra",
+    [
+        pytest.param(
+            b"\x00\x00\x00\x10\x00\x04\x00\x00abcdefgh",
+            Container(len=16, proto=0x40000, payload=b"abcdefgh"),
+            b"",
+            id="8-byte payload",
+        ),
+        pytest.param(
+            b"\x00\x00\x00\x08\x00\x04\x00\x00",
+            Container(len=8, proto=0x40000, payload=b""),
+            b"",
+            id="no payload",
+        ),
+        pytest.param(
+            b"\x00\x00\x00\x09\x00\x04\x00\x00abcde",
+            Container(len=9, proto=0x40000, payload=b"a"),
+            b"bcde",
+            id="1-byte payload and extra padding",
+        ),
+        pytest.param(
+            b"\x00\x00\x00\x0B\x00\x03\x00\x00hi\x00",
+            Container(len=11, proto=pq3.protocol(3, 0), payload=[b"hi"]),
+            b"",
+            id="implied parameter list when using proto version 3.0",
+        ),
+    ],
+)
+def test_Startup_parse(raw, expected, extra):
+    with io.BytesIO(raw) as stream:
+        actual = pq3.Startup.parse_stream(stream)
+
+        assert actual == expected
+        assert stream.read() == extra
+
+
[email protected](
+    "packet,expected_bytes",
+    [
+        pytest.param(
+            dict(),
+            b"\x00\x00\x00\x08\x00\x00\x00\x00",
+            id="nothing set",
+        ),
+        pytest.param(
+            dict(len=10, proto=0x12345678),
+            b"\x00\x00\x00\x0A\x12\x34\x56\x78\x00\x00",
+            id="len and proto set explicitly",
+        ),
+        pytest.param(
+            dict(proto=0x12345678),
+            b"\x00\x00\x00\x08\x12\x34\x56\x78",
+            id="implied len with no payload",
+        ),
+        pytest.param(
+            dict(proto=0x12345678, payload=b"abcd"),
+            b"\x00\x00\x00\x0C\x12\x34\x56\x78abcd",
+            id="implied len with payload",
+        ),
+        pytest.param(
+            dict(payload=[b""]),
+            b"\x00\x00\x00\x09\x00\x03\x00\x00\x00",
+            id="implied proto version 3 when sending parameters",
+        ),
+        pytest.param(
+            dict(payload=[b"hi", b""]),
+            b"\x00\x00\x00\x0C\x00\x03\x00\x00hi\x00\x00",
+            id="implied proto version 3 and len when sending more than one parameter",
+        ),
+        pytest.param(
+            dict(payload=dict(user="jsmith", database="postgres")),
+            b"\x00\x00\x00\x27\x00\x03\x00\x00user\x00jsmith\x00database\x00postgres\x00\x00",
+            id="auto-serialization of dict parameters",
+        ),
+    ],
+)
+def test_Startup_build(packet, expected_bytes):
+    actual = pq3.Startup.build(packet)
+    assert actual == expected_bytes
+
+
[email protected](
+    "raw,expected,extra",
+    [
+        pytest.param(
+            b"*\x00\x00\x00\x08abcd",
+            dict(type=b"*", len=8, payload=b"abcd"),
+            b"",
+            id="4-byte payload",
+        ),
+        pytest.param(
+            b"*\x00\x00\x00\x04",
+            dict(type=b"*", len=4, payload=b""),
+            b"",
+            id="no payload",
+        ),
+        pytest.param(
+            b"*\x00\x00\x00\x05xabcd",
+            dict(type=b"*", len=5, payload=b"x"),
+            b"abcd",
+            id="1-byte payload with extra padding",
+        ),
+        pytest.param(
+            b"R\x00\x00\x00\x08\x00\x00\x00\x00",
+            dict(
+                type=pq3.types.AuthnRequest,
+                len=8,
+                payload=dict(type=pq3.authn.OK, body=None),
+            ),
+            b"",
+            id="AuthenticationOk",
+        ),
+        pytest.param(
+            b"R\x00\x00\x00\x12\x00\x00\x00\x0AEXTERNAL\x00\x00",
+            dict(
+                type=pq3.types.AuthnRequest,
+                len=18,
+                payload=dict(type=pq3.authn.SASL, body=[b"EXTERNAL", b""]),
+            ),
+            b"",
+            id="AuthenticationSASL",
+        ),
+        pytest.param(
+            b"R\x00\x00\x00\x0D\x00\x00\x00\x0B12345",
+            dict(
+                type=pq3.types.AuthnRequest,
+                len=13,
+                payload=dict(type=pq3.authn.SASLContinue, body=b"12345"),
+            ),
+            b"",
+            id="AuthenticationSASLContinue",
+        ),
+        pytest.param(
+            b"R\x00\x00\x00\x0D\x00\x00\x00\x0C12345",
+            dict(
+                type=pq3.types.AuthnRequest,
+                len=13,
+                payload=dict(type=pq3.authn.SASLFinal, body=b"12345"),
+            ),
+            b"",
+            id="AuthenticationSASLFinal",
+        ),
+        pytest.param(
+            b"p\x00\x00\x00\x0Bhunter2",
+            dict(
+                type=pq3.types.PasswordMessage,
+                len=11,
+                payload=b"hunter2",
+            ),
+            b"",
+            id="PasswordMessage",
+        ),
+        pytest.param(
+            b"K\x00\x00\x00\x0C\x00\x00\x00\x00\x12\x34\x56\x78",
+            dict(
+                type=pq3.types.BackendKeyData,
+                len=12,
+                payload=dict(pid=0, key=0x12345678),
+            ),
+            b"",
+            id="BackendKeyData",
+        ),
+        pytest.param(
+            b"C\x00\x00\x00\x08SET\x00",
+            dict(
+                type=pq3.types.CommandComplete,
+                len=8,
+                payload=dict(tag=b"SET"),
+            ),
+            b"",
+            id="CommandComplete",
+        ),
+        pytest.param(
+            b"E\x00\x00\x00\x11Mbad!\x00Mdog!\x00\x00",
+            dict(type=b"E", len=17, payload=dict(fields=[b"Mbad!", b"Mdog!", b""])),
+            b"",
+            id="ErrorResponse",
+        ),
+        pytest.param(
+            b"S\x00\x00\x00\x08a\x00b\x00",
+            dict(
+                type=pq3.types.ParameterStatus,
+                len=8,
+                payload=dict(name=b"a", value=b"b"),
+            ),
+            b"",
+            id="ParameterStatus",
+        ),
+        pytest.param(
+            b"Z\x00\x00\x00\x05x",
+            dict(type=b"Z", len=5, payload=dict(status=b"x")),
+            b"",
+            id="ReadyForQuery",
+        ),
+        pytest.param(
+            b"Q\x00\x00\x00\x06!\x00",
+            dict(type=pq3.types.Query, len=6, payload=dict(query=b"!")),
+            b"",
+            id="Query",
+        ),
+        pytest.param(
+            b"D\x00\x00\x00\x0B\x00\x01\x00\x00\x00\x01!",
+            dict(type=pq3.types.DataRow, len=11, payload=dict(columns=[b"!"])),
+            b"",
+            id="DataRow",
+        ),
+        pytest.param(
+            b"D\x00\x00\x00\x06\x00\x00extra",
+            dict(type=pq3.types.DataRow, len=6, payload=dict(columns=[])),
+            b"extra",
+            id="DataRow with extra data",
+        ),
+        pytest.param(
+            b"I\x00\x00\x00\x04",
+            dict(type=pq3.types.EmptyQueryResponse, len=4, payload=None),
+            b"",
+            id="EmptyQueryResponse",
+        ),
+        pytest.param(
+            b"I\x00\x00\x00\x04\xFF",
+            dict(type=b"I", len=4, payload=None),
+            b"\xFF",
+            id="EmptyQueryResponse with extra bytes",
+        ),
+        pytest.param(
+            b"X\x00\x00\x00\x04",
+            dict(type=pq3.types.Terminate, len=4, payload=None),
+            b"",
+            id="Terminate",
+        ),
+    ],
+)
+def test_Pq3_parse(raw, expected, extra):
+    with io.BytesIO(raw) as stream:
+        actual = pq3.Pq3.parse_stream(stream)
+
+        assert actual == expected
+        assert stream.read() == extra
+
+
[email protected](
+    "fields,expected",
+    [
+        pytest.param(
+            dict(type=b"*", len=5),
+            b"*\x00\x00\x00\x05",
+            id="type and len set explicitly",
+        ),
+        pytest.param(
+            dict(type=b"*"),
+            b"*\x00\x00\x00\x04",
+            id="implied len with no payload",
+        ),
+        pytest.param(
+            dict(type=b"*", payload=b"1234"),
+            b"*\x00\x00\x00\x081234",
+            id="implied len with payload",
+        ),
+        pytest.param(
+            dict(type=b"*", len=12, payload=b"1234"),
+            b"*\x00\x00\x00\x0C1234",
+            id="overridden len (payload underflow)",
+        ),
+        pytest.param(
+            dict(type=b"*", len=5, payload=b"1234"),
+            b"*\x00\x00\x00\x051234",
+            id="overridden len (payload overflow)",
+        ),
+        pytest.param(
+            dict(type=pq3.types.AuthnRequest, payload=dict(type=pq3.authn.OK)),
+            b"R\x00\x00\x00\x08\x00\x00\x00\x00",
+            id="implied len/type for AuthenticationOK",
+        ),
+        pytest.param(
+            dict(
+                type=pq3.types.AuthnRequest,
+                payload=dict(
+                    type=pq3.authn.SASL,
+                    body=[b"SCRAM-SHA-256-PLUS", b"SCRAM-SHA-256", b""],
+                ),
+            ),
+            b"R\x00\x00\x00\x2A\x00\x00\x00\x0ASCRAM-SHA-256-PLUS\x00SCRAM-SHA-256\x00\x00",
+            id="implied len/type for AuthenticationSASL",
+        ),
+        pytest.param(
+            dict(
+                type=pq3.types.AuthnRequest,
+                payload=dict(type=pq3.authn.SASLContinue, body=b"12345"),
+            ),
+            b"R\x00\x00\x00\x0D\x00\x00\x00\x0B12345",
+            id="implied len/type for AuthenticationSASLContinue",
+        ),
+        pytest.param(
+            dict(
+                type=pq3.types.AuthnRequest,
+                payload=dict(type=pq3.authn.SASLFinal, body=b"12345"),
+            ),
+            b"R\x00\x00\x00\x0D\x00\x00\x00\x0C12345",
+            id="implied len/type for AuthenticationSASLFinal",
+        ),
+        pytest.param(
+            dict(
+                type=pq3.types.PasswordMessage,
+                payload=b"hunter2",
+            ),
+            b"p\x00\x00\x00\x0Bhunter2",
+            id="implied len/type for PasswordMessage",
+        ),
+        pytest.param(
+            dict(type=pq3.types.BackendKeyData, payload=dict(pid=1, key=7)),
+            b"K\x00\x00\x00\x0C\x00\x00\x00\x01\x00\x00\x00\x07",
+            id="implied len/type for BackendKeyData",
+        ),
+        pytest.param(
+            dict(type=pq3.types.CommandComplete, payload=dict(tag=b"SET")),
+            b"C\x00\x00\x00\x08SET\x00",
+            id="implied len/type for CommandComplete",
+        ),
+        pytest.param(
+            dict(type=pq3.types.ErrorResponse, payload=dict(fields=[b"error", b""])),
+            b"E\x00\x00\x00\x0Berror\x00\x00",
+            id="implied len/type for ErrorResponse",
+        ),
+        pytest.param(
+            dict(type=pq3.types.ParameterStatus, payload=dict(name=b"a", value=b"b")),
+            b"S\x00\x00\x00\x08a\x00b\x00",
+            id="implied len/type for ParameterStatus",
+        ),
+        pytest.param(
+            dict(type=pq3.types.ReadyForQuery, payload=dict(status=b"I")),
+            b"Z\x00\x00\x00\x05I",
+            id="implied len/type for ReadyForQuery",
+        ),
+        pytest.param(
+            dict(type=pq3.types.Query, payload=dict(query=b"SELECT 1;")),
+            b"Q\x00\x00\x00\x0eSELECT 1;\x00",
+            id="implied len/type for Query",
+        ),
+        pytest.param(
+            dict(type=pq3.types.DataRow, payload=dict(columns=[b"abcd"])),
+            b"D\x00\x00\x00\x0E\x00\x01\x00\x00\x00\x04abcd",
+            id="implied len/type for DataRow",
+        ),
+        pytest.param(
+            dict(type=pq3.types.EmptyQueryResponse),
+            b"I\x00\x00\x00\x04",
+            id="implied len for EmptyQueryResponse",
+        ),
+        pytest.param(
+            dict(type=pq3.types.Terminate),
+            b"X\x00\x00\x00\x04",
+            id="implied len for Terminate",
+        ),
+    ],
+)
+def test_Pq3_build(fields, expected):
+    actual = pq3.Pq3.build(fields)
+    assert actual == expected
+
+
[email protected](
+    "raw,expected,extra",
+    [
+        pytest.param(
+            b"\x00\x00",
+            dict(columns=[]),
+            b"",
+            id="no columns",
+        ),
+        pytest.param(
+            b"\x00\x01\x00\x00\x00\x04abcd",
+            dict(columns=[b"abcd"]),
+            b"",
+            id="one column",
+        ),
+        pytest.param(
+            b"\x00\x02\x00\x00\x00\x04abcd\x00\x00\x00\x01x",
+            dict(columns=[b"abcd", b"x"]),
+            b"",
+            id="multiple columns",
+        ),
+        pytest.param(
+            b"\x00\x02\x00\x00\x00\x00\x00\x00\x00\x01x",
+            dict(columns=[b"", b"x"]),
+            b"",
+            id="empty column value",
+        ),
+        pytest.param(
+            b"\x00\x02\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF",
+            dict(columns=[None, None]),
+            b"",
+            id="null columns",
+        ),
+    ],
+)
+def test_DataRow_parse(raw, expected, extra):
+    pkt = b"D" + struct.pack("!i", len(raw) + 4) + raw
+    with io.BytesIO(pkt) as stream:
+        actual = pq3.Pq3.parse_stream(stream)
+
+        assert actual.type == pq3.types.DataRow
+        assert actual.payload == expected
+        assert stream.read() == extra
+
+
[email protected](
+    "fields,expected",
+    [
+        pytest.param(
+            dict(),
+            b"\x00\x00",
+            id="no columns",
+        ),
+        pytest.param(
+            dict(columns=[None, None]),
+            b"\x00\x02\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF",
+            id="null columns",
+        ),
+    ],
+)
+def test_DataRow_build(fields, expected):
+    actual = pq3.Pq3.build(dict(type=pq3.types.DataRow, payload=fields))
+
+    expected = b"D" + struct.pack("!i", len(expected) + 4) + expected
+    assert actual == expected
+
+
[email protected](
+    "raw,expected,exception",
+    [
+        pytest.param(
+            b"EXTERNAL\x00\xFF\xFF\xFF\xFF",
+            dict(name=b"EXTERNAL", len=-1, data=None),
+            None,
+            id="no initial response",
+        ),
+        pytest.param(
+            b"EXTERNAL\x00\x00\x00\x00\x02me",
+            dict(name=b"EXTERNAL", len=2, data=b"me"),
+            None,
+            id="initial response",
+        ),
+        pytest.param(
+            b"EXTERNAL\x00\x00\x00\x00\x02meextra",
+            None,
+            TerminatedError,
+            id="extra data",
+        ),
+        pytest.param(
+            b"EXTERNAL\x00\x00\x00\x00\xFFme",
+            None,
+            StreamError,
+            id="underflow",
+        ),
+    ],
+)
+def test_SASLInitialResponse_parse(raw, expected, exception):
+    ctx = contextlib.nullcontext()
+    if exception:
+        ctx = pytest.raises(exception)
+
+    with ctx:
+        actual = pq3.SASLInitialResponse.parse(raw)
+        assert actual == expected
+
+
[email protected](
+    "fields,expected",
+    [
+        pytest.param(
+            dict(name=b"EXTERNAL"),
+            b"EXTERNAL\x00\xFF\xFF\xFF\xFF",
+            id="no initial response",
+        ),
+        pytest.param(
+            dict(name=b"EXTERNAL", data=None),
+            b"EXTERNAL\x00\xFF\xFF\xFF\xFF",
+            id="no initial response (explicit None)",
+        ),
+        pytest.param(
+            dict(name=b"EXTERNAL", data=b""),
+            b"EXTERNAL\x00\x00\x00\x00\x00",
+            id="empty response",
+        ),
+        pytest.param(
+            dict(name=b"EXTERNAL", data=b"[email protected]"),
+            b"EXTERNAL\x00\x00\x00\x00\[email protected]",
+            id="initial response",
+        ),
+        pytest.param(
+            dict(name=b"EXTERNAL", len=2, data=b"[email protected]"),
+            b"EXTERNAL\x00\x00\x00\x00\[email protected]",
+            id="data overflow",
+        ),
+        pytest.param(
+            dict(name=b"EXTERNAL", len=14, data=b"me"),
+            b"EXTERNAL\x00\x00\x00\x00\x0Eme",
+            id="data underflow",
+        ),
+    ],
+)
+def test_SASLInitialResponse_build(fields, expected):
+    actual = pq3.SASLInitialResponse.build(fields)
+    assert actual == expected
+
+
[email protected](
+    "version,expected_bytes",
+    [
+        pytest.param((3, 0), b"\x00\x03\x00\x00", id="version 3"),
+        pytest.param((1234, 5679), b"\x04\xd2\x16\x2f", id="SSLRequest"),
+    ],
+)
+def test_protocol(version, expected_bytes):
+    # Make sure the integer returned by protocol is correctly serialized on the
+    # wire.
+    assert struct.pack("!i", pq3.protocol(*version)) == expected_bytes
+
+
[email protected](
+    "envvar,func,expected",
+    [
+        ("PGHOST", pq3.pghost, "localhost"),
+        ("PGPORT", pq3.pgport, 5432),
+        (
+            "PGUSER",
+            pq3.pguser,
+            os.getlogin() if platform.system() == "Windows" else getpass.getuser(),
+        ),
+        ("PGDATABASE", pq3.pgdatabase, "postgres"),
+    ],
+)
+def test_env_defaults(monkeypatch, envvar, func, expected):
+    monkeypatch.delenv(envvar, raising=False)
+
+    actual = func()
+    assert actual == expected
+
+
[email protected](
+    "envvars,func,expected",
+    [
+        (dict(PGHOST="otherhost"), pq3.pghost, "otherhost"),
+        (dict(PGPORT="6789"), pq3.pgport, 6789),
+        (dict(PGUSER="postgres"), pq3.pguser, "postgres"),
+        (dict(PGDATABASE="template1"), pq3.pgdatabase, "template1"),
+    ],
+)
+def test_env(monkeypatch, envvars, func, expected):
+    for k, v in envvars.items():
+        monkeypatch.setenv(k, v)
+
+    actual = func()
+    assert actual == expected
diff --git a/src/test/python/tls.py b/src/test/python/tls.py
new file mode 100644
index 0000000000..075c02c1ca
--- /dev/null
+++ b/src/test/python/tls.py
@@ -0,0 +1,195 @@
+#
+# Copyright 2021 VMware, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+from construct import *
+
+#
+# TLS 1.3
+#
+# Most of the types below are transcribed from RFC 8446:
+#
+#     https://tools.ietf.org/html/rfc8446
+#
+
+
+def _Vector(size_field, element):
+    return Prefixed(size_field, GreedyRange(element))
+
+
+# Alerts
+
+AlertLevel = Enum(
+    Byte,
+    warning=1,
+    fatal=2,
+)
+
+AlertDescription = Enum(
+    Byte,
+    close_notify=0,
+    unexpected_message=10,
+    bad_record_mac=20,
+    decryption_failed_RESERVED=21,
+    record_overflow=22,
+    decompression_failure=30,
+    handshake_failure=40,
+    no_certificate_RESERVED=41,
+    bad_certificate=42,
+    unsupported_certificate=43,
+    certificate_revoked=44,
+    certificate_expired=45,
+    certificate_unknown=46,
+    illegal_parameter=47,
+    unknown_ca=48,
+    access_denied=49,
+    decode_error=50,
+    decrypt_error=51,
+    export_restriction_RESERVED=60,
+    protocol_version=70,
+    insufficient_security=71,
+    internal_error=80,
+    user_canceled=90,
+    no_renegotiation=100,
+    unsupported_extension=110,
+)
+
+Alert = Struct(
+    "level" / AlertLevel,
+    "description" / AlertDescription,
+)
+
+
+# Extensions
+
+ExtensionType = Enum(
+    Int16ub,
+    server_name=0,
+    max_fragment_length=1,
+    status_request=5,
+    supported_groups=10,
+    signature_algorithms=13,
+    use_srtp=14,
+    heartbeat=15,
+    application_layer_protocol_negotiation=16,
+    signed_certificate_timestamp=18,
+    client_certificate_type=19,
+    server_certificate_type=20,
+    padding=21,
+    pre_shared_key=41,
+    early_data=42,
+    supported_versions=43,
+    cookie=44,
+    psk_key_exchange_modes=45,
+    certificate_authorities=47,
+    oid_filters=48,
+    post_handshake_auth=49,
+    signature_algorithms_cert=50,
+    key_share=51,
+)
+
+Extension = Struct(
+    "extension_type" / ExtensionType,
+    "extension_data" / Prefixed(Int16ub, GreedyBytes),
+)
+
+
+# ClientHello
+
+
+class _CipherSuiteAdapter(Adapter):
+    class _hextuple(tuple):
+        def __repr__(self):
+            return f"(0x{self[0]:02X}, 0x{self[1]:02X})"
+
+    def _encode(self, obj, context, path):
+        return bytes(obj)
+
+    def _decode(self, obj, context, path):
+        assert len(obj) == 2
+        return self._hextuple(obj)
+
+
+ProtocolVersion = Hex(Int16ub)
+
+Random = Hex(Bytes(32))
+
+CipherSuite = _CipherSuiteAdapter(Byte[2])
+
+ClientHello = Struct(
+    "legacy_version" / ProtocolVersion,
+    "random" / Random,
+    "legacy_session_id" / Prefixed(Byte, Hex(GreedyBytes)),
+    "cipher_suites" / _Vector(Int16ub, CipherSuite),
+    "legacy_compression_methods" / Prefixed(Byte, GreedyBytes),
+    "extensions" / _Vector(Int16ub, Extension),
+)
+
+# ServerHello
+
+ServerHello = Struct(
+    "legacy_version" / ProtocolVersion,
+    "random" / Random,
+    "legacy_session_id_echo" / Prefixed(Byte, Hex(GreedyBytes)),
+    "cipher_suite" / CipherSuite,
+    "legacy_compression_method" / Hex(Byte),
+    "extensions" / _Vector(Int16ub, Extension),
+)
+
+# Handshake
+
+HandshakeType = Enum(
+    Byte,
+    client_hello=1,
+    server_hello=2,
+    new_session_ticket=4,
+    end_of_early_data=5,
+    encrypted_extensions=8,
+    certificate=11,
+    certificate_request=13,
+    certificate_verify=15,
+    finished=20,
+    key_update=24,
+    message_hash=254,
+)
+
+Handshake = Struct(
+    "msg_type" / HandshakeType,
+    "length" / Int24ub,
+    "payload"
+    / Switch(
+        this.msg_type,
+        {
+            HandshakeType.client_hello: ClientHello,
+            HandshakeType.server_hello: ServerHello,
+            # HandshakeType.end_of_early_data: EndOfEarlyData,
+            # HandshakeType.encrypted_extensions: EncryptedExtensions,
+            # HandshakeType.certificate_request: CertificateRequest,
+            # HandshakeType.certificate: Certificate,
+            # HandshakeType.certificate_verify: CertificateVerify,
+            # HandshakeType.finished: Finished,
+            # HandshakeType.new_session_ticket: NewSessionTicket,
+            # HandshakeType.key_update: KeyUpdate,
+        },
+        default=FixedSized(this.length, GreedyBytes),
+    ),
+)
+
+# Records
+
+ContentType = Enum(
+    Byte,
+    invalid=0,
+    change_cipher_spec=20,
+    alert=21,
+    handshake=22,
+    application_data=23,
+)
+
+Plaintext = Struct(
+    "type" / ContentType,
+    "legacy_record_version" / ProtocolVersion,
+    "length" / Int16ub,
+    "fragment" / FixedSized(this.length, GreedyBytes),
+)
diff --git a/src/tools/make_venv b/src/tools/make_venv
new file mode 100755
index 0000000000..804307ee12
--- /dev/null
+++ b/src/tools/make_venv
@@ -0,0 +1,56 @@
+#!/usr/bin/env python3
+
+import argparse
+import subprocess
+import os
+import platform
+import sys
+
+parser = argparse.ArgumentParser()
+
+parser.add_argument('--requirements', help='path to pip requirements file', type=str)
+parser.add_argument('--privatedir', help='private directory for target', type=str)
+parser.add_argument('venv_path', help='desired venv location')
+
+args = parser.parse_args()
+
+# Decide whether or not to capture stdout into a log file. We only do this if
+# we've been given our own private directory.
+#
+# FIXME Unfortunately this interferes with debugging on Cirrus, because the
+# private directory isn't uploaded in the sanity check's artifacts. When we
+# don't capture the log file, it gets spammed to stdout during build... Is there
+# a way to push this into the meson-log somehow? For now, the capture
+# implementation is commented out.
+logfile = None
+
+if args.privatedir:
+    if not os.path.isdir(args.privatedir):
+        os.mkdir(args.privatedir)
+
+    # FIXME see above comment
+    # logpath = os.path.join(args.privatedir, 'stdout.txt')
+    # logfile = open(logpath, 'w')
+
+def run(*args):
+    kwargs = dict(check=True)
+    if logfile:
+        kwargs.update(stdout=logfile)
+
+    subprocess.run(args, **kwargs)
+
+# Create the virtualenv first.
+run(sys.executable, '-m', 'venv', args.venv_path)
+
+# Update pip next. This helps avoid old pip bugs; the version inside system
+# Pythons tends to be pretty out of date.
+bindir = 'Scripts' if platform.system() == 'Windows' else 'bin'
+python = os.path.join(args.venv_path, bindir, 'python3')
+run(python, '-m', 'pip', 'install', '-U', 'pip')
+
+# Finally, install the test's requirements. We need pytest and pytest-tap, no
+# matter what the test needs.
+pip = os.path.join(args.venv_path, bindir, 'pip')
+run(pip, 'install', 'pytest', 'pytest-tap')
+if args.requirements:
+    run(pip, 'install', '-r', args.requirements)
diff --git a/src/tools/testwrap b/src/tools/testwrap
index 8ae8fb79ba..ffdf760d79 100755
--- a/src/tools/testwrap
+++ b/src/tools/testwrap
@@ -14,6 +14,7 @@ parser.add_argument('--testgroup', help='test group', type=str)
 parser.add_argument('--testname', help='test name', type=str)
 parser.add_argument('--skip', help='skip test (with reason)', type=str)
 parser.add_argument('--pg-test-extra', help='extra tests', type=str)
+parser.add_argument('--skip-without-extra', help='skip if PG_TEST_EXTRA is missing this arg', type=str)
 parser.add_argument('test_command', nargs='*')
 
 args = parser.parse_args()
@@ -29,6 +30,12 @@ if args.skip is not None:
     print('1..0 # Skipped: ' + args.skip)
     sys.exit(0)
 
+if args.skip_without_extra is not None:
+    extras = os.environ.get("PG_TEST_EXTRA", args.pg_test_extra)
+    if extras is None or args.skip_without_extra not in extras.split():
+        print(f'1..0 # Skipped: PG_TEST_EXTRA does not contain "{args.skip_without_extra}"')
+        sys.exit(0)
+
 if os.path.exists(testdir) and os.path.isdir(testdir):
     shutil.rmtree(testdir)
 os.makedirs(testdir)
-- 
2.39.3 (Apple Git-146)



  [application/octet-stream] v46-0002-XXX-fix-libcurl-link-error.patch (1.2K, ../../[email protected]/3-v46-0002-XXX-fix-libcurl-link-error.patch)
  download | inline diff:
From dde6a4afb3b702c9265465d73c65b684bf1b9381 Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Mon, 13 Jan 2025 12:31:59 -0800
Subject: [PATCH v46 2/3] XXX fix libcurl link error

The ftp/curl port appears to be missing a minimum version dependency on
libssh2, so the following starts showing up after upgrading to curl
8.11.1_1:

    libcurl.so.4: Undefined symbol "libssh2_session_callback_set2"

But 13.3 is EOL, so it's not clear if anyone would be interested in a
bug report, and a FreeBSD 14 Cirrus image is in progress. Hack past it
for now.
---
 .cirrus.tasks.yml | 1 +
 1 file changed, 1 insertion(+)

diff --git a/.cirrus.tasks.yml b/.cirrus.tasks.yml
index c192a07770..3afea832bc 100644
--- a/.cirrus.tasks.yml
+++ b/.cirrus.tasks.yml
@@ -168,6 +168,7 @@ task:
     sysctl kern.corefile='/tmp/cores/%N.%P.core'
   setup_additional_packages_script: |
     pkg install -y curl
+    pkg upgrade -y libssh2 # XXX shouldn't be necessary. revisit w/ FreeBSD 14
 
   # NB: Intentionally build without -Dllvm. The freebsd image size is already
   # large enough to make VM startup slow, and even without llvm freebsd
-- 
2.39.3 (Apple Git-146)



  [application/octet-stream] v46-0001-Add-OAUTHBEARER-SASL-mechanism.patch (299.3K, ../../[email protected]/4-v46-0001-Add-OAUTHBEARER-SASL-mechanism.patch)
  download | inline diff:
From 93f059c214722f19d1881d8241905412ebe1faab Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Wed, 23 Oct 2024 09:37:33 -0700
Subject: [PATCH v46 1/3] Add OAUTHBEARER SASL mechanism

DO NOT USE THIS PROOF OF CONCEPT IN PRODUCTION.

Implement OAUTHBEARER (RFC 7628) and OAuth 2.0 Device Authorization
Grants (RFC 8628). This adds a new auth method, oauth, to pg_hba. When
speaking to a OAuth-enabled server, it looks a bit like this:

    $ psql 'host=example.org oauth_issuer=... oauth_client_id=...'
    Visit https://oauth.example.org/login and enter the code: FPQ2-M4BG

The OAuth issuer must support device authorization. No other OAuth flows
are currently implemented (but clients may provide their own flows).

The client implementation requires libcurl and its development headers.
Pass --with-libcurl/-Dlibcurl=enabled during configuration. The server
implementation does not require additional build-time dependencies, but
an external validator module must be supplied.

Thomas Munro wrote the kqueue() implementation for oauth-curl; thanks!

Several TODOs:
- perform several sanity checks on the OAuth issuer's responses
- improve error debuggability during the OAuth handshake
- fix libcurl initialization thread-safety
- harden the libcurl flow implementation
- fill in documentation stubs
- support protocol "variants" implemented by major providers
- implement more helpful handling of HBA misconfigurations
- use logdetail during auth failures
- ...and more.

Co-authored-by: Daniel Gustafsson <[email protected]>
---
 .cirrus.tasks.yml                             |   15 +-
 config/programs.m4                            |   42 +
 configure                                     |  279 ++
 configure.ac                                  |   41 +
 doc/src/sgml/client-auth.sgml                 |  252 ++
 doc/src/sgml/config.sgml                      |   26 +
 doc/src/sgml/filelist.sgml                    |    1 +
 doc/src/sgml/installation.sgml                |   27 +
 doc/src/sgml/libpq.sgml                       |  393 +++
 doc/src/sgml/oauth-validators.sgml            |  402 +++
 doc/src/sgml/postgres.sgml                    |    1 +
 doc/src/sgml/protocol.sgml                    |  133 +-
 doc/src/sgml/regress.sgml                     |   10 +
 meson.build                                   |   66 +
 meson_options.txt                             |    3 +
 src/Makefile.global.in                        |    1 +
 src/backend/libpq/Makefile                    |    1 +
 src/backend/libpq/auth-oauth.c                |  860 ++++++
 src/backend/libpq/auth.c                      |   10 +-
 src/backend/libpq/hba.c                       |   64 +-
 src/backend/libpq/meson.build                 |    1 +
 src/backend/libpq/pg_hba.conf.sample          |    4 +-
 src/backend/utils/adt/hbafuncs.c              |   19 +
 src/backend/utils/misc/guc_tables.c           |   12 +
 src/backend/utils/misc/postgresql.conf.sample |    3 +
 src/include/common/oauth-common.h             |   19 +
 src/include/libpq/auth.h                      |    1 +
 src/include/libpq/hba.h                       |    7 +-
 src/include/libpq/oauth.h                     |   54 +
 src/include/pg_config.h.in                    |    9 +
 src/interfaces/libpq/Makefile                 |   11 +-
 src/interfaces/libpq/exports.txt              |    3 +
 src/interfaces/libpq/fe-auth-oauth-curl.c     | 2635 +++++++++++++++++
 src/interfaces/libpq/fe-auth-oauth.c          | 1141 +++++++
 src/interfaces/libpq/fe-auth-oauth.h          |   45 +
 src/interfaces/libpq/fe-auth.c                |   36 +-
 src/interfaces/libpq/fe-auth.h                |    3 +
 src/interfaces/libpq/fe-connect.c             |   48 +-
 src/interfaces/libpq/libpq-fe.h               |   82 +
 src/interfaces/libpq/libpq-int.h              |   13 +-
 src/interfaces/libpq/meson.build              |    5 +
 src/makefiles/meson.build                     |    1 +
 src/test/authentication/t/001_password.pl     |    8 +-
 src/test/modules/Makefile                     |    1 +
 src/test/modules/meson.build                  |    1 +
 src/test/modules/oauth_validator/.gitignore   |    4 +
 src/test/modules/oauth_validator/Makefile     |   40 +
 src/test/modules/oauth_validator/README       |   13 +
 .../modules/oauth_validator/fail_validator.c  |   42 +
 src/test/modules/oauth_validator/meson.build  |   69 +
 .../oauth_validator/oauth_hook_client.c       |  264 ++
 .../modules/oauth_validator/t/001_server.pl   |  551 ++++
 .../modules/oauth_validator/t/002_client.pl   |  154 +
 .../modules/oauth_validator/t/OAuth/Server.pm |  140 +
 .../modules/oauth_validator/t/oauth_server.py |  391 +++
 src/test/modules/oauth_validator/validator.c  |  135 +
 src/test/perl/PostgreSQL/Test/Cluster.pm      |   20 +-
 src/tools/pgindent/pgindent                   |   14 +
 src/tools/pgindent/typedefs.list              |   11 +
 59 files changed, 8598 insertions(+), 39 deletions(-)
 create mode 100644 doc/src/sgml/oauth-validators.sgml
 create mode 100644 src/backend/libpq/auth-oauth.c
 create mode 100644 src/include/common/oauth-common.h
 create mode 100644 src/include/libpq/oauth.h
 create mode 100644 src/interfaces/libpq/fe-auth-oauth-curl.c
 create mode 100644 src/interfaces/libpq/fe-auth-oauth.c
 create mode 100644 src/interfaces/libpq/fe-auth-oauth.h
 create mode 100644 src/test/modules/oauth_validator/.gitignore
 create mode 100644 src/test/modules/oauth_validator/Makefile
 create mode 100644 src/test/modules/oauth_validator/README
 create mode 100644 src/test/modules/oauth_validator/fail_validator.c
 create mode 100644 src/test/modules/oauth_validator/meson.build
 create mode 100644 src/test/modules/oauth_validator/oauth_hook_client.c
 create mode 100644 src/test/modules/oauth_validator/t/001_server.pl
 create mode 100644 src/test/modules/oauth_validator/t/002_client.pl
 create mode 100644 src/test/modules/oauth_validator/t/OAuth/Server.pm
 create mode 100755 src/test/modules/oauth_validator/t/oauth_server.py
 create mode 100644 src/test/modules/oauth_validator/validator.c

diff --git a/.cirrus.tasks.yml b/.cirrus.tasks.yml
index cfe2117e02..c192a07770 100644
--- a/.cirrus.tasks.yml
+++ b/.cirrus.tasks.yml
@@ -23,7 +23,7 @@ env:
   MTEST_ARGS: --print-errorlogs --no-rebuild -C build
   PGCTLTIMEOUT: 120 # avoids spurious failures during parallel tests
   TEMP_CONFIG: ${CIRRUS_WORKING_DIR}/src/tools/ci/pg_ci_base.conf
-  PG_TEST_EXTRA: kerberos ldap ssl libpq_encryption load_balance
+  PG_TEST_EXTRA: kerberos ldap ssl libpq_encryption load_balance oauth
 
 
 # What files to preserve in case tests fail
@@ -167,7 +167,7 @@ task:
     chown root:postgres /tmp/cores
     sysctl kern.corefile='/tmp/cores/%N.%P.core'
   setup_additional_packages_script: |
-    #pkg install -y ...
+    pkg install -y curl
 
   # NB: Intentionally build without -Dllvm. The freebsd image size is already
   # large enough to make VM startup slow, and even without llvm freebsd
@@ -222,6 +222,7 @@ LINUX_CONFIGURE_FEATURES: &LINUX_CONFIGURE_FEATURES >-
   --with-gssapi
   --with-icu
   --with-ldap
+  --with-libcurl
   --with-libxml
   --with-libxslt
   --with-llvm
@@ -315,8 +316,10 @@ task:
     EOF
 
   setup_additional_packages_script: |
-    #apt-get update
-    #DEBIAN_FRONTEND=noninteractive apt-get -y install ...
+    apt-get update
+    DEBIAN_FRONTEND=noninteractive apt-get -y install \
+      libcurl4-openssl-dev \
+      libcurl4-openssl-dev:i386 \
 
   matrix:
     - name: Linux - Debian Bookworm - Autoconf
@@ -692,8 +695,8 @@ task:
     folder: $CCACHE_DIR
 
   setup_additional_packages_script: |
-    #apt-get update
-    #DEBIAN_FRONTEND=noninteractive apt-get -y install ...
+    apt-get update
+    DEBIAN_FRONTEND=noninteractive apt-get -y install libcurl4-openssl-dev
 
   ###
   # Test that code can be built with gcc/clang without warnings
diff --git a/config/programs.m4 b/config/programs.m4
index 7b55c2664a..86a3750f9e 100644
--- a/config/programs.m4
+++ b/config/programs.m4
@@ -274,3 +274,45 @@ AC_DEFUN([PGAC_CHECK_STRIP],
   AC_SUBST(STRIP_STATIC_LIB)
   AC_SUBST(STRIP_SHARED_LIB)
 ])# PGAC_CHECK_STRIP
+
+
+
+# PGAC_CHECK_LIBCURL
+# ------------------
+# Check for required libraries and headers, and test to see whether the current
+# installation of libcurl is threadsafe.
+
+AC_DEFUN([PGAC_CHECK_LIBCURL],
+[
+  AC_CHECK_HEADER(curl/curl.h, [],
+				  [AC_MSG_ERROR([header file <curl/curl.h> is required for --with-libcurl])])
+  AC_CHECK_LIB(curl, curl_multi_init, [],
+			   [AC_MSG_ERROR([library 'curl' does not provide curl_multi_init])])
+
+  # Check to see whether the current platform supports threadsafe Curl
+  # initialization.
+  AC_CACHE_CHECK([for curl_global_init thread safety], [pgac_cv__libcurl_threadsafe_init],
+  [AC_RUN_IFELSE([AC_LANG_PROGRAM([
+#include <curl/curl.h>
+],[
+    curl_version_info_data *info;
+
+    if (curl_global_init(CURL_GLOBAL_ALL))
+        return -1;
+
+    info = curl_version_info(CURLVERSION_NOW);
+#ifdef CURL_VERSION_THREADSAFE
+    if (info->features & CURL_VERSION_THREADSAFE)
+        return 0;
+#endif
+
+    return 1;
+])],
+  [pgac_cv__libcurl_threadsafe_init=yes],
+  [pgac_cv__libcurl_threadsafe_init=no],
+  [pgac_cv__libcurl_threadsafe_init=unknown])])
+  if test x"$pgac_cv__libcurl_threadsafe_init" = xyes ; then
+    AC_DEFINE(HAVE_THREADSAFE_CURL_GLOBAL_INIT, 1,
+              [Define to 1 if curl_global_init() is guaranteed to be threadsafe.])
+  fi
+])# PGAC_CHECK_LIBCURL
diff --git a/configure b/configure
index 0ffcaeb436..33422d2411 100755
--- a/configure
+++ b/configure
@@ -708,6 +708,9 @@ XML2_LIBS
 XML2_CFLAGS
 XML2_CONFIG
 with_libxml
+LIBCURL_LIBS
+LIBCURL_CFLAGS
+with_libcurl
 with_uuid
 with_readline
 with_systemd
@@ -864,6 +867,7 @@ with_readline
 with_libedit_preferred
 with_uuid
 with_ossp_uuid
+with_libcurl
 with_libxml
 with_libxslt
 with_system_tzdata
@@ -894,6 +898,8 @@ PKG_CONFIG_PATH
 PKG_CONFIG_LIBDIR
 ICU_CFLAGS
 ICU_LIBS
+LIBCURL_CFLAGS
+LIBCURL_LIBS
 XML2_CONFIG
 XML2_CFLAGS
 XML2_LIBS
@@ -1574,6 +1580,7 @@ Optional Packages:
                           prefer BSD Libedit over GNU Readline
   --with-uuid=LIB         build contrib/uuid-ossp using LIB (bsd,e2fs,ossp)
   --with-ossp-uuid        obsolete spelling of --with-uuid=ossp
+  --with-libcurl          build with libcurl support
   --with-libxml           build with XML support
   --with-libxslt          use XSLT support when building contrib/xml2
   --with-system-tzdata=DIR
@@ -1607,6 +1614,10 @@ Some influential environment variables:
               path overriding pkg-config's built-in search path
   ICU_CFLAGS  C compiler flags for ICU, overriding pkg-config
   ICU_LIBS    linker flags for ICU, overriding pkg-config
+  LIBCURL_CFLAGS
+              C compiler flags for LIBCURL, overriding pkg-config
+  LIBCURL_LIBS
+              linker flags for LIBCURL, overriding pkg-config
   XML2_CONFIG path to xml2-config utility
   XML2_CFLAGS C compiler flags for XML2, overriding pkg-config
   XML2_LIBS   linker flags for XML2, overriding pkg-config
@@ -8762,6 +8773,157 @@ fi
 
 
 
+#
+# libcurl
+#
+{ $as_echo "$as_me:${as_lineno-$LINENO}: checking whether to build with libcurl support" >&5
+$as_echo_n "checking whether to build with libcurl support... " >&6; }
+
+
+
+# Check whether --with-libcurl was given.
+if test "${with_libcurl+set}" = set; then :
+  withval=$with_libcurl;
+  case $withval in
+    yes)
+
+$as_echo "#define USE_LIBCURL 1" >>confdefs.h
+
+      ;;
+    no)
+      :
+      ;;
+    *)
+      as_fn_error $? "no argument expected for --with-libcurl option" "$LINENO" 5
+      ;;
+  esac
+
+else
+  with_libcurl=no
+
+fi
+
+
+{ $as_echo "$as_me:${as_lineno-$LINENO}: result: $with_libcurl" >&5
+$as_echo "$with_libcurl" >&6; }
+
+
+if test "$with_libcurl" = yes ; then
+  # Check for libcurl 7.61.0 or higher (corresponding to RHEL8 and the ability
+  # to explicitly set TLS 1.3 ciphersuites).
+
+pkg_failed=no
+{ $as_echo "$as_me:${as_lineno-$LINENO}: checking for libcurl >= 7.61.0" >&5
+$as_echo_n "checking for libcurl >= 7.61.0... " >&6; }
+
+if test -n "$LIBCURL_CFLAGS"; then
+    pkg_cv_LIBCURL_CFLAGS="$LIBCURL_CFLAGS"
+ elif test -n "$PKG_CONFIG"; then
+    if test -n "$PKG_CONFIG" && \
+    { { $as_echo "$as_me:${as_lineno-$LINENO}: \$PKG_CONFIG --exists --print-errors \"libcurl >= 7.61.0\""; } >&5
+  ($PKG_CONFIG --exists --print-errors "libcurl >= 7.61.0") 2>&5
+  ac_status=$?
+  $as_echo "$as_me:${as_lineno-$LINENO}: \$? = $ac_status" >&5
+  test $ac_status = 0; }; then
+  pkg_cv_LIBCURL_CFLAGS=`$PKG_CONFIG --cflags "libcurl >= 7.61.0" 2>/dev/null`
+		      test "x$?" != "x0" && pkg_failed=yes
+else
+  pkg_failed=yes
+fi
+ else
+    pkg_failed=untried
+fi
+if test -n "$LIBCURL_LIBS"; then
+    pkg_cv_LIBCURL_LIBS="$LIBCURL_LIBS"
+ elif test -n "$PKG_CONFIG"; then
+    if test -n "$PKG_CONFIG" && \
+    { { $as_echo "$as_me:${as_lineno-$LINENO}: \$PKG_CONFIG --exists --print-errors \"libcurl >= 7.61.0\""; } >&5
+  ($PKG_CONFIG --exists --print-errors "libcurl >= 7.61.0") 2>&5
+  ac_status=$?
+  $as_echo "$as_me:${as_lineno-$LINENO}: \$? = $ac_status" >&5
+  test $ac_status = 0; }; then
+  pkg_cv_LIBCURL_LIBS=`$PKG_CONFIG --libs "libcurl >= 7.61.0" 2>/dev/null`
+		      test "x$?" != "x0" && pkg_failed=yes
+else
+  pkg_failed=yes
+fi
+ else
+    pkg_failed=untried
+fi
+
+
+
+if test $pkg_failed = yes; then
+        { $as_echo "$as_me:${as_lineno-$LINENO}: result: no" >&5
+$as_echo "no" >&6; }
+
+if $PKG_CONFIG --atleast-pkgconfig-version 0.20; then
+        _pkg_short_errors_supported=yes
+else
+        _pkg_short_errors_supported=no
+fi
+        if test $_pkg_short_errors_supported = yes; then
+	        LIBCURL_PKG_ERRORS=`$PKG_CONFIG --short-errors --print-errors --cflags --libs "libcurl >= 7.61.0" 2>&1`
+        else
+	        LIBCURL_PKG_ERRORS=`$PKG_CONFIG --print-errors --cflags --libs "libcurl >= 7.61.0" 2>&1`
+        fi
+	# Put the nasty error message in config.log where it belongs
+	echo "$LIBCURL_PKG_ERRORS" >&5
+
+	as_fn_error $? "Package requirements (libcurl >= 7.61.0) were not met:
+
+$LIBCURL_PKG_ERRORS
+
+Consider adjusting the PKG_CONFIG_PATH environment variable if you
+installed software in a non-standard prefix.
+
+Alternatively, you may set the environment variables LIBCURL_CFLAGS
+and LIBCURL_LIBS to avoid the need to call pkg-config.
+See the pkg-config man page for more details." "$LINENO" 5
+elif test $pkg_failed = untried; then
+        { $as_echo "$as_me:${as_lineno-$LINENO}: result: no" >&5
+$as_echo "no" >&6; }
+	{ { $as_echo "$as_me:${as_lineno-$LINENO}: error: in \`$ac_pwd':" >&5
+$as_echo "$as_me: error: in \`$ac_pwd':" >&2;}
+as_fn_error $? "The pkg-config script could not be found or is too old.  Make sure it
+is in your PATH or set the PKG_CONFIG environment variable to the full
+path to pkg-config.
+
+Alternatively, you may set the environment variables LIBCURL_CFLAGS
+and LIBCURL_LIBS to avoid the need to call pkg-config.
+See the pkg-config man page for more details.
+
+To get pkg-config, see <http://pkg-config.freedesktop.org/>.
+See \`config.log' for more details" "$LINENO" 5; }
+else
+	LIBCURL_CFLAGS=$pkg_cv_LIBCURL_CFLAGS
+	LIBCURL_LIBS=$pkg_cv_LIBCURL_LIBS
+        { $as_echo "$as_me:${as_lineno-$LINENO}: result: yes" >&5
+$as_echo "yes" >&6; }
+
+fi
+
+  # We only care about -I, -D, and -L switches;
+  # note that -lcurl will be added by PGAC_CHECK_LIBCURL below.
+  for pgac_option in $LIBCURL_CFLAGS; do
+    case $pgac_option in
+      -I*|-D*) CPPFLAGS="$CPPFLAGS $pgac_option";;
+    esac
+  done
+  for pgac_option in $LIBCURL_LIBS; do
+    case $pgac_option in
+      -L*) LDFLAGS="$LDFLAGS $pgac_option";;
+    esac
+  done
+
+  # OAuth requires python for testing
+  if test "$with_python" != yes; then
+    { $as_echo "$as_me:${as_lineno-$LINENO}: WARNING: *** OAuth support tests require --with-python to run" >&5
+$as_echo "$as_me: WARNING: *** OAuth support tests require --with-python to run" >&2;}
+  fi
+fi
+
+
 #
 # XML
 #
@@ -12216,6 +12378,123 @@ fi
 
 fi
 
+# XXX libcurl must link after libgssapi_krb5 on FreeBSD to avoid segfaults
+# during gss_acquire_cred(). This is possibly related to Curl's Heimdal
+# dependency on that platform?
+if test "$with_libcurl" = yes ; then
+
+  ac_fn_c_check_header_mongrel "$LINENO" "curl/curl.h" "ac_cv_header_curl_curl_h" "$ac_includes_default"
+if test "x$ac_cv_header_curl_curl_h" = xyes; then :
+
+else
+  as_fn_error $? "header file <curl/curl.h> is required for --with-libcurl" "$LINENO" 5
+fi
+
+
+  { $as_echo "$as_me:${as_lineno-$LINENO}: checking for curl_multi_init in -lcurl" >&5
+$as_echo_n "checking for curl_multi_init in -lcurl... " >&6; }
+if ${ac_cv_lib_curl_curl_multi_init+:} false; then :
+  $as_echo_n "(cached) " >&6
+else
+  ac_check_lib_save_LIBS=$LIBS
+LIBS="-lcurl  $LIBS"
+cat confdefs.h - <<_ACEOF >conftest.$ac_ext
+/* end confdefs.h.  */
+
+/* Override any GCC internal prototype to avoid an error.
+   Use char because int might match the return type of a GCC
+   builtin and then its argument prototype would still apply.  */
+#ifdef __cplusplus
+extern "C"
+#endif
+char curl_multi_init ();
+int
+main ()
+{
+return curl_multi_init ();
+  ;
+  return 0;
+}
+_ACEOF
+if ac_fn_c_try_link "$LINENO"; then :
+  ac_cv_lib_curl_curl_multi_init=yes
+else
+  ac_cv_lib_curl_curl_multi_init=no
+fi
+rm -f core conftest.err conftest.$ac_objext \
+    conftest$ac_exeext conftest.$ac_ext
+LIBS=$ac_check_lib_save_LIBS
+fi
+{ $as_echo "$as_me:${as_lineno-$LINENO}: result: $ac_cv_lib_curl_curl_multi_init" >&5
+$as_echo "$ac_cv_lib_curl_curl_multi_init" >&6; }
+if test "x$ac_cv_lib_curl_curl_multi_init" = xyes; then :
+  cat >>confdefs.h <<_ACEOF
+#define HAVE_LIBCURL 1
+_ACEOF
+
+  LIBS="-lcurl $LIBS"
+
+else
+  as_fn_error $? "library 'curl' does not provide curl_multi_init" "$LINENO" 5
+fi
+
+
+  # Check to see whether the current platform supports threadsafe Curl
+  # initialization.
+  { $as_echo "$as_me:${as_lineno-$LINENO}: checking for curl_global_init thread safety" >&5
+$as_echo_n "checking for curl_global_init thread safety... " >&6; }
+if ${pgac_cv__libcurl_threadsafe_init+:} false; then :
+  $as_echo_n "(cached) " >&6
+else
+  if test "$cross_compiling" = yes; then :
+  pgac_cv__libcurl_threadsafe_init=unknown
+else
+  cat confdefs.h - <<_ACEOF >conftest.$ac_ext
+/* end confdefs.h.  */
+
+#include <curl/curl.h>
+
+int
+main ()
+{
+
+    curl_version_info_data *info;
+
+    if (curl_global_init(CURL_GLOBAL_ALL))
+        return -1;
+
+    info = curl_version_info(CURLVERSION_NOW);
+#ifdef CURL_VERSION_THREADSAFE
+    if (info->features & CURL_VERSION_THREADSAFE)
+        return 0;
+#endif
+
+    return 1;
+
+  ;
+  return 0;
+}
+_ACEOF
+if ac_fn_c_try_run "$LINENO"; then :
+  pgac_cv__libcurl_threadsafe_init=yes
+else
+  pgac_cv__libcurl_threadsafe_init=no
+fi
+rm -f core *.core core.conftest.* gmon.out bb.out conftest$ac_exeext \
+  conftest.$ac_objext conftest.beam conftest.$ac_ext
+fi
+
+fi
+{ $as_echo "$as_me:${as_lineno-$LINENO}: result: $pgac_cv__libcurl_threadsafe_init" >&5
+$as_echo "$pgac_cv__libcurl_threadsafe_init" >&6; }
+  if test x"$pgac_cv__libcurl_threadsafe_init" = xyes ; then
+
+$as_echo "#define HAVE_THREADSAFE_CURL_GLOBAL_INIT 1" >>confdefs.h
+
+  fi
+
+fi
+
 if test "$with_gssapi" = yes ; then
   if test "$PORTNAME" != "win32"; then
     { $as_echo "$as_me:${as_lineno-$LINENO}: checking for library containing gss_store_cred_into" >&5
diff --git a/configure.ac b/configure.ac
index f56681e0d9..b6d02f5ecc 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1007,6 +1007,40 @@ fi
 AC_SUBST(with_uuid)
 
 
+#
+# libcurl
+#
+AC_MSG_CHECKING([whether to build with libcurl support])
+PGAC_ARG_BOOL(with, libcurl, no, [build with libcurl support],
+              [AC_DEFINE([USE_LIBCURL], 1, [Define to 1 to build with libcurl support. (--with-libcurl)])])
+AC_MSG_RESULT([$with_libcurl])
+AC_SUBST(with_libcurl)
+
+if test "$with_libcurl" = yes ; then
+  # Check for libcurl 7.61.0 or higher (corresponding to RHEL8 and the ability
+  # to explicitly set TLS 1.3 ciphersuites).
+  PKG_CHECK_MODULES(LIBCURL, [libcurl >= 7.61.0])
+
+  # We only care about -I, -D, and -L switches;
+  # note that -lcurl will be added by PGAC_CHECK_LIBCURL below.
+  for pgac_option in $LIBCURL_CFLAGS; do
+    case $pgac_option in
+      -I*|-D*) CPPFLAGS="$CPPFLAGS $pgac_option";;
+    esac
+  done
+  for pgac_option in $LIBCURL_LIBS; do
+    case $pgac_option in
+      -L*) LDFLAGS="$LDFLAGS $pgac_option";;
+    esac
+  done
+
+  # OAuth requires python for testing
+  if test "$with_python" != yes; then
+    AC_MSG_WARN([*** OAuth support tests require --with-python to run])
+  fi
+fi
+
+
 #
 # XML
 #
@@ -1294,6 +1328,13 @@ failure.  It is possible the compiler isn't looking in the proper directory.
 Use --without-zlib to disable zlib support.])])
 fi
 
+# XXX libcurl must link after libgssapi_krb5 on FreeBSD to avoid segfaults
+# during gss_acquire_cred(). This is possibly related to Curl's Heimdal
+# dependency on that platform?
+if test "$with_libcurl" = yes ; then
+  PGAC_CHECK_LIBCURL
+fi
+
 if test "$with_gssapi" = yes ; then
   if test "$PORTNAME" != "win32"; then
     AC_SEARCH_LIBS(gss_store_cred_into, [gssapi_krb5 gss 'gssapi -lkrb5 -lcrypto'], [],
diff --git a/doc/src/sgml/client-auth.sgml b/doc/src/sgml/client-auth.sgml
index 782b49c85a..f84085dbac 100644
--- a/doc/src/sgml/client-auth.sgml
+++ b/doc/src/sgml/client-auth.sgml
@@ -656,6 +656,16 @@ include_dir         <replaceable>directory</replaceable>
          </para>
         </listitem>
        </varlistentry>
+
+       <varlistentry>
+        <term><literal>oauth</literal></term>
+        <listitem>
+         <para>
+          Authorize and optionally authenticate using a third-party OAuth 2.0
+          identity provider. See <xref linkend="auth-oauth"/> for details.
+         </para>
+        </listitem>
+       </varlistentry>
       </variablelist>
 
       </para>
@@ -1143,6 +1153,12 @@ omicron         bryanh                  guest1
       only on OpenBSD).
      </para>
     </listitem>
+    <listitem>
+     <para>
+      <link linkend="auth-oauth">OAuth authorization/authentication</link>,
+      which relies on an external OAuth 2.0 identity provider.
+     </para>
+    </listitem>
    </itemizedlist>
   </para>
 
@@ -2329,6 +2345,242 @@ host ... radius radiusservers="server1,server2" radiussecrets="""secret one"",""
    </note>
   </sect1>
 
+  <sect1 id="auth-oauth">
+   <title>OAuth Authorization/Authentication</title>
+
+   <indexterm zone="auth-oauth">
+    <primary>OAuth Authorization/Authentication</primary>
+   </indexterm>
+
+   <para>
+    OAuth 2.0 is an industry-standard framework, defined in
+    <ulink url="https://datatracker.ietf.org/doc/html/rfc6749">RFC 6749</ulink>,
+    to enable third-party applications to obtain limited access to a protected
+    resource.
+
+    OAuth client support has to be enabled when <productname>PostgreSQL</productname>
+    is built, see <xref linkend="installation"/> for more information.
+   </para>
+
+   <para>
+    This documentation uses the following terminology when discussing the OAuth
+    ecosystem:
+
+    <variablelist>
+
+     <varlistentry>
+      <term>Resource Owner (or End User)</term>
+      <listitem>
+       <para>
+        The user or system who owns protected resources and can grant access to
+        them. This documentation also uses the term <emphasis>end user</emphasis>
+        when the resource owner is a person. When you use
+        <application>psql</application> to connect to the database using OAuth,
+        you are the resource owner/end user.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term>Client</term>
+      <listitem>
+       <para>
+        The system which accesses the protected resources using access
+        tokens. Applications using libpq, such as <application>psql</application>,
+        are the OAuth clients when connecting to a
+        <productname>PostgreSQL</productname> cluster.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term>Resource Server</term>
+      <listitem>
+       <para>
+        The system which hosts the protected resources which are
+        accessed by the client. The <productname>PostgreSQL</productname>
+        cluster being connected to is the resource server.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term>Provider</term>
+      <listitem>
+       <para>
+        The organization, product vendor, or other entity which develops and/or
+        administers the OAuth servers and clients for a given application.
+        Different providers typically choose different implementation details
+        for their OAuth systems; a client of one provider is not generally
+        guaranteed to have access to the servers of another.
+       </para>
+       <para>
+        This use of the term "provider" is not standard, but it seems to be in
+        wide use colloquially. (It should not be confused with OpenID's similar
+        term "Identity Provider". While the implementation of OAuth in
+        <productname>PostgreSQL</productname> is intended to be interoperable
+        and compatible with OpenID Connect/OIDC, it is not itself an OIDC client
+        and does not require its use.)
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term>Authorization Server</term>
+      <listitem>
+       <para>
+        The system which receives requests from, and issues access tokens to,
+        the client after the authenticated resource owner has given approval.
+        <productname>PostgreSQL</productname> does not provide an authorization
+        server; it's obtained from the OAuth provider.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term id="auth-oauth-issuer">Issuer</term>
+      <listitem>
+       <para>
+        An identifier for an authorization server, printed as an
+        <literal>https://</literal> URL, which provides a trusted "namespace"
+        for OAuth clients and applications. The issuer identifier allows a
+        single authorization server to talk to the clients of mutually
+        untrusting entities, as long as they maintain separate issuers.
+       </para>
+      </listitem>
+     </varlistentry>
+
+    </variablelist>
+
+    <note>
+     <para>
+      For small deployments, there may not be a meaningful distinction between
+      the "provider", "authorization server", and "issuer". However, for more
+      complicated setups, there may be a one-to-many (or many-to-many)
+      relationship: a provider may rent out multiple issuer identifiers to
+      separate tenants, then provide multiple authorization servers, possibly
+      with different supported feature sets, to interact with their clients.
+     </para>
+    </note>
+   </para>
+
+   <para>
+    <productname>PostgreSQL</productname> supports bearer tokens, defined in
+    <ulink url="https://datatracker.ietf.org/doc/html/rfc6750">RFC 6750</ulink>,
+    which are a type of access token used with OAuth 2.0 where the token is an
+    opaque string.  The format of the access token is implementation specific
+    and is chosen by each authorization server.
+   </para>
+
+   <para>
+    The following configuration options are supported for OAuth:
+    <variablelist>
+     <varlistentry>
+      <term><literal>issuer</literal></term>
+      <listitem>
+       <para>
+        An HTTPS URL which is either the exact
+        <link linkend="auth-oauth-issuer">issuer identifier</link> of the
+        authorization server, as defined by its discovery document, or a
+        well-known URI that points directly to that discovery document. This
+        parameter is required.
+       </para>
+       <para>
+        When an OAuth client connects to the server, a URL for the discovery
+        document will be constructed using the issuer identifier. By default,
+        this URL uses the conventions of OpenID Connect Discovery: the path
+        <literal>/.well-known/openid-configuration</literal> will be appended
+        to the end of the issuer identifier. Alternatively, if the
+        <literal>issuer</literal> contains a <literal>/.well-known/</literal>
+        path segment, that URL will be provided to the client as-is.
+       </para>
+       <warning>
+        <para>
+         The OAuth client in libpq requires the server's issuer setting to
+         exactly match the issuer identifier which is provided in the discovery
+         document, which must in turn match the client's
+         <xref linkend="libpq-connect-oauth-issuer"/> setting. No variations in
+         case or format are permitted.
+        </para>
+       </warning>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term><literal>scope</literal></term>
+      <listitem>
+       <para>
+        A space-separated list of the OAuth scopes needed for the server to
+        both authorize the client and authenticate the user.  Appropriate values
+        are determined by the authorization server and the OAuth validation
+        module used (see <xref linkend="oauth-validators" /> for more
+        information on validators).  This parameter is required.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term><literal>validator</literal></term>
+      <listitem>
+       <para>
+        The library to use for validating bearer tokens. If given, the name must
+        exactly match one of the libraries listed in
+        <xref linkend="guc-oauth-validator-libraries" />.  This parameter is
+        optional unless <literal>oauth_validator_libraries</literal> contains
+        more than one library, in which case it is required.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term><literal>map</literal></term>
+      <listitem>
+       <para>
+        Allows for mapping between OAuth identity provider and database user
+        names.  See <xref linkend="auth-username-maps"/> for details.  If a
+        map is not specified, the user name associated with the token (as
+        determined by the OAuth validator) must exactly match the role name
+        being requested.  This parameter is optional.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term id="auth-oauth-delegate-ident-mapping" xreflabel="delegate_ident_mapping">
+       <literal>delegate_ident_mapping</literal>
+      </term>
+      <listitem>
+       <para>
+        An advanced option which is not intended for common use.
+       </para>
+       <para>
+        When set to <literal>1</literal>, standard user mapping with
+        <filename>pg_ident.conf</filename> is skipped, and the OAuth validator
+        takes full responsibility for mapping end user identities to database
+        roles.  If the validator authorizes the token, the server trusts that
+        the user is allowed to connect under the requested role, and the
+        connection is allowed to proceed regardless of the authentication
+        status of the user.
+       </para>
+       <para>
+        This parameter is incompatible with <literal>map</literal>.
+       </para>
+       <warning>
+        <para>
+         <literal>delegate_ident_mapping</literal> provides additional
+         flexibility in the design of the authentication system, but it also
+         requires careful implementation of the OAuth validator, which must
+         determine whether the provided token carries sufficient end-user
+         privileges in addition to the <link linkend="oauth-validators">standard
+         checks</link> required of all validators.  Use with caution.
+        </para>
+       </warning>
+      </listitem>
+     </varlistentry>
+    </variablelist>
+   </para>
+  </sect1>
+
   <sect1 id="client-authentication-problems">
    <title>Authentication Problems</title>
 
diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml
index 38244409e3..d53595f895 100644
--- a/doc/src/sgml/config.sgml
+++ b/doc/src/sgml/config.sgml
@@ -1209,6 +1209,32 @@ include_dir 'conf.d'
        </para>
       </listitem>
      </varlistentry>
+
+     <varlistentry id="guc-oauth-validator-libraries" xreflabel="oauth_validator_libraries">
+      <term><varname>oauth_validator_libraries</varname> (<type>string</type>)
+      <indexterm>
+       <primary><varname>oauth_validator_libraries</varname> configuration parameter</primary>
+      </indexterm>
+      </term>
+      <listitem>
+       <para>
+        The library/libraries to use for validating OAuth connection tokens. If
+        only one validator library is provided, it will be used by default for
+        any OAuth connections; otherwise, all
+        <link linkend="auth-oauth"><literal>oauth</literal> HBA entries</link>
+        must explicitly set a <literal>validator</literal> chosen from this
+        list. If set to an empty string (the default), OAuth connections will be
+        refused. This parameter can only be set in the
+        <filename>postgresql.conf</filename> file.
+       </para>
+       <para>
+        Validator modules must be implemented/obtained separately;
+        <productname>PostgreSQL</productname> does not ship with any default
+        implementations. For more information on implementing OAuth validators,
+        see <xref linkend="oauth-validators" />.
+       </para>
+      </listitem>
+     </varlistentry>
      </variablelist>
      </sect2>
 
diff --git a/doc/src/sgml/filelist.sgml b/doc/src/sgml/filelist.sgml
index 66e6dccd4c..25fb99cee6 100644
--- a/doc/src/sgml/filelist.sgml
+++ b/doc/src/sgml/filelist.sgml
@@ -111,6 +111,7 @@
 <!ENTITY generic-wal SYSTEM "generic-wal.sgml">
 <!ENTITY custom-rmgr SYSTEM "custom-rmgr.sgml">
 <!ENTITY backup-manifest SYSTEM "backup-manifest.sgml">
+<!ENTITY oauth-validators SYSTEM "oauth-validators.sgml">
 
 <!-- contrib information -->
 <!ENTITY contrib         SYSTEM "contrib.sgml">
diff --git a/doc/src/sgml/installation.sgml b/doc/src/sgml/installation.sgml
index 3f0a7e9c06..96e433179b 100644
--- a/doc/src/sgml/installation.sgml
+++ b/doc/src/sgml/installation.sgml
@@ -1143,6 +1143,19 @@ build-postgresql:
        </listitem>
       </varlistentry>
 
+      <varlistentry id="configure-option-with-libcurl">
+       <term><option>--with-libcurl</option></term>
+       <listitem>
+        <para>
+         Build with libcurl support for OAuth 2.0 client flows.
+         This requires the <productname>curl</productname> package to be
+         installed.  Building with this will check for the required header files
+         and libraries to make sure that your <productname>curl</productname>
+         installation is sufficient before proceeding.
+        </para>
+       </listitem>
+      </varlistentry>
+
       <varlistentry id="configure-option-with-libxml">
        <term><option>--with-libxml</option></term>
        <listitem>
@@ -2584,6 +2597,20 @@ ninja install
       </listitem>
      </varlistentry>
 
+     <varlistentry id="configure-with-libcurl-meson">
+      <term><option>-Dlibcurl={ auto | enabled | disabled }</option></term>
+      <listitem>
+       <para>
+        Build with libcurl support for OAuth 2.0 client flows.
+        This requires the <productname>curl</productname> package to be
+        installed.  Building with this will check for the required header files
+        and libraries to make sure that your <productname>curl</productname>
+        installation is sufficient before proceeding. The default for this
+        option is auto.
+       </para>
+      </listitem>
+     </varlistentry>
+
      <varlistentry id="configure-with-libxml-meson">
       <term><option>-Dlibxml={ auto | enabled | disabled }</option></term>
       <listitem>
diff --git a/doc/src/sgml/libpq.sgml b/doc/src/sgml/libpq.sgml
index e04acf1c20..9a69ffbc5b 100644
--- a/doc/src/sgml/libpq.sgml
+++ b/doc/src/sgml/libpq.sgml
@@ -1385,6 +1385,15 @@ postgresql://%2Fvar%2Flib%2Fpostgresql/dbname
           </listitem>
          </varlistentry>
 
+         <varlistentry>
+          <term><literal>oauth</literal></term>
+          <listitem>
+           <para>
+            The server must request an OAuth bearer token from the client.
+           </para>
+          </listitem>
+         </varlistentry>
+
          <varlistentry>
           <term><literal>none</literal></term>
           <listitem>
@@ -2373,6 +2382,106 @@ postgresql://%2Fvar%2Flib%2Fpostgresql/dbname
        </para>
       </listitem>
      </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-issuer" xreflabel="oauth_issuer">
+      <term><literal>oauth_issuer</literal></term>
+      <listitem>
+       <para>
+        The HTTPS URL of a trusted issuer to contact if the server requests an
+        OAuth token for the connection. This parameter is required for all OAuth
+        connections; it should exactly match the <literal>issuer</literal>
+        setting in <link linkend="auth-oauth">the server's HBA configuration.</link>
+       </para>
+       <para>
+        As part of the standard authentication handshake, <application>libpq</application>
+        will ask the server for a <emphasis>discovery document:</emphasis> a URL
+        providing a set of OAuth configuration parameters. The server must
+        provide a URL that is directly constructed from the components of the
+        <literal>oauth_issuer</literal>, and this value must exactly match the
+        issuer identifier that is declared in the discovery document itself, or
+        the connection will fail. This is required to prevent a class of "mix-up
+        attacks" on OAuth clients.
+       </para>
+       <para>
+        You may also explicitly set <literal>oauth_issuer</literal> to the
+        <literal>/.well-known/</literal> URI used for OAuth discovery. In this
+        case, if the server asks for a different URL, the connection will fail,
+        but a <link linkend="libpq-oauth-authdata-hooks">custom OAuth flow</link>
+        may be able to speed up the standard handshake by using previously
+        cached tokens. (In this case, it is recommended that
+        <xref linkend="libpq-connect-oauth-scope"/> be set as well, since the
+        client will not have a chance to ask the server for a correct scope
+        setting, and the default scopes for a token may not be sufficient to
+        connect.) <application>libpq</application> currently supports the
+        following well-known endpoints:
+        <itemizedlist spacing="compact">
+         <listitem><para><literal>/.well-known/openid-configuration</literal></para></listitem>
+         <listitem><para><literal>/.well-known/oauth-authorization-server</literal></para></listitem>
+        </itemizedlist>
+       </para>
+       <warning>
+        <para>
+         Issuers are highly privileged during the OAuth connection handshake. As
+         a rule of thumb, if you would not trust the operator of a URL to handle
+         access to your servers, or to impersonate you directly, that URL should
+         not be trusted as an <literal>oauth_issuer</literal>.
+        </para>
+       </warning>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-client-id" xreflabel="oauth_client_id">
+      <term><literal>oauth_client_id</literal></term>
+      <listitem>
+       <para>
+        An OAuth 2.0 client identifier, as issued by the authorization server.
+        If the <productname>PostgreSQL</productname> server
+        <link linkend="auth-oauth">requests an OAuth token</link> for the
+        connection (and if no <link linkend="libpq-oauth-authdata-hooks">custom
+        OAuth hook</link> is installed to provide one), then this parameter must
+        be set; otherwise, the connection will fail.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-client-secret" xreflabel="oauth_client_secret">
+      <term><literal>oauth_client_secret</literal></term>
+      <listitem>
+       <para>
+        The client password, if any, to use when contacting the OAuth
+        authorization server. Whether this parameter is required or not is
+        determined by the OAuth provider; "public" clients generally do not use
+        a secret, whereas "confidential" clients generally do.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-scope" xreflabel="oauth_scope">
+      <term><literal>oauth_scope</literal></term>
+      <listitem>
+       <para>
+        The scope of the access request sent to the authorization server,
+        specified as a (possibly empty) space-separated list of OAuth scope
+        identifiers. This parameter is optional and intended for advanced usage.
+       </para>
+       <para>
+        Usually the client will obtain appropriate scope settings from the
+        <productname>PostgreSQL</productname> server. If this parameter is used,
+        the server's requested scope list will be ignored. This can prevent a
+        less-trusted server from requesting inappropriate access scopes from the
+        end user. However, if the client's scope setting does not contain the
+        server's required scopes, the server is likely to reject the issued
+        token, and the connection will fail.
+       </para>
+       <para>
+        The meaning of an empty scope list is provider-dependent. An OAuth
+        authorization server may choose to issue a token with "default scope",
+        whatever that happens to be, or it may reject the token request
+        entirely.
+       </para>
+      </listitem>
+     </varlistentry>
+
     </variablelist>
    </para>
   </sect2>
@@ -10020,6 +10129,278 @@ void PQinitSSL(int do_ssl);
 
  </sect1>
 
+ <sect1 id="libpq-oauth">
+  <title>OAuth Support</title>
+
+  <para>
+   TODO
+  </para>
+
+  <sect2 id="libpq-oauth-authdata-hooks">
+   <title>Authdata Hooks</title>
+
+   <para>
+    The behavior of the OAuth flow may be modified or replaced by a client using
+    the following hook API:
+
+    <variablelist>
+     <varlistentry id="libpq-PQsetAuthDataHook">
+      <term><function>PQsetAuthDataHook</function><indexterm><primary>PQsetAuthDataHook</primary></indexterm></term>
+
+      <listitem>
+       <para>
+        Sets the <symbol>PGauthDataHook</symbol>, overriding
+        <application>libpq</application>'s handling of one or more aspects of
+        its OAuth client flow.
+<synopsis>
+void PQsetAuthDataHook(PQauthDataHook_type hook);
+</synopsis>
+        If <replaceable>hook</replaceable> is <literal>NULL</literal>, the
+        default handler will be reinstalled. Otherwise, the application passes
+        a pointer to a callback function with the signature:
+<programlisting>
+int hook_fn(PGauthData type, PGconn *conn, void *data);
+</programlisting>
+        which <application>libpq</application> will call when when action is
+        required of the application. <replaceable>type</replaceable> describes
+        the request being made, <replaceable>conn</replaceable> is the
+        connection handle being authenticated, and <replaceable>data</replaceable>
+        points to request-specific metadata. The contents of this pointer are
+        determined by <replaceable>type</replaceable>; see
+        <xref linkend="libpq-oauth-authdata-hooks-types"/> for the supported
+        list.
+       </para>
+       <para>
+        Hooks can be chained together to allow cooperative and/or fallback
+        behavior. In general, a hook implementation should examine the incoming
+        <replaceable>type</replaceable> (and, potentially, the request metadata
+        and/or the settings for the particular <replaceable>conn</replaceable>
+        in use) to decide whether or not to handle a specific piece of authdata.
+        If not, it should delegate to the previous hook in the chain
+        (retrievable via <function>PQgetAuthDataHook</function>).
+       </para>
+       <para>
+        Success is indicated by returning an integer greater than zero.
+        Returning a negative integer signals an error condition and abandons the
+        connection attempt. (A zero value is reserved for the default
+        implementation.)
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="libpq-PQgetAuthDataHook">
+      <term><function>PQgetAuthDataHook</function><indexterm><primary>PQgetAuthDataHook</primary></indexterm></term>
+
+      <listitem>
+       <para>
+        Retrieves the current value of <symbol>PGauthDataHook</symbol>.
+<synopsis>
+PQauthDataHook_type PQgetAuthDataHook(void);
+</synopsis>
+        At initialization time (before the first call to
+        <function>PQsetAuthDataHook</function>), this function will return
+        <symbol>PQdefaultAuthDataHook</symbol>.
+       </para>
+      </listitem>
+     </varlistentry>
+    </variablelist>
+   </para>
+
+   <sect3 id="libpq-oauth-authdata-hooks-types">
+    <title>Hook Types</title>
+    <para>
+     The following <symbol>PGauthData</symbol> types and their corresponding
+     <replaceable>data</replaceable> structures are defined:
+
+     <variablelist>
+      <varlistentry id="libpq-oauth-authdata-prompt-oauth-device">
+       <term>
+        <symbol>PQAUTHDATA_PROMPT_OAUTH_DEVICE</symbol>
+        <indexterm><primary>PQAUTHDATA_PROMPT_OAUTH_DEVICE</primary></indexterm>
+       </term>
+       <listitem>
+        <para>
+         Replaces the default user prompt during the builtin device
+         authorization client flow. <replaceable>data</replaceable> points to
+         an instance of <symbol>PGpromptOAuthDevice</symbol>:
+<synopsis>
+typedef struct _PGpromptOAuthDevice
+{
+    const char *verification_uri;   /* verification URI to visit */
+    const char *user_code;          /* user code to enter */
+} PGpromptOAuthDevice;
+</synopsis>
+        </para>
+        <para>
+         The OAuth Device Authorization flow included in <application>libpq</application>
+         requires the end user to visit a URL with a browser, then enter a code
+         which permits <application>libpq</application> to connect to the server
+         on their behalf. The default prompt simply prints the
+         <literal>verification_uri</literal> and <literal>user_code</literal>
+         on standard error. Replacement implementations may display this
+         information using any preferred method, for example with a GUI.
+        </para>
+        <para>
+         This callback is only invoked during the builtin device
+         authorization flow. If the application installs a
+         <link linkend="libpq-oauth-authdata-oauth-bearer-token">custom OAuth
+         flow</link>, this authdata type will not be used.
+        </para>
+       </listitem>
+      </varlistentry>
+
+      <varlistentry id="libpq-oauth-authdata-oauth-bearer-token">
+       <term>
+        <symbol>PQAUTHDATA_OAUTH_BEARER_TOKEN</symbol>
+        <indexterm><primary>PQAUTHDATA_OAUTH_BEARER_TOKEN</primary></indexterm>
+       </term>
+       <listitem>
+        <para>
+         Replaces the entire OAuth flow with a custom implementation. The hook
+         should either directly return a Bearer token for the current
+         user/issuer/scope combination, if one is available without blocking, or
+         else set up an asynchronous callback to retrieve one.
+        </para>
+        <para>
+         <replaceable>data</replaceable> points to an instance
+         of <symbol>PGoauthBearerRequest</symbol>, which should be filled in
+         by the implementation:
+<synopsis>
+typedef struct _PGoauthBearerRequest
+{
+    /* Hook inputs (constant across all calls) */
+    const char *const openid_configuration; /* OIDC discovery URL */
+    const char *const scope;                /* required scope(s), or NULL */
+
+    /* Hook outputs */
+
+    /* Callback implementing a custom asynchronous OAuth flow. */
+    PostgresPollingStatusType (*async) (PGconn *conn,
+                                        struct _PGoauthBearerRequest *request,
+                                        SOCKTYPE *altsock);
+
+    /* Callback to clean up custom allocations. */
+    void        (*cleanup) (PGconn *conn, struct _PGoauthBearerRequest *request);
+
+    char       *token;   /* acquired Bearer token */
+    void       *user;    /* hook-defined allocated data */
+} PGoauthBearerRequest;
+</synopsis>
+        </para>
+        <para>
+         Two pieces of information are provided to the hook by
+         <application>libpq</application>:
+         <replaceable>openid_configuration</replaceable> contains the URL of an
+         OAuth discovery document describing the authorization server's
+         supported flows, and <replaceable>scope</replaceable> contains a
+         (possibly empty) space-separated list of OAuth scopes which are
+         required to access the server. Either or both may be
+         <literal>NULL</literal> to indicate that the information was not
+         discoverable. (In this case, implementations may be able to establish
+         the requirements using some other preconfigured knowledge, or they may
+         choose to fail.)
+        </para>
+        <para>
+         The final output of the hook is <replaceable>token</replaceable>, which
+         must point to a valid Bearer token for use on the connection. (This
+         token should be issued by the
+         <xref linkend="libpq-connect-oauth-issuer"/> and hold the requested
+         scopes, or the connection will be rejected by the server's validator
+         module.) The allocated token string must remain valid until
+         <application>libpq</application> is finished connecting; the hook
+         should set a <replaceable>cleanup</replaceable> callback which will be
+         called when <application>libpq</application> no longer requires it.
+        </para>
+        <para>
+         If an implementation cannot immediately produce a
+         <replaceable>token</replaceable> during the initial call to the hook,
+         it should set the <replaceable>async</replaceable> callback to handle
+         nonblocking communication with the authorization server.
+         <footnote>
+          <para>
+           Performing blocking operations during the
+           <symbol>PQAUTHDATA_OAUTH_BEARER_TOKEN</symbol> hook callback will
+           interfere with nonblocking connection APIs such as
+           <function>PQconnectPoll</function> and prevent concurrent connections
+           from making progress. Applications which only ever use the
+           synchronous connection primitives, such as
+           <function>PQconnectdb</function>, may synchronously retrieve a token
+           during the hook instead of implementing the
+           <replaceable>async</replaceable> callback, but they will necessarily
+           be limited to one connection at a time.
+          </para>
+         </footnote>
+         This will be called to begin the flow immediately upon return from the
+         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
+         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>
+         after setting <replaceable>token</replaceable> when the flow is
+         complete, or <symbol>PGRES_POLLING_FAILED</symbol> to indicate failure.
+        </para>
+        <para>
+         Implementations may wish to store additional data for bookkeeping
+         across calls to the <replaceable>async</replaceable> and
+         <replaceable>cleanup</replaceable> callbacks. The
+         <replaceable>user</replaceable> pointer is provided for this purpose;
+         <application>libpq</application> will not touch its contents and the
+         application may use it at its convenience. (Remember to free any
+         allocations during token cleanup.)
+        </para>
+       </listitem>
+      </varlistentry>
+     </variablelist>
+    </para>
+   </sect3>
+  </sect2>
+
+  <sect2 id="libpq-oauth-debugging">
+   <title>Debugging and Developer Settings</title>
+
+   <para>
+    A "dangerous debugging mode" may be enabled by setting the environment
+    variable <envar>PGOAUTHDEBUG=UNSAFE</envar>. This functionality is provided
+    for ease of local development and testing only. It does several things that
+    you will not want a production system to do:
+
+    <itemizedlist spacing="compact">
+     <listitem>
+      <para>
+       permits the use of unencrypted HTTP during the OAuth provider exchange
+      </para>
+     </listitem>
+     <listitem>
+      <para>
+       allows the system's trusted CA list to be completely replaced using the
+       <envar>PGOAUTHCAFILE</envar> environment variable
+      </para>
+     </listitem>
+     <listitem>
+      <para>
+       sprays HTTP traffic (containing several critical secrets) to standard
+       error during the OAuth flow
+      </para>
+     </listitem>
+     <listitem>
+      <para>
+       permits the use of zero-second retry intervals, which can cause the
+       client to busy-loop and pointlessly consume CPU
+      </para>
+     </listitem>
+    </itemizedlist>
+   </para>
+   <warning>
+    <para>
+     Do not share the output of the OAuth flow traffic with third parties. It
+     contains secrets that can be used to attack your clients and servers.
+    </para>
+   </warning>
+  </sect2>
+ </sect1>
+
 
  <sect1 id="libpq-threading">
   <title>Behavior in Threaded Programs</title>
@@ -10092,6 +10473,18 @@ int PQisthreadsafe();
    <application>libpq</application> source code for a way to do cooperative
    locking between <application>libpq</application> and your application.
   </para>
+
+  <para>
+   Similarly, if you are using Curl inside your application,
+   <emphasis>and</emphasis> you do not already
+   <ulink url="https://curl.se/libcurl/c/curl_global_init.html">initialize
+   libcurl globally</ulink> before starting new threads, you will need to
+   cooperatively lock (again via <function>PQregisterThreadLock</function>)
+   around any code that may initialize libcurl. This restriction is lifted for
+   more recent versions of Curl that are built to support threadsafe
+   initialization; those builds can be identified by the advertisement of a
+   <literal>threadsafe</literal> feature in their version metadata.
+  </para>
  </sect1>
 
 
diff --git a/doc/src/sgml/oauth-validators.sgml b/doc/src/sgml/oauth-validators.sgml
new file mode 100644
index 0000000000..d0bca9196d
--- /dev/null
+++ b/doc/src/sgml/oauth-validators.sgml
@@ -0,0 +1,402 @@
+<!-- doc/src/sgml/oauth-validators.sgml -->
+
+<chapter id="oauth-validators">
+ <title>OAuth Validator Modules</title>
+ <indexterm zone="oauth-validators">
+  <primary>OAuth Validators</primary>
+ </indexterm>
+ <para>
+  <productname>PostgreSQL</productname> provides infrastructure for creating
+  custom modules to perform server-side validation of OAuth bearer tokens.
+  Because OAuth implementations vary so wildly, and bearer token validation is
+  heavily dependent on the issuing party, the server cannot check the token
+  itself; validator modules provide the glue between the server and the OAuth
+  provider in use.
+ </para>
+ <para>
+  OAuth validator modules must at least consist of an initialization function
+  (see <xref linkend="oauth-validator-init"/>) and the required callback for
+  performing validation (see <xref linkend="oauth-validator-callback-validate"/>).
+ </para>
+ <warning>
+  <para>
+   Since a misbehaving validator might let unauthorized users into the database,
+   correct implementation is critical. See
+   <xref linkend="oauth-validator-design"/> for design considerations.
+  </para>
+ </warning>
+
+ <sect1 id="oauth-validator-design">
+  <title>Safely Designing a Validator Module</title>
+  <warning>
+   <para>
+    Read and understand the entirety of this section before implementing a
+    validator module. A malfunctioning validator is potentially worse than no
+    authentication at all, both because of the false sense of security it
+    provides, and because it may contribute to attacks against other pieces of
+    an OAuth ecosystem.
+   </para>
+  </warning>
+
+  <sect2 id="oauth-validator-design-responsibilities">
+   <title>Validator Responsibilities</title>
+   <para>
+    TODO
+   </para>
+   <variablelist>
+    <varlistentry>
+     <term>Validate the Token</term>
+     <listitem>
+      <para>
+       The validator must first ensure that the presented token is in fact a
+       valid Bearer token for use in client authentication. The correct way to
+       do this depends on the provider, but it generally involves either
+       cryptographic operations to prove that the token was created by a trusted
+       party (offline validation), or the presentation of the token to that
+       trusted party so that it can perform validation for you (online
+       validation).
+      </para>
+      <para>
+       Online validation, usually implemented via
+       <ulink url="https://datatracker.ietf.org/doc/html/rfc7662">OAuth Token
+       Introspection</ulink>, requires fewer steps of a validator module and
+       allows central revocation of a token in the event that it is stolen
+       or misissued. However, it does require the module to make at least one
+       network call per authentication attempt (all of which must complete
+       within the configured <xref linkend="guc-authentication-timeout"/>).
+       Additionally, your provider may not provide introspection endpoints for
+       use by external resource servers.
+      </para>
+      <para>
+       Offline validation is much more involved, typically requiring a validator
+       to maintain a list of trusted signing keys for a provider and then
+       check the token's cryptographic signature along with its contents.
+       Implementations must follow the provider's instructions to the letter,
+       including any verification of issuer ("where is this token from?"),
+       audience ("who is this token for?"), and validity period ("when can this
+       token be used?"). Since there is no communication between the module and
+       the provider, tokens cannot be centrally revoked using this method;
+       offline validator implementations may wish to place restrictions on the
+       maximum length of a token's validity period.
+      </para>
+      <para>
+       If the token cannot be validated, the module should immediately fail.
+       Further authentication/authorization is pointless if the bearer token
+       wasn't issued by a trusted party.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Authorize the Client</term>
+     <listitem>
+      <para>
+       Next the validator must ensure that the end user has given the client
+       permission to access the server on their behalf. This generally involves
+       checking the scopes that have been assigned to the token, to make sure
+       that they cover database access for the current HBA parameters.
+      </para>
+      <para>
+       The purpose of this step is to prevent an OAuth client from obtaining a
+       token under false pretenses. If the validator requires all tokens to
+       carry scopes that cover database access, the provider should then loudly
+       prompt the user to grant that access during the flow. This gives them the
+       opportunity to reject the request if the client isn't supposed to be
+       using their credentials to connect to databases.
+      </para>
+      <para>
+       While it is possible to establish client authorization without explicit
+       scopes by using out-of-band knowledge of the deployed architecture, doing
+       so removes the user from the loop, which prevents them from catching
+       deployment mistakes and allows any such mistakes to be exploited
+       silently. Access to the database must be tightly restricted to only
+       trusted clients
+       <footnote>
+        <para>
+         That is, "trusted" in the sense that the OAuth client and the
+         <productname>PostgreSQL</productname> server are controlled by the same
+         entity. Notably, the Device Authorization client flow supported by
+         libpq does not usually meet this bar, since it's designed for use by
+         public/untrusted clients.
+        </para>
+       </footnote>
+       if users are not prompted for additional scopes.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Authenticate the End User</term>
+     <listitem>
+      <para>
+       Finally, the validator should determine a user identifier for the token,
+       either by asking the provider for this information or by extracting it
+       from the token itself, and return that identifier to the server (which
+       will then make a final authorization decision using the HBA
+       configuration). This identifier will be available within the session via
+       <link linkend="functions-info-session-table"><function>system_user</function></link>
+       and recorded in the server logs if <xref linkend="guc-log-connections"/>
+       is enabled.
+      </para>
+      <para>
+       Different providers may record a variety of different authentication
+       information for an end user, typically referred to as
+       <emphasis>claims</emphasis>. Providers usually document which of these
+       claims are trustworthy enough to use for authorization decisions and
+       which are not. (For instance, it would probably not be wise to use an
+       end user's full name as the identifier for authentication, since many
+       providers allow users to change their display names arbitrarily.)
+       Ultimately, the choice of which claim (or combination of claims) to use
+       comes down to the provider implementation and application requirements.
+      </para>
+      <para>
+       Note that anonymous/pseudonymous login is possible as well, by enabling
+       usermap delegation; see
+       <xref linkend="oauth-validator-design-usermap-delegation"/>.
+      </para>
+     </listitem>
+    </varlistentry>
+   </variablelist>
+  </sect2>
+
+  <sect2 id="oauth-validator-design-guidelines">
+   <title>General Coding Guidelines</title>
+   <para>
+    Developers should keep the following in mind when implementing token
+    validation:
+   </para>
+   <variablelist>
+    <varlistentry>
+     <term>Token Confidentiality</term>
+     <listitem>
+      <para>
+       Modules should not write tokens, or pieces of tokens, into the server
+       log. This is true even if the module considers the token invalid; an
+       attacker who confuses a client into communicating with the wrong provider
+       should not be able to retrieve that (otherwise valid) token from the
+       disk.
+      </para>
+      <para>
+       Implementations that send tokens over the network (for example, to
+       perform online token validation with a provider) must authenticate the
+       peer and ensure that strong transport security is in use.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Logging</term>
+     <listitem>
+      <para>
+       Modules may use the same <link linkend="error-message-reporting">logging
+       facilities</link> as standard extensions; however, the rules for emitting
+       log entries to the client are subtly different during the authentication
+       phase of the connection. Generally speaking, modules should log
+       verification problems at the <symbol>COMMERROR</symbol> level and return
+       normally, instead of using <symbol>ERROR</symbol>/<symbol>FATAL</symbol>
+       to unwind the stack, to avoid leaking information to unauthenticated
+       clients.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Interruptibility</term>
+     <listitem>
+      <para>
+       Modules must remain interruptible by signals so that the server can
+       correctly handle authentication timeouts and shutdown signals from
+       <application>pg_ctl</application>. For example, a module receiving
+       <symbol>EINTR</symbol>/<symbol>EAGAIN</symbol> from a blocking call
+       should call <function>CHECK_FOR_INTERRUPTS()</function> before retrying.
+       The same should be done during any long-running loops. Failure to follow
+       this guidance may result in unresponsive backend sessions.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Testing</term>
+     <listitem>
+      <para>
+       The breadth of testing an OAuth system is well beyond the scope of this
+       documentation, but at minimum, negative testing should be considered
+       mandatory. It's trivial to design a module that lets authorized users in;
+       the whole point of the system is to keep unauthorized users out.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Documentation</term>
+     <listitem>
+      <para>
+       Validator implementations should document the contents and format of the
+       authenticated ID that is reported to the server for each end user, since
+       DBAs may need to use this information to construct pg_ident maps. (For
+       instance, is it an email address? an organizational ID number? a UUID?)
+       They should also document whether or not it is safe to use the module in
+       <symbol>delegate_ident_mapping=1</symbol> mode, and what additional
+       configuration is required in order to do so.
+      </para>
+     </listitem>
+    </varlistentry>
+   </variablelist>
+  </sect2>
+
+  <sect2 id="oauth-validator-design-usermap-delegation">
+   <title>Authorizing Users (Usermap Delegation)</title>
+   <para>
+    The standard deliverable of a validation module is the user identifier,
+    which the server will then compare to any configured
+    <link linkend="auth-username-maps"><filename>pg_ident.conf</filename>
+    mappings</link> and determine whether the end user is authorized to connect.
+    However, OAuth is itself an authorization framework, and tokens may carry
+    information about user privileges. For example, a token may be associated
+    with the organizational groups that a user belongs to, or list the roles
+    that a user may assume, and duplicating that knowledge into local usermaps
+    for every server may not be desirable.
+   </para>
+   <para>
+    To bypass username mapping entirely, and have the validator module assume
+    the additional responsibility of authorizing user connections, the HBA may
+    be configured with <xref linkend="auth-oauth-delegate-ident-mapping"/>.
+    The module may then use token scopes or an equivalent method to decide
+    whether the user is allowed to connect under their desired role. The user
+    identifier will still be recorded by the server, but it plays no part in
+    determining whether to continue the connection.
+   </para>
+   <para>
+    Using this scheme, authentication itself is optional. As long as the module
+    reports that the connection is authorized, login will continue even if there
+    is no recorded user identifier at all. This makes it possible to implement
+    anonymous or pseudonymous access to the database, where the third-party
+    provider performs all necessary authentication but does not provide any
+    user-identifying information to the server. (Some providers may create an
+    anonymized ID number that can be recorded instead, for later auditing.)
+   </para>
+   <para>
+    Usermap delegation provides the most architectural flexibility, but it turns
+    the validator module into a single point of failure for connection
+    authorization. Use with caution.
+   </para>
+  </sect2>
+ </sect1>
+
+ <sect1 id="oauth-validator-init">
+  <title>Initialization Functions</title>
+  <indexterm zone="oauth-validator-init">
+   <primary>_PG_oauth_validator_module_init</primary>
+  </indexterm>
+  <para>
+   An OAuth validator module is loaded by dynamically loading one of the shared
+   libraries listed in <xref linkend="guc-oauth-validator-libraries"/>.
+   The normal library search path is used to locate the library. To
+   provide the validator callbacks and to indicate that the library is an OAuth
+   validator module a function named
+   <function>_PG_oauth_validator_module_init</function> must be provided. The
+   return value of the function must be a pointer to a struct of type
+   <structname>OAuthValidatorCallbacks</structname>, which contains pointers to
+   the module's token validation functions. The returned
+   pointer must be of server lifetime, which is typically achieved by defining
+   it as a <literal>static const</literal> variable in global scope.
+<programlisting>
+typedef struct OAuthValidatorCallbacks
+{
+    ValidatorStartupCB startup_cb;
+    ValidatorShutdownCB shutdown_cb;
+    ValidatorValidateCB validate_cb;
+} OAuthValidatorCallbacks;
+
+typedef const OAuthValidatorCallbacks *(*OAuthValidatorModuleInit) (void);
+</programlisting>
+
+   Only the <function>validate_cb</function> callback is required, the others
+   are optional.
+  </para>
+ </sect1>
+
+ <sect1 id="oauth-validator-callbacks">
+  <title>OAuth Validator Callbacks</title>
+  <para>
+   OAuth validator modules implement their functionality by defining a set of
+   callbacks. The server will call them as required to process the
+   authentication request from the user.
+  </para>
+
+  <sect2 id="oauth-validator-callback-startup">
+   <title>Startup Callback</title>
+   <para>
+    The <function>startup_cb</function> callback is executed directly after
+    loading the module. This callback can be used to set up local state and
+    perform additional initialization if required. If the validator module
+    has state it can use <structfield>state->private_data</structfield> to
+    store it.
+
+<programlisting>
+typedef void (*ValidatorStartupCB) (ValidatorModuleState *state);
+</programlisting>
+   </para>
+  </sect2>
+
+  <sect2 id="oauth-validator-callback-validate">
+   <title>Validate Callback</title>
+   <para>
+    The <function>validate_cb</function> callback is executed during the OAuth
+    exchange when a user attempts to authenticate using OAuth.  Any state set in
+    previous calls will be available in <structfield>state->private_data</structfield>.
+
+<programlisting>
+typedef ValidatorModuleResult *(*ValidatorValidateCB) (ValidatorModuleState *state, const char *token, const char *role);
+</programlisting>
+
+    <replaceable>token</replaceable> will contain the bearer token to validate.
+    The server has ensured that the token is well-formed syntactically, but no
+    other validation has been performed.  <replaceable>role</replaceable> will
+    contain the role the user has requested to log in as.  The callback must
+    return a palloc'd <literal>ValidatorModuleResult</literal> struct, which is
+    defined as below:
+
+<programlisting>
+typedef struct ValidatorModuleResult
+{
+    bool        authorized;
+    char       *authn_id;
+} ValidatorModuleResult;
+</programlisting>
+
+    The connection will only proceed if the module sets
+    <structfield>authorized</structfield> to <literal>true</literal>.  To
+    authenticate the user, the authenticated user name (as determined using the
+    token) shall be palloc'd and returned in the <structfield>authn_id</structfield>
+    field.  Alternatively, <structfield>authn_id</structfield> may be set to
+    NULL if the token is valid but the associated user identity cannot be
+    determined.
+   </para>
+   <para>
+    The caller assumes ownership of the returned memory allocation, the
+    validator module should not in any way access the memory after it has been
+    returned.  A validator may instead return NULL to signal an internal
+    error.
+   </para>
+   <para>
+    The behavior after <function>validate_cb</function> returns depends on the
+    specific HBA setup.  Normally, the <structfield>authn_id</structfield> user
+    name must exactly match the role that the user is logging in as.  (This
+    behavior may be modified with a usermap.)  But when authenticating against
+    an HBA rule with <literal>trust_validator_authz</literal> turned on, the
+    server will not perform any checks on the value of
+    <structfield>authn_id</structfield> at all; in this case it is up to the
+    validator to ensure that the token carries enough privileges for the user to
+    log in under the indicated <replaceable>role</replaceable>.
+   </para>
+  </sect2>
+
+  <sect2 id="oauth-validator-callback-shutdown">
+   <title>Shutdown Callback</title>
+   <para>
+    The <function>shutdown_cb</function> callback is executed when the backend
+    process associated with the connection exits. If the validator module has
+    any state, this callback should free it to avoid resource leaks.
+<programlisting>
+typedef void (*ValidatorShutdownCB) (ValidatorModuleState *state);
+</programlisting>
+   </para>
+  </sect2>
+
+ </sect1>
+</chapter>
diff --git a/doc/src/sgml/postgres.sgml b/doc/src/sgml/postgres.sgml
index 7be25c5850..af476c82fc 100644
--- a/doc/src/sgml/postgres.sgml
+++ b/doc/src/sgml/postgres.sgml
@@ -229,6 +229,7 @@ break is not needed in a wider output rendering.
   &logicaldecoding;
   &replication-origins;
   &archive-modules;
+  &oauth-validators;
 
  </part>
 
diff --git a/doc/src/sgml/protocol.sgml b/doc/src/sgml/protocol.sgml
index fb5dec1172..3bd9e68e6c 100644
--- a/doc/src/sgml/protocol.sgml
+++ b/doc/src/sgml/protocol.sgml
@@ -1688,11 +1688,11 @@ SELCT 1/0;<!-- this typo is intentional -->
 
   <para>
    <firstterm>SASL</firstterm> is a framework for authentication in connection-oriented
-   protocols. At the moment, <productname>PostgreSQL</productname> implements two SASL
-   authentication mechanisms, SCRAM-SHA-256 and SCRAM-SHA-256-PLUS. More
-   might be added in the future. The below steps illustrate how SASL
-   authentication is performed in general, while the next subsection gives
-   more details on SCRAM-SHA-256 and SCRAM-SHA-256-PLUS.
+   protocols. At the moment, <productname>PostgreSQL</productname> implements three
+   SASL authentication mechanisms: SCRAM-SHA-256, SCRAM-SHA-256-PLUS, and
+   OAUTHBEARER. More might be added in the future. The below steps illustrate how SASL
+   authentication is performed in general, while the next subsections give
+   more details on particular mechanisms.
   </para>
 
   <procedure>
@@ -1727,7 +1727,7 @@ SELCT 1/0;<!-- this typo is intentional -->
    <step id="sasl-auth-end">
     <para>
      Finally, when the authentication exchange is completed successfully, the
-     server sends an AuthenticationSASLFinal message, followed
+     server sends an optional AuthenticationSASLFinal message, followed
      immediately by an AuthenticationOk message. The AuthenticationSASLFinal
      contains additional server-to-client data, whose content is particular to the
      selected authentication mechanism. If the authentication mechanism doesn't
@@ -1746,9 +1746,9 @@ SELCT 1/0;<!-- this typo is intentional -->
    <title>SCRAM-SHA-256 Authentication</title>
 
    <para>
-    The implemented SASL mechanisms at the moment
-    are <literal>SCRAM-SHA-256</literal> and its variant with channel
-    binding <literal>SCRAM-SHA-256-PLUS</literal>. They are described in
+    <literal>SCRAM-SHA-256</literal>, and its variant with channel
+    binding <literal>SCRAM-SHA-256-PLUS</literal>, are password-based
+    authentication mechanisms. They are described in
     detail in <ulink url="https://datatracker.ietf.org/doc/html/rfc7677">RFC 7677</ulink>
     and <ulink url="https://datatracker.ietf.org/doc/html/rfc5802">RFC 5802</ulink>.
    </para>
@@ -1850,6 +1850,121 @@ SELCT 1/0;<!-- this typo is intentional -->
     </step>
    </procedure>
   </sect2>
+
+  <sect2 id="sasl-oauthbearer">
+   <title>OAUTHBEARER Authentication</title>
+
+   <para>
+    <literal>OAUTHBEARER</literal> is a token-based mechanism for federated
+    authentication. It is described in detail in
+    <ulink url="https://datatracker.ietf.org/doc/html/rfc7628">RFC 7628</ulink>.
+   </para>
+
+   <para>
+    A typical exchange differs depending on whether or not the client already
+    has a bearer token cached for the current user. If it does not, the exchange
+    will take place over two connections: the first "discovery" connection to
+    obtain OAuth metadata from the server, and the second connection to send
+    the token after the client has obtained it. (libpq does not currently
+    implement a caching method as part of its builtin flow, so it uses the
+    two-connection exchange.)
+   </para>
+
+   <para>
+    This mechanism is client-initiated, like SCRAM. The client initial response
+    consists of the standard "GS2" header used by SCRAM, followed by a list of
+    <literal>key=value</literal> pairs. The only key currently supported by
+    the server is <literal>auth</literal>, which contains the bearer token.
+    <literal>OAUTHBEARER</literal> additionally specifies three optional
+    components of the client initial response (the <literal>authzid</literal> of
+    the GS2 header, and the <structfield>host</structfield> and
+    <structfield>port</structfield> keys) which are currently ignored by the
+    server.
+   </para>
+
+   <para>
+    <literal>OAUTHBEARER</literal> does not support channel binding, and there
+    is no "OAUTHBEARER-PLUS" mechanism. This mechanism does not make use of
+    server data during a successful authentication, so the
+    AuthenticationSASLFinal message is not used in the exchange.
+   </para>
+
+   <procedure>
+    <title>Example</title>
+    <step>
+     <para>
+      During the first exchange, the server sends an AuthenticationSASL message
+      with the <literal>OAUTHBEARER</literal> mechanism advertised.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      The client responds by sending a SASLInitialResponse message which
+      indicates the <literal>OAUTHBEARER</literal> mechanism. Assuming the
+      client does not already have a valid bearer token for the current user,
+      the <structfield>auth</structfield> field is empty, indicating a discovery
+      connection.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      Server sends an AuthenticationSASLContinue message containing an error
+      <literal>status</literal> alongside a well-known URI and scopes that the
+      client should use to conduct an OAuth flow.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      Client sends a SASLResponse message containing the empty set (a single
+      <literal>0x01</literal> byte) to finish its half of the discovery
+      exchange.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      Server sends an ErrorMessage to fail the first exchange.
+     </para>
+     <para>
+      At this point, the client conducts one of many possible OAuth flows to
+      obtain a bearer token, using any metadata that it has been configured with
+      in addition to that provided by the server. (This description is left
+      deliberately vague; <literal>OAUTHBEARER</literal> does not specify or
+      mandate any particular method for obtaining a token.)
+     </para>
+     <para>
+      Once it has a token, the client reconnects to the server for the final
+      exchange:
+     </para>
+    </step>
+
+    <step>
+     <para>
+      The server once again sends an AuthenticationSASL message with the
+      <literal>OAUTHBEARER</literal> mechanism advertised.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      The client responds by sending a SASLInitialResponse message, but this
+      time the <structfield>auth</structfield> field in the message contains the
+      bearer token that was obtained during the client flow.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      The server validates the token according to the instructions of the
+      token provider. If the client is authorized to connect, it sends an
+      AuthenticationOk message to end the SASL exchange.
+     </para>
+    </step>
+   </procedure>
+  </sect2>
  </sect1>
 
  <sect1 id="protocol-replication">
diff --git a/doc/src/sgml/regress.sgml b/doc/src/sgml/regress.sgml
index 7c474559bd..0e5e8e8f30 100644
--- a/doc/src/sgml/regress.sgml
+++ b/doc/src/sgml/regress.sgml
@@ -347,6 +347,16 @@ make check-world PG_TEST_EXTRA='kerberos ldap ssl load_balance libpq_encryption'
       </para>
      </listitem>
     </varlistentry>
+
+    <varlistentry>
+     <term><literal>oauth</literal></term>
+     <listitem>
+      <para>
+       Runs the test suite under <filename>src/test/modules/oauth_validator</filename>.
+       This opens TCP/IP listen sockets for a test-server running HTTPS.
+      </para>
+     </listitem>
+    </varlistentry>
    </variablelist>
 
    Tests for features that are not supported by the current build
diff --git a/meson.build b/meson.build
index 1ceadb9a83..80a6b1d57d 100644
--- a/meson.build
+++ b/meson.build
@@ -854,6 +854,67 @@ endif
 
 
 
+###############################################################
+# Library: libcurl
+###############################################################
+
+libcurlopt = get_option('libcurl')
+if not libcurlopt.disabled()
+  # Check for libcurl 7.61.0 or higher (corresponding to RHEL8 and the ability
+  # to explicitly set TLS 1.3 ciphersuites).
+  libcurl = dependency('libcurl', version: '>= 7.61.0', required: libcurlopt)
+  if libcurl.found()
+    cdata.set('USE_LIBCURL', 1)
+
+    # Check to see whether the current platform supports threadsafe Curl
+    # initialization.
+    libcurl_threadsafe_init = false
+
+    if not meson.is_cross_build()
+      r = cc.run('''
+        #include <curl/curl.h>
+
+        int main(void)
+        {
+            curl_version_info_data *info;
+
+            if (curl_global_init(CURL_GLOBAL_ALL))
+                return -1;
+
+            info = curl_version_info(CURLVERSION_NOW);
+        #ifdef CURL_VERSION_THREADSAFE
+            if (info->features & CURL_VERSION_THREADSAFE)
+                return 0;
+        #endif
+
+            return 1;
+        }''',
+        name: 'test for curl_global_init thread safety',
+        dependencies: libcurl,
+      )
+
+      assert(r.compiled())
+      if r.returncode() == 0
+        libcurl_threadsafe_init = true
+        message('curl_global_init is threadsafe')
+      elif r.returncode() == 1
+        message('curl_global_init is not threadsafe')
+      else
+        message('curl_global_init failed; assuming not threadsafe')
+      endif
+    endif
+
+    if libcurl_threadsafe_init
+      cdata.set('HAVE_THREADSAFE_CURL_GLOBAL_INIT', 1)
+    endif
+  endif
+
+else
+  libcurl = not_found_dep
+endif
+
+
+
 ###############################################################
 # Library: libxml
 ###############################################################
@@ -3044,6 +3105,10 @@ libpq_deps += [
 
   gssapi,
   ldap_r,
+  # XXX libcurl must link after libgssapi_krb5 on FreeBSD to avoid segfaults
+  # during gss_acquire_cred(). This is possibly related to Curl's Heimdal
+  # dependency on that platform?
+  libcurl,
   libintl,
   ssl,
 ]
@@ -3720,6 +3785,7 @@ if meson.version().version_compare('>=0.57')
       'gss': gssapi,
       'icu': icu,
       'ldap': ldap,
+      'libcurl': libcurl,
       'libxml': libxml,
       'libxslt': libxslt,
       'llvm': llvm,
diff --git a/meson_options.txt b/meson_options.txt
index d9c7ddccbc..702c451714 100644
--- a/meson_options.txt
+++ b/meson_options.txt
@@ -100,6 +100,9 @@ option('icu', type: 'feature', value: 'auto',
 option('ldap', type: 'feature', value: 'auto',
   description: 'LDAP support')
 
+option('libcurl', type : 'feature', value: 'auto',
+  description: 'libcurl support')
+
 option('libedit_preferred', type: 'boolean', value: false,
   description: 'Prefer BSD Libedit over GNU Readline')
 
diff --git a/src/Makefile.global.in b/src/Makefile.global.in
index bbe11e75bf..3b620bac5a 100644
--- a/src/Makefile.global.in
+++ b/src/Makefile.global.in
@@ -190,6 +190,7 @@ with_systemd	= @with_systemd@
 with_gssapi	= @with_gssapi@
 with_krb_srvnam	= @with_krb_srvnam@
 with_ldap	= @with_ldap@
+with_libcurl	= @with_libcurl@
 with_libxml	= @with_libxml@
 with_libxslt	= @with_libxslt@
 with_llvm	= @with_llvm@
diff --git a/src/backend/libpq/Makefile b/src/backend/libpq/Makefile
index 6d385fd6a4..98eb2a8242 100644
--- a/src/backend/libpq/Makefile
+++ b/src/backend/libpq/Makefile
@@ -15,6 +15,7 @@ include $(top_builddir)/src/Makefile.global
 # be-fsstubs is here for historical reasons, probably belongs elsewhere
 
 OBJS = \
+	auth-oauth.o \
 	auth-sasl.o \
 	auth-scram.o \
 	auth.o \
diff --git a/src/backend/libpq/auth-oauth.c b/src/backend/libpq/auth-oauth.c
new file mode 100644
index 0000000000..6155d63a11
--- /dev/null
+++ b/src/backend/libpq/auth-oauth.c
@@ -0,0 +1,860 @@
+/*-------------------------------------------------------------------------
+ *
+ * auth-oauth.c
+ *	  Server-side implementation of the SASL OAUTHBEARER mechanism.
+ *
+ * See the following RFC for more details:
+ * - RFC 7628: https://tools.ietf.org/html/rfc7628
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/backend/libpq/auth-oauth.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include <unistd.h>
+#include <fcntl.h>
+
+#include "common/oauth-common.h"
+#include "fmgr.h"
+#include "lib/stringinfo.h"
+#include "libpq/auth.h"
+#include "libpq/hba.h"
+#include "libpq/oauth.h"
+#include "libpq/sasl.h"
+#include "storage/fd.h"
+#include "storage/ipc.h"
+#include "utils/json.h"
+#include "utils/varlena.h"
+
+/* GUC */
+char	   *oauth_validator_libraries_string = NULL;
+
+static void oauth_get_mechanisms(Port *port, StringInfo buf);
+static void *oauth_init(Port *port, const char *selected_mech, const char *shadow_pass);
+static int	oauth_exchange(void *opaq, const char *input, int inputlen,
+						   char **output, int *outputlen, const char **logdetail);
+
+static void load_validator_library(const char *libname);
+static void shutdown_validator_library(int code, Datum arg);
+
+static ValidatorModuleState *validator_module_state;
+static const OAuthValidatorCallbacks *ValidatorCallbacks;
+
+/* Mechanism declaration */
+const pg_be_sasl_mech pg_be_oauth_mech = {
+	.get_mechanisms = oauth_get_mechanisms,
+	.init = oauth_init,
+	.exchange = oauth_exchange,
+
+	.max_message_length = PG_MAX_AUTH_TOKEN_LENGTH,
+};
+
+/* Valid states for the oauth_exchange() machine. */
+enum oauth_state
+{
+	OAUTH_STATE_INIT = 0,
+	OAUTH_STATE_ERROR,
+	OAUTH_STATE_FINISHED,
+};
+
+/* Mechanism callback state. */
+struct oauth_ctx
+{
+	enum oauth_state state;
+	Port	   *port;
+	const char *issuer;
+	const char *scope;
+};
+
+static char *sanitize_char(char c);
+static char *parse_kvpairs_for_auth(char **input);
+static void generate_error_response(struct oauth_ctx *ctx, char **output, int *outputlen);
+static bool validate(Port *port, const char *auth);
+
+/* Constants seen in an OAUTHBEARER client initial response. */
+#define KVSEP 0x01				/* separator byte for key/value pairs */
+#define AUTH_KEY "auth"			/* key containing the Authorization header */
+#define BEARER_SCHEME "Bearer " /* required header scheme (case-insensitive!) */
+
+/*
+ * Retrieves the OAUTHBEARER mechanism list (currently a single item).
+ *
+ * For a full description of the API, see libpq/sasl.h.
+ */
+static void
+oauth_get_mechanisms(Port *port, StringInfo buf)
+{
+	/* Only OAUTHBEARER is supported. */
+	appendStringInfoString(buf, OAUTHBEARER_NAME);
+	appendStringInfoChar(buf, '\0');
+}
+
+/*
+ * Initializes mechanism state and loads the configured validator module.
+ *
+ * For a full description of the API, see libpq/sasl.h.
+ */
+static void *
+oauth_init(Port *port, const char *selected_mech, const char *shadow_pass)
+{
+	struct oauth_ctx *ctx;
+
+	if (strcmp(selected_mech, OAUTHBEARER_NAME) != 0)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("client selected an invalid SASL authentication mechanism"));
+
+	ctx = palloc0(sizeof(*ctx));
+
+	ctx->state = OAUTH_STATE_INIT;
+	ctx->port = port;
+
+	Assert(port->hba);
+	ctx->issuer = port->hba->oauth_issuer;
+	ctx->scope = port->hba->oauth_scope;
+
+	load_validator_library(port->hba->oauth_validator);
+
+	return ctx;
+}
+
+/*
+ * Implements the OAUTHBEARER SASL exchange (RFC 7628, Sec. 3.2). This pulls
+ * apart the client initial response and validates the Bearer token. It also
+ * handles the dummy error response for a failed handshake, as described in
+ * Sec. 3.2.3.
+ *
+ * For a full description of the API, see libpq/sasl.h.
+ */
+static int
+oauth_exchange(void *opaq, const char *input, int inputlen,
+			   char **output, int *outputlen, const char **logdetail)
+{
+	char	   *input_copy;
+	char	   *p;
+	char		cbind_flag;
+	char	   *auth;
+	int			status;
+
+	struct oauth_ctx *ctx = opaq;
+
+	*output = NULL;
+	*outputlen = -1;
+
+	/*
+	 * If the client didn't include an "Initial Client Response" in the
+	 * SASLInitialResponse message, send an empty challenge, to which the
+	 * client will respond with the same data that usually comes in the
+	 * Initial Client Response.
+	 */
+	if (input == NULL)
+	{
+		Assert(ctx->state == OAUTH_STATE_INIT);
+
+		*output = pstrdup("");
+		*outputlen = 0;
+		return PG_SASL_EXCHANGE_CONTINUE;
+	}
+
+	/*
+	 * Check that the input length agrees with the string length of the input.
+	 */
+	if (inputlen == 0)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("The message is empty."));
+	if (inputlen != strlen(input))
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message length does not match input length."));
+
+	switch (ctx->state)
+	{
+		case OAUTH_STATE_INIT:
+			/* Handle this case below. */
+			break;
+
+		case OAUTH_STATE_ERROR:
+
+			/*
+			 * Only one response is valid for the client during authentication
+			 * failure: a single kvsep.
+			 */
+			if (inputlen != 1 || *input != KVSEP)
+				ereport(ERROR,
+						errcode(ERRCODE_PROTOCOL_VIOLATION),
+						errmsg("malformed OAUTHBEARER message"),
+						errdetail("Client did not send a kvsep response."));
+
+			/* The (failed) handshake is now complete. */
+			ctx->state = OAUTH_STATE_FINISHED;
+			return PG_SASL_EXCHANGE_FAILURE;
+
+		default:
+			elog(ERROR, "invalid OAUTHBEARER exchange state");
+			return PG_SASL_EXCHANGE_FAILURE;
+	}
+
+	/* Handle the client's initial message. */
+	p = input_copy = pstrdup(input);
+
+	/*
+	 * OAUTHBEARER does not currently define a channel binding (so there is no
+	 * OAUTHBEARER-PLUS, and we do not accept a 'p' specifier). We accept a
+	 * 'y' specifier purely for the remote chance that a future specification
+	 * could define one; then future clients can still interoperate with this
+	 * server implementation. 'n' is the expected case.
+	 */
+	cbind_flag = *p;
+	switch (cbind_flag)
+	{
+		case 'p':
+			ereport(ERROR,
+					errcode(ERRCODE_PROTOCOL_VIOLATION),
+					errmsg("malformed OAUTHBEARER message"),
+					errdetail("The server does not support channel binding for OAuth, but the client message includes channel binding data."));
+			break;
+
+		case 'y':				/* fall through */
+		case 'n':
+			p++;
+			if (*p != ',')
+				ereport(ERROR,
+						errcode(ERRCODE_PROTOCOL_VIOLATION),
+						errmsg("malformed OAUTHBEARER message"),
+						errdetail("Comma expected, but found character \"%s\".",
+								  sanitize_char(*p)));
+			p++;
+			break;
+
+		default:
+			ereport(ERROR,
+					errcode(ERRCODE_PROTOCOL_VIOLATION),
+					errmsg("malformed OAUTHBEARER message"),
+					errdetail("Unexpected channel-binding flag \"%s\".",
+							  sanitize_char(cbind_flag)));
+	}
+
+	/*
+	 * Forbid optional authzid (authorization identity).  We don't support it.
+	 */
+	if (*p == 'a')
+		ereport(ERROR,
+				errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				errmsg("client uses authorization identity, but it is not supported"));
+	if (*p != ',')
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Unexpected attribute \"%s\" in client-first-message.",
+						  sanitize_char(*p)));
+	p++;
+
+	/* All remaining fields are separated by the RFC's kvsep (\x01). */
+	if (*p != KVSEP)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Key-value separator expected, but found character \"%s\".",
+						  sanitize_char(*p)));
+	p++;
+
+	auth = parse_kvpairs_for_auth(&p);
+	if (!auth)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message does not contain an auth value."));
+
+	/* We should be at the end of our message. */
+	if (*p)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message contains additional data after the final terminator."));
+
+	if (!validate(ctx->port, auth))
+	{
+		generate_error_response(ctx, output, outputlen);
+
+		ctx->state = OAUTH_STATE_ERROR;
+		status = PG_SASL_EXCHANGE_CONTINUE;
+	}
+	else
+	{
+		ctx->state = OAUTH_STATE_FINISHED;
+		status = PG_SASL_EXCHANGE_SUCCESS;
+	}
+
+	/* Don't let extra copies of the bearer token hang around. */
+	explicit_bzero(input_copy, inputlen);
+
+	return status;
+}
+
+/*
+ * Convert an arbitrary byte to printable form.  For error messages.
+ *
+ * If it's a printable ASCII character, print it as a single character.
+ * otherwise, print it in hex.
+ *
+ * The returned pointer points to a static buffer.
+ */
+static char *
+sanitize_char(char c)
+{
+	static char buf[5];
+
+	if (c >= 0x21 && c <= 0x7E)
+		snprintf(buf, sizeof(buf), "'%c'", c);
+	else
+		snprintf(buf, sizeof(buf), "0x%02x", (unsigned char) c);
+	return buf;
+}
+
+/*
+ * Performs syntactic validation of a key and value from the initial client
+ * response. (Semantic validation of interesting values must be performed
+ * later.)
+ */
+static void
+validate_kvpair(const char *key, const char *val)
+{
+	/*-----
+	 * From Sec 3.1:
+	 *     key            = 1*(ALPHA)
+	 */
+	static const char *key_allowed_set =
+		"abcdefghijklmnopqrstuvwxyz"
+		"ABCDEFGHIJKLMNOPQRSTUVWXYZ";
+
+	size_t		span;
+
+	if (!key[0])
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message contains an empty key name."));
+
+	span = strspn(key, key_allowed_set);
+	if (key[span] != '\0')
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message contains an invalid key name."));
+
+	/*-----
+	 * From Sec 3.1:
+	 *     value          = *(VCHAR / SP / HTAB / CR / LF )
+	 *
+	 * The VCHAR (visible character) class is large; a loop is more
+	 * straightforward than strspn().
+	 */
+	for (; *val; ++val)
+	{
+		if (0x21 <= *val && *val <= 0x7E)
+			continue;			/* VCHAR */
+
+		switch (*val)
+		{
+			case ' ':
+			case '\t':
+			case '\r':
+			case '\n':
+				continue;		/* SP, HTAB, CR, LF */
+
+			default:
+				ereport(ERROR,
+						errcode(ERRCODE_PROTOCOL_VIOLATION),
+						errmsg("malformed OAUTHBEARER message"),
+						errdetail("Message contains an invalid value."));
+		}
+	}
+}
+
+/*
+ * Consumes all kvpairs in an OAUTHBEARER exchange message. If the "auth" key is
+ * found, its value is returned.
+ */
+static char *
+parse_kvpairs_for_auth(char **input)
+{
+	char	   *pos = *input;
+	char	   *auth = NULL;
+
+	/*----
+	 * The relevant ABNF, from Sec. 3.1:
+	 *
+	 *     kvsep          = %x01
+	 *     key            = 1*(ALPHA)
+	 *     value          = *(VCHAR / SP / HTAB / CR / LF )
+	 *     kvpair         = key "=" value kvsep
+	 *   ;;gs2-header     = See RFC 5801
+	 *     client-resp    = (gs2-header kvsep *kvpair kvsep) / kvsep
+	 *
+	 * By the time we reach this code, the gs2-header and initial kvsep have
+	 * already been validated. We start at the beginning of the first kvpair.
+	 */
+
+	while (*pos)
+	{
+		char	   *end;
+		char	   *sep;
+		char	   *key;
+		char	   *value;
+
+		/*
+		 * Find the end of this kvpair. Note that input is null-terminated by
+		 * the SASL code, so the strchr() is bounded.
+		 */
+		end = strchr(pos, KVSEP);
+		if (!end)
+			ereport(ERROR,
+					errcode(ERRCODE_PROTOCOL_VIOLATION),
+					errmsg("malformed OAUTHBEARER message"),
+					errdetail("Message contains an unterminated key/value pair."));
+		*end = '\0';
+
+		if (pos == end)
+		{
+			/* Empty kvpair, signifying the end of the list. */
+			*input = pos + 1;
+			return auth;
+		}
+
+		/*
+		 * Find the end of the key name.
+		 */
+		sep = strchr(pos, '=');
+		if (!sep)
+			ereport(ERROR,
+					errcode(ERRCODE_PROTOCOL_VIOLATION),
+					errmsg("malformed OAUTHBEARER message"),
+					errdetail("Message contains a key without a value."));
+		*sep = '\0';
+
+		/* Both key and value are now safely terminated. */
+		key = pos;
+		value = sep + 1;
+		validate_kvpair(key, value);
+
+		if (strcmp(key, AUTH_KEY) == 0)
+		{
+			if (auth)
+				ereport(ERROR,
+						errcode(ERRCODE_PROTOCOL_VIOLATION),
+						errmsg("malformed OAUTHBEARER message"),
+						errdetail("Message contains multiple auth values."));
+
+			auth = value;
+		}
+		else
+		{
+			/*
+			 * The RFC also defines the host and port keys, but they are not
+			 * required for OAUTHBEARER and we do not use them. Also, per Sec.
+			 * 3.1, any key/value pairs we don't recognize must be ignored.
+			 */
+		}
+
+		/* Move to the next pair. */
+		pos = end + 1;
+	}
+
+	ereport(ERROR,
+			errcode(ERRCODE_PROTOCOL_VIOLATION),
+			errmsg("malformed OAUTHBEARER message"),
+			errdetail("Message did not contain a final terminator."));
+
+	pg_unreachable();
+	return NULL;
+}
+
+/*
+ * Builds the JSON response for failed authentication (RFC 7628, Sec. 3.2.2).
+ * This contains the required scopes for entry and a pointer to the OAuth/OpenID
+ * discovery document, which the client may use to conduct its OAuth flow.
+ */
+static void
+generate_error_response(struct oauth_ctx *ctx, char **output, int *outputlen)
+{
+	StringInfoData buf;
+	StringInfoData issuer;
+
+	/*
+	 * The admin needs to set an issuer and scope for OAuth to work. There's
+	 * not really a way to hide this from the user, either, because we can't
+	 * choose a "default" issuer, so be honest in the failure message.
+	 *
+	 * TODO: see if there's a better place to fail, earlier than this.
+	 */
+	if (!ctx->issuer || !ctx->scope)
+		ereport(FATAL,
+				errcode(ERRCODE_INTERNAL_ERROR),
+				errmsg("OAuth is not properly configured for this user"),
+				errdetail_log("The issuer and scope parameters must be set in pg_hba.conf."));
+
+	/*
+	 * Build a default .well-known URI based on our issuer, unless the HBA has
+	 * already provided one.
+	 */
+	initStringInfo(&issuer);
+	appendStringInfoString(&issuer, ctx->issuer);
+	if (strstr(ctx->issuer, "/.well-known/") == NULL)
+		appendStringInfoString(&issuer, "/.well-known/openid-configuration");
+
+	initStringInfo(&buf);
+
+	/*
+	 * Escaping the string here is belt-and-suspenders defensive programming
+	 * since escapable characters aren't valid in either the issuer URI or the
+	 * scope list, but the HBA doesn't enforce that yet.
+	 */
+	appendStringInfoString(&buf, "{ \"status\": \"invalid_token\", ");
+
+	appendStringInfoString(&buf, "\"openid-configuration\": ");
+	escape_json(&buf, issuer.data);
+	pfree(issuer.data);
+
+	appendStringInfoString(&buf, ", \"scope\": ");
+	escape_json(&buf, ctx->scope);
+
+	appendStringInfoString(&buf, " }");
+
+	*output = buf.data;
+	*outputlen = buf.len;
+}
+
+/*-----
+ * Validates the provided Authorization header and returns the token from
+ * within it. NULL is returned on validation failure.
+ *
+ * Only Bearer tokens are accepted. The ABNF is defined in RFC 6750, Sec.
+ * 2.1:
+ *
+ *      b64token    = 1*( ALPHA / DIGIT /
+ *                        "-" / "." / "_" / "~" / "+" / "/" ) *"="
+ *      credentials = "Bearer" 1*SP b64token
+ *
+ * The "credentials" construction is what we receive in our auth value.
+ *
+ * Since that spec is subordinate to HTTP (i.e. the HTTP Authorization
+ * header format; RFC 9110 Sec. 11), the "Bearer" scheme string must be
+ * compared case-insensitively. (This is not mentioned in RFC 6750, but the
+ * OAUTHBEARER spec points it out: RFC 7628 Sec. 4.)
+ *
+ * Invalid formats are technically a protocol violation, but we shouldn't
+ * reflect any information about the sensitive Bearer token back to the
+ * client; log at COMMERROR instead.
+ */
+static const char *
+validate_token_format(const char *header)
+{
+	size_t		span;
+	const char *token;
+	static const char *const b64token_allowed_set =
+		"abcdefghijklmnopqrstuvwxyz"
+		"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+		"0123456789-._~+/";
+
+	/* Missing auth headers should be handled by the caller. */
+	Assert(header);
+
+	if (header[0] == '\0')
+	{
+		/*
+		 * A completely empty auth header represents a query for
+		 * authentication parameters. The client expects it to fail; there's
+		 * no need to make any extra noise in the logs.
+		 *
+		 * TODO: should we find a way to return STATUS_EOF at the top level,
+		 * to suppress the authentication error entirely?
+		 */
+		return NULL;
+	}
+
+	if (pg_strncasecmp(header, BEARER_SCHEME, strlen(BEARER_SCHEME)))
+	{
+		ereport(COMMERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAuth bearer token"),
+				errdetail_log("Client response indicated a non-Bearer authentication scheme."));
+		return NULL;
+	}
+
+	/* Pull the bearer token out of the auth value. */
+	token = header + strlen(BEARER_SCHEME);
+
+	/* Swallow any additional spaces. */
+	while (*token == ' ')
+		token++;
+
+	/* Tokens must not be empty. */
+	if (!*token)
+	{
+		ereport(COMMERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAuth bearer token"),
+				errdetail_log("Bearer token is empty."));
+		return NULL;
+	}
+
+	/*
+	 * Make sure the token contains only allowed characters. Tokens may end
+	 * with any number of '=' characters.
+	 */
+	span = strspn(token, b64token_allowed_set);
+	while (token[span] == '=')
+		span++;
+
+	if (token[span] != '\0')
+	{
+		/*
+		 * This error message could be more helpful by printing the
+		 * problematic character(s), but that'd be a bit like printing a piece
+		 * of someone's password into the logs.
+		 */
+		ereport(COMMERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAuth bearer token"),
+				errdetail_log("Bearer token is not in the correct format."));
+		return NULL;
+	}
+
+	return token;
+}
+
+/*
+ * Checks that the "auth" kvpair in the client response contains a syntactically
+ * valid Bearer token, then passes it along to the loaded validator module for
+ * authorization. Returns true if validation succeeds.
+ */
+static bool
+validate(Port *port, const char *auth)
+{
+	int			map_status;
+	ValidatorModuleResult *ret;
+	const char *token;
+	bool		status;
+
+	/* Ensure that we have a correct token to validate */
+	if (!(token = validate_token_format(auth)))
+		return false;
+
+	/*
+	 * Ensure that we have a validation library loaded, this should always be
+	 * the case and an error here is indicative of a bug.
+	 */
+	if (!ValidatorCallbacks || !ValidatorCallbacks->validate_cb)
+		ereport(FATAL,
+				errcode(ERRCODE_INTERNAL_ERROR),
+				errmsg("validation of OAuth token requested without a validator loaded"));
+
+	/* Call the validation function from the validator module */
+	ret = ValidatorCallbacks->validate_cb(validator_module_state,
+										  token, port->user_name);
+	if (ret == NULL)
+	{
+		ereport(LOG, errmsg("internal error in OAuth validator module"));
+		return false;
+	}
+
+	/*
+	 * Log any authentication results even if the token isn't authorized; it
+	 * might be useful for auditing or troubleshooting.
+	 */
+	if (ret->authn_id)
+		set_authn_id(port, ret->authn_id);
+
+	if (!ret->authorized)
+	{
+		ereport(LOG,
+				errmsg("OAuth bearer authentication failed for user \"%s\"",
+					   port->user_name),
+				errdetail_log("Validator failed to authorize the provided token."));
+
+		status = false;
+		goto cleanup;
+	}
+
+	if (port->hba->oauth_skip_usermap)
+	{
+		/*
+		 * If the validator is our authorization authority, we're done.
+		 * Authentication may or may not have been performed depending on the
+		 * validator implementation; all that matters is that the validator
+		 * says the user can log in with the target role.
+		 */
+		status = true;
+		goto cleanup;
+	}
+
+	/* Make sure the validator authenticated the user. */
+	if (ret->authn_id == NULL || ret->authn_id[0] == '\0')
+	{
+		ereport(LOG,
+				errmsg("OAuth bearer authentication failed for user \"%s\"",
+					   port->user_name),
+				errdetail_log("Validator provided no identity."));
+
+		status = false;
+		goto cleanup;
+	}
+
+	/* Finally, check the user map. */
+	map_status = check_usermap(port->hba->usermap, port->user_name,
+							   MyClientConnectionInfo.authn_id, false);
+	status = (map_status == STATUS_OK);
+
+cleanup:
+
+	/*
+	 * Clear and free the validation result from the validator module once
+	 * we're done with it.
+	 */
+	if (ret->authn_id != NULL)
+		pfree(ret->authn_id);
+	pfree(ret);
+
+	return status;
+}
+
+/*
+ * load_validator_library
+ *
+ * Load the configured validator library in order to perform token validation.
+ * There is no built-in fallback since validation is implementation specific. If
+ * no validator library is configured, or if it fails to load, then error out
+ * since token validation won't be possible.
+ */
+static void
+load_validator_library(const char *libname)
+{
+	OAuthValidatorModuleInit validator_init;
+
+	Assert(libname && *libname);
+
+	validator_init = (OAuthValidatorModuleInit)
+		load_external_function(libname, "_PG_oauth_validator_module_init",
+							   false, NULL);
+
+	/*
+	 * The validator init function is required since it will set the callbacks
+	 * for the validator library.
+	 */
+	if (validator_init == NULL)
+		ereport(ERROR,
+				errmsg("%s module \"%s\" must define the symbol %s",
+					   "OAuth validator", libname, "_PG_oauth_validator_module_init"));
+
+	ValidatorCallbacks = (*validator_init) ();
+	Assert(ValidatorCallbacks);
+
+	/* Allocate memory for validator library private state data */
+	validator_module_state = (ValidatorModuleState *) palloc0(sizeof(ValidatorModuleState));
+	if (ValidatorCallbacks->startup_cb != NULL)
+		ValidatorCallbacks->startup_cb(validator_module_state);
+
+	before_shmem_exit(shutdown_validator_library, 0);
+}
+
+/*
+ * Call the validator module's shutdown callback, if one is provided. This is
+ * invoked via before_shmem_exit().
+ */
+static void
+shutdown_validator_library(int code, Datum arg)
+{
+	if (ValidatorCallbacks->shutdown_cb != NULL)
+		ValidatorCallbacks->shutdown_cb(validator_module_state);
+}
+
+/*
+ * Ensure an OAuth validator named in the HBA is permitted by the configuration.
+ *
+ * If the validator is currently unset and exactly one library is declared in
+ * oauth_validator_libraries, then that library will be used as the validator.
+ * Otherwise the name must be present in the list of oauth_validator_libraries.
+ */
+bool
+check_oauth_validator(HbaLine *hbaline, int elevel, char **err_msg)
+{
+	int			line_num = hbaline->linenumber;
+	char	   *file_name = hbaline->sourcefile;
+	char	   *rawstring;
+	List	   *elemlist = NIL;
+
+	*err_msg = NULL;
+
+	if (oauth_validator_libraries_string[0] == '\0')
+	{
+		ereport(elevel,
+				errcode(ERRCODE_CONFIG_FILE_ERROR),
+				errmsg("oauth_validator_libraries must be set for authentication method %s",
+					   "oauth"),
+				errcontext("line %d of configuration file \"%s\"",
+						   line_num, file_name));
+		*err_msg = psprintf("oauth_validator_libraries must be set for authentication method %s",
+							"oauth");
+		return false;
+	}
+
+	/* SplitDirectoriesString needs a modifiable copy */
+	rawstring = pstrdup(oauth_validator_libraries_string);
+
+	if (!SplitDirectoriesString(rawstring, ',', &elemlist))
+	{
+		/* syntax error in list */
+		ereport(elevel,
+				errcode(ERRCODE_CONFIG_FILE_ERROR),
+				errmsg("invalid list syntax in parameter \"%s\"",
+					   "oauth_validator_libraries"));
+		*err_msg = psprintf("invalid list syntax in parameter \"%s\"",
+							"oauth_validator_libraries");
+		goto done;
+	}
+
+	if (!hbaline->oauth_validator)
+	{
+		if (elemlist->length == 1)
+		{
+			hbaline->oauth_validator = pstrdup(linitial(elemlist));
+			goto done;
+		}
+
+		ereport(elevel,
+				errcode(ERRCODE_CONFIG_FILE_ERROR),
+				errmsg("authentication method \"oauth\" requires argument \"validator\" to be set when oauth_validator_libraries contains multiple options"),
+				errcontext("line %d of configuration file \"%s\"",
+						   line_num, file_name));
+		*err_msg = "authentication method \"oauth\" requires argument \"validator\" to be set when oauth_validator_libraries contains multiple options";
+		goto done;
+	}
+
+	foreach_ptr(char, allowed, elemlist)
+	{
+		if (strcmp(allowed, hbaline->oauth_validator) == 0)
+			goto done;
+	}
+
+	ereport(elevel,
+			errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+			errmsg("validator \"%s\" is not permitted by %s",
+				   hbaline->oauth_validator, "oauth_validator_libraries"),
+			errcontext("line %d of configuration file \"%s\"",
+					   line_num, file_name));
+	*err_msg = psprintf("validator \"%s\" is not permitted by %s",
+						hbaline->oauth_validator, "oauth_validator_libraries");
+
+done:
+	list_free_deep(elemlist);
+	pfree(rawstring);
+
+	return (*err_msg == NULL);
+}
diff --git a/src/backend/libpq/auth.c b/src/backend/libpq/auth.c
index d6ef32cc82..0f65014e64 100644
--- a/src/backend/libpq/auth.c
+++ b/src/backend/libpq/auth.c
@@ -29,6 +29,7 @@
 #include "libpq/auth.h"
 #include "libpq/crypt.h"
 #include "libpq/libpq.h"
+#include "libpq/oauth.h"
 #include "libpq/pqformat.h"
 #include "libpq/sasl.h"
 #include "libpq/scram.h"
@@ -45,7 +46,6 @@
  */
 static void auth_failed(Port *port, int status, const char *logdetail);
 static char *recv_password_packet(Port *port);
-static void set_authn_id(Port *port, const char *id);
 
 
 /*----------------------------------------------------------------
@@ -289,6 +289,9 @@ auth_failed(Port *port, int status, const char *logdetail)
 		case uaRADIUS:
 			errstr = gettext_noop("RADIUS authentication failed for user \"%s\"");
 			break;
+		case uaOAuth:
+			errstr = gettext_noop("OAuth bearer authentication failed for user \"%s\"");
+			break;
 		default:
 			errstr = gettext_noop("authentication failed for user \"%s\": invalid authentication method");
 			break;
@@ -324,7 +327,7 @@ auth_failed(Port *port, int status, const char *logdetail)
  * lifetime of MyClientConnectionInfo, so it is safe to pass a string that is
  * managed by an external library.
  */
-static void
+void
 set_authn_id(Port *port, const char *id)
 {
 	Assert(id);
@@ -611,6 +614,9 @@ ClientAuthentication(Port *port)
 		case uaTrust:
 			status = STATUS_OK;
 			break;
+		case uaOAuth:
+			status = CheckSASLAuth(&pg_be_oauth_mech, port, NULL, NULL);
+			break;
 	}
 
 	if ((status == STATUS_OK && port->hba->clientcert == clientCertFull)
diff --git a/src/backend/libpq/hba.c b/src/backend/libpq/hba.c
index 510c9ffc6d..332fad2783 100644
--- a/src/backend/libpq/hba.c
+++ b/src/backend/libpq/hba.c
@@ -32,6 +32,7 @@
 #include "libpq/hba.h"
 #include "libpq/ifaddr.h"
 #include "libpq/libpq-be.h"
+#include "libpq/oauth.h"
 #include "postmaster/postmaster.h"
 #include "regex/regex.h"
 #include "replication/walsender.h"
@@ -114,7 +115,8 @@ static const char *const UserAuthName[] =
 	"ldap",
 	"cert",
 	"radius",
-	"peer"
+	"peer",
+	"oauth",
 };
 
 /*
@@ -1747,6 +1749,8 @@ parse_hba_line(TokenizedAuthLine *tok_line, int elevel)
 #endif
 	else if (strcmp(token->string, "radius") == 0)
 		parsedline->auth_method = uaRADIUS;
+	else if (strcmp(token->string, "oauth") == 0)
+		parsedline->auth_method = uaOAuth;
 	else
 	{
 		ereport(elevel,
@@ -2039,6 +2043,36 @@ parse_hba_line(TokenizedAuthLine *tok_line, int elevel)
 		parsedline->clientcert = clientCertFull;
 	}
 
+	/*
+	 * Enforce proper configuration of OAuth authentication.
+	 */
+	if (parsedline->auth_method == uaOAuth)
+	{
+		MANDATORY_AUTH_ARG(parsedline->oauth_scope, "scope", "oauth");
+		MANDATORY_AUTH_ARG(parsedline->oauth_issuer, "issuer", "oauth");
+
+		/* Ensure a validator library is set and permitted by the config. */
+		if (!check_oauth_validator(parsedline, elevel, err_msg))
+			return NULL;
+
+		/*
+		 * Supplying a usermap combined with the option to skip usermapping is
+		 * nonsensical and indicates a configuration error.
+		 */
+		if (parsedline->oauth_skip_usermap && parsedline->usermap != NULL)
+		{
+			ereport(elevel,
+					errcode(ERRCODE_CONFIG_FILE_ERROR),
+			/* translator: strings are replaced with hba options */
+					errmsg("%s cannot be used in combination with %s",
+						   "map", "delegate_ident_mapping"),
+					errcontext("line %d of configuration file \"%s\"",
+							   line_num, file_name));
+			*err_msg = "map cannot be used in combination with delegate_ident_mapping";
+			return NULL;
+		}
+	}
+
 	return parsedline;
 }
 
@@ -2066,8 +2100,9 @@ parse_hba_auth_opt(char *name, char *val, HbaLine *hbaline,
 			hbaline->auth_method != uaPeer &&
 			hbaline->auth_method != uaGSS &&
 			hbaline->auth_method != uaSSPI &&
-			hbaline->auth_method != uaCert)
-			INVALID_AUTH_OPTION("map", gettext_noop("ident, peer, gssapi, sspi, and cert"));
+			hbaline->auth_method != uaCert &&
+			hbaline->auth_method != uaOAuth)
+			INVALID_AUTH_OPTION("map", gettext_noop("ident, peer, gssapi, sspi, cert, and oauth"));
 		hbaline->usermap = pstrdup(val);
 	}
 	else if (strcmp(name, "clientcert") == 0)
@@ -2450,6 +2485,29 @@ parse_hba_auth_opt(char *name, char *val, HbaLine *hbaline,
 		hbaline->radiusidentifiers = parsed_identifiers;
 		hbaline->radiusidentifiers_s = pstrdup(val);
 	}
+	else if (strcmp(name, "issuer") == 0)
+	{
+		REQUIRE_AUTH_OPTION(uaOAuth, "issuer", "oauth");
+		hbaline->oauth_issuer = pstrdup(val);
+	}
+	else if (strcmp(name, "scope") == 0)
+	{
+		REQUIRE_AUTH_OPTION(uaOAuth, "scope", "oauth");
+		hbaline->oauth_scope = pstrdup(val);
+	}
+	else if (strcmp(name, "validator") == 0)
+	{
+		REQUIRE_AUTH_OPTION(uaOAuth, "validator", "oauth");
+		hbaline->oauth_validator = pstrdup(val);
+	}
+	else if (strcmp(name, "delegate_ident_mapping") == 0)
+	{
+		REQUIRE_AUTH_OPTION(uaOAuth, "delegate_ident_mapping", "oauth");
+		if (strcmp(val, "1") == 0)
+			hbaline->oauth_skip_usermap = true;
+		else
+			hbaline->oauth_skip_usermap = false;
+	}
 	else
 	{
 		ereport(elevel,
diff --git a/src/backend/libpq/meson.build b/src/backend/libpq/meson.build
index 0f0421037e..31aa2faae1 100644
--- a/src/backend/libpq/meson.build
+++ b/src/backend/libpq/meson.build
@@ -1,6 +1,7 @@
 # Copyright (c) 2022-2025, PostgreSQL Global Development Group
 
 backend_sources += files(
+  'auth-oauth.c',
   'auth-sasl.c',
   'auth-scram.c',
   'auth.c',
diff --git a/src/backend/libpq/pg_hba.conf.sample b/src/backend/libpq/pg_hba.conf.sample
index bad13497a3..b64c8dea97 100644
--- a/src/backend/libpq/pg_hba.conf.sample
+++ b/src/backend/libpq/pg_hba.conf.sample
@@ -53,8 +53,8 @@
 # directly connected to.
 #
 # METHOD can be "trust", "reject", "md5", "password", "scram-sha-256",
-# "gss", "sspi", "ident", "peer", "pam", "ldap", "radius" or "cert".
-# Note that "password" sends passwords in clear text; "md5" or
+# "gss", "sspi", "ident", "peer", "pam", "oauth", "ldap", "radius" or
+# "cert".  Note that "password" sends passwords in clear text; "md5" or
 # "scram-sha-256" are preferred since they send encrypted passwords.
 #
 # OPTIONS are a set of options for the authentication in the format
diff --git a/src/backend/utils/adt/hbafuncs.c b/src/backend/utils/adt/hbafuncs.c
index 03c38e8c45..b62c3d944c 100644
--- a/src/backend/utils/adt/hbafuncs.c
+++ b/src/backend/utils/adt/hbafuncs.c
@@ -152,6 +152,25 @@ get_hba_options(HbaLine *hba)
 				CStringGetTextDatum(psprintf("radiusports=%s", hba->radiusports_s));
 	}
 
+	if (hba->auth_method == uaOAuth)
+	{
+		if (hba->oauth_issuer)
+			options[noptions++] =
+				CStringGetTextDatum(psprintf("issuer=%s", hba->oauth_issuer));
+
+		if (hba->oauth_scope)
+			options[noptions++] =
+				CStringGetTextDatum(psprintf("scope=%s", hba->oauth_scope));
+
+		if (hba->oauth_validator)
+			options[noptions++] =
+				CStringGetTextDatum(psprintf("validator=%s", hba->oauth_validator));
+
+		if (hba->oauth_skip_usermap)
+			options[noptions++] =
+				CStringGetTextDatum(psprintf("delegate_ident_mapping=true"));
+	}
+
 	/* If you add more options, consider increasing MAX_HBA_OPTIONS. */
 	Assert(noptions <= MAX_HBA_OPTIONS);
 
diff --git a/src/backend/utils/misc/guc_tables.c b/src/backend/utils/misc/guc_tables.c
index ce7534d4d2..7747a09c2a 100644
--- a/src/backend/utils/misc/guc_tables.c
+++ b/src/backend/utils/misc/guc_tables.c
@@ -49,6 +49,7 @@
 #include "jit/jit.h"
 #include "libpq/auth.h"
 #include "libpq/libpq.h"
+#include "libpq/oauth.h"
 #include "libpq/scram.h"
 #include "nodes/queryjumble.h"
 #include "optimizer/cost.h"
@@ -4832,6 +4833,17 @@ struct config_string ConfigureNamesString[] =
 		check_restrict_nonsystem_relation_kind, assign_restrict_nonsystem_relation_kind, NULL
 	},
 
+	{
+		{"oauth_validator_libraries", PGC_SIGHUP, CONN_AUTH_AUTH,
+			gettext_noop("Lists libraries that may be called to validate OAuth v2 bearer tokens."),
+			NULL,
+			GUC_LIST_INPUT | GUC_LIST_QUOTE | GUC_SUPERUSER_ONLY
+		},
+		&oauth_validator_libraries_string,
+		"",
+		NULL, NULL, NULL
+	},
+
 	/* End-of-list marker */
 	{
 		{NULL, 0, 0, NULL, NULL}, NULL, NULL, NULL, NULL, NULL
diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample
index c40b7a3121..9184ea0f1d 100644
--- a/src/backend/utils/misc/postgresql.conf.sample
+++ b/src/backend/utils/misc/postgresql.conf.sample
@@ -121,6 +121,9 @@
 #ssl_passphrase_command = ''
 #ssl_passphrase_command_supports_reload = off
 
+# OAuth
+#oauth_validator_libraries = ''	# comma-separated list of trusted validator modules
+
 
 #------------------------------------------------------------------------------
 # RESOURCE USAGE (except WAL)
diff --git a/src/include/common/oauth-common.h b/src/include/common/oauth-common.h
new file mode 100644
index 0000000000..8fe5626778
--- /dev/null
+++ b/src/include/common/oauth-common.h
@@ -0,0 +1,19 @@
+/*-------------------------------------------------------------------------
+ *
+ * oauth-common.h
+ *		Declarations for helper functions used for OAuth/OIDC authentication
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/include/common/oauth-common.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef OAUTH_COMMON_H
+#define OAUTH_COMMON_H
+
+/* Name of SASL mechanism per IANA */
+#define OAUTHBEARER_NAME "OAUTHBEARER"
+
+#endif							/* OAUTH_COMMON_H */
diff --git a/src/include/libpq/auth.h b/src/include/libpq/auth.h
index 902c5f6de3..25b5742068 100644
--- a/src/include/libpq/auth.h
+++ b/src/include/libpq/auth.h
@@ -39,6 +39,7 @@ extern PGDLLIMPORT bool pg_gss_accept_delegation;
 extern void ClientAuthentication(Port *port);
 extern void sendAuthRequest(Port *port, AuthRequest areq, const char *extradata,
 							int extralen);
+extern void set_authn_id(Port *port, const char *id);
 
 /* Hook for plugins to get control in ClientAuthentication() */
 typedef void (*ClientAuthentication_hook_type) (Port *, int);
diff --git a/src/include/libpq/hba.h b/src/include/libpq/hba.h
index b20d0051f7..3657f182db 100644
--- a/src/include/libpq/hba.h
+++ b/src/include/libpq/hba.h
@@ -39,7 +39,8 @@ typedef enum UserAuth
 	uaCert,
 	uaRADIUS,
 	uaPeer,
-#define USER_AUTH_LAST uaPeer	/* Must be last value of this enum */
+	uaOAuth,
+#define USER_AUTH_LAST uaOAuth	/* Must be last value of this enum */
 } UserAuth;
 
 /*
@@ -135,6 +136,10 @@ typedef struct HbaLine
 	char	   *radiusidentifiers_s;
 	List	   *radiusports;
 	char	   *radiusports_s;
+	char	   *oauth_issuer;
+	char	   *oauth_scope;
+	char	   *oauth_validator;
+	bool		oauth_skip_usermap;
 } HbaLine;
 
 typedef struct IdentLine
diff --git a/src/include/libpq/oauth.h b/src/include/libpq/oauth.h
new file mode 100644
index 0000000000..4fcdda7430
--- /dev/null
+++ b/src/include/libpq/oauth.h
@@ -0,0 +1,54 @@
+/*-------------------------------------------------------------------------
+ *
+ * oauth.h
+ *	  Interface to libpq/auth-oauth.c
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/include/libpq/oauth.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PG_OAUTH_H
+#define PG_OAUTH_H
+
+#include "libpq/libpq-be.h"
+#include "libpq/sasl.h"
+
+extern PGDLLIMPORT char *oauth_validator_libraries_string;
+
+typedef struct ValidatorModuleState
+{
+	void	   *private_data;
+} ValidatorModuleState;
+
+typedef struct ValidatorModuleResult
+{
+	bool		authorized;
+	char	   *authn_id;
+} ValidatorModuleResult;
+
+typedef void (*ValidatorStartupCB) (ValidatorModuleState *state);
+typedef void (*ValidatorShutdownCB) (ValidatorModuleState *state);
+typedef ValidatorModuleResult *(*ValidatorValidateCB) (ValidatorModuleState *state, const char *token, const char *role);
+
+typedef struct OAuthValidatorCallbacks
+{
+	ValidatorStartupCB startup_cb;
+	ValidatorShutdownCB shutdown_cb;
+	ValidatorValidateCB validate_cb;
+} OAuthValidatorCallbacks;
+
+typedef const OAuthValidatorCallbacks *(*OAuthValidatorModuleInit) (void);
+extern PGDLLEXPORT const OAuthValidatorCallbacks *_PG_oauth_validator_module_init(void);
+
+/* Implementation */
+extern const pg_be_sasl_mech pg_be_oauth_mech;
+
+/*
+ * Ensure a validator named in the HBA is permitted by the configuration.
+ */
+extern bool check_oauth_validator(HbaLine *hba, int elevel, char **err_msg);
+
+#endif							/* PG_OAUTH_H */
diff --git a/src/include/pg_config.h.in b/src/include/pg_config.h.in
index 07b2f798ab..c04ee38d08 100644
--- a/src/include/pg_config.h.in
+++ b/src/include/pg_config.h.in
@@ -229,6 +229,9 @@
 /* Define to 1 if you have the `crypto' library (-lcrypto). */
 #undef HAVE_LIBCRYPTO
 
+/* Define to 1 if you have the `curl' library (-lcurl). */
+#undef HAVE_LIBCURL
+
 /* Define to 1 if you have the `ldap' library (-lldap). */
 #undef HAVE_LIBLDAP
 
@@ -442,6 +445,9 @@
 /* Define to 1 if you have the <termios.h> header file. */
 #undef HAVE_TERMIOS_H
 
+/* Define to 1 if curl_global_init() is guaranteed to be threadsafe. */
+#undef HAVE_THREADSAFE_CURL_GLOBAL_INIT
+
 /* Define to 1 if your compiler understands `typeof' or something similar. */
 #undef HAVE_TYPEOF
 
@@ -663,6 +669,9 @@
 /* Define to 1 to build with LDAP support. (--with-ldap) */
 #undef USE_LDAP
 
+/* Define to 1 to build with libcurl support. (--with-libcurl) */
+#undef USE_LIBCURL
+
 /* Define to 1 to build with XML support. (--with-libxml) */
 #undef USE_LIBXML
 
diff --git a/src/interfaces/libpq/Makefile b/src/interfaces/libpq/Makefile
index 701810a272..90b0b65db6 100644
--- a/src/interfaces/libpq/Makefile
+++ b/src/interfaces/libpq/Makefile
@@ -31,6 +31,7 @@ endif
 
 OBJS = \
 	$(WIN32RES) \
+	fe-auth-oauth.o \
 	fe-auth-scram.o \
 	fe-cancel.o \
 	fe-connect.o \
@@ -63,6 +64,10 @@ OBJS += \
 	fe-secure-gssapi.o
 endif
 
+ifeq ($(with_libcurl),yes)
+OBJS += fe-auth-oauth-curl.o
+endif
+
 ifeq ($(PORTNAME), cygwin)
 override shlib = cyg$(NAME)$(DLSUFFIX)
 endif
@@ -81,7 +86,7 @@ endif
 # that are built correctly for use in a shlib.
 SHLIB_LINK_INTERNAL = -lpgcommon_shlib -lpgport_shlib
 ifneq ($(PORTNAME), win32)
-SHLIB_LINK += $(filter -lcrypt -ldes -lcom_err -lcrypto -lk5crypto -lkrb5 -lgssapi_krb5 -lgss -lgssapi -lssl -lsocket -lnsl -lresolv -lintl -lm, $(LIBS)) $(LDAP_LIBS_FE) $(PTHREAD_LIBS)
+SHLIB_LINK += $(filter -lcrypt -ldes -lcom_err -lcrypto -lk5crypto -lkrb5 -lgssapi_krb5 -lgss -lgssapi -lssl -lcurl -lsocket -lnsl -lresolv -lintl -lm, $(LIBS)) $(LDAP_LIBS_FE) $(PTHREAD_LIBS)
 else
 SHLIB_LINK += $(filter -lcrypt -ldes -lcom_err -lcrypto -lk5crypto -lkrb5 -lgssapi32 -lssl -lsocket -lnsl -lresolv -lintl -lm $(PTHREAD_LIBS), $(LIBS)) $(LDAP_LIBS_FE)
 endif
@@ -110,6 +115,8 @@ backend_src = $(top_srcdir)/src/backend
 # which seems to insert references to that even in pure C code. Excluding
 # __tsan_func_exit is necessary when using ThreadSanitizer data race detector
 # which use this function for instrumentation of function exit.
+# libcurl registers an exit handler in the memory debugging code when running
+# with LeakSanitizer.
 # Skip the test when profiling, as gcc may insert exit() calls for that.
 # Also skip the test on platforms where libpq infrastructure may be provided
 # by statically-linked libraries, as we can't expect them to honor this
@@ -117,7 +124,7 @@ backend_src = $(top_srcdir)/src/backend
 libpq-refs-stamp: $(shlib)
 ifneq ($(enable_coverage), yes)
 ifeq (,$(filter solaris,$(PORTNAME)))
-	@if nm -A -u $< 2>/dev/null | grep -v -e __cxa_atexit -e __tsan_func_exit | grep exit; then \
+	@if nm -A -u $< 2>/dev/null | grep -v -e __cxa_atexit -e __tsan_func_exit -e _atexit | grep exit; then \
 		echo 'libpq must not be calling any function which invokes exit'; exit 1; \
 	fi
 endif
diff --git a/src/interfaces/libpq/exports.txt b/src/interfaces/libpq/exports.txt
index 2ad2cbf5ca..9b789cbec0 100644
--- a/src/interfaces/libpq/exports.txt
+++ b/src/interfaces/libpq/exports.txt
@@ -206,3 +206,6 @@ PQsocketPoll              203
 PQsetChunkedRowsMode      204
 PQgetCurrentTimeUSec      205
 PQservice                 206
+PQsetAuthDataHook         207
+PQgetAuthDataHook         208
+PQdefaultAuthDataHook     209
diff --git a/src/interfaces/libpq/fe-auth-oauth-curl.c b/src/interfaces/libpq/fe-auth-oauth-curl.c
new file mode 100644
index 0000000000..2407200ea9
--- /dev/null
+++ b/src/interfaces/libpq/fe-auth-oauth-curl.c
@@ -0,0 +1,2635 @@
+/*-------------------------------------------------------------------------
+ *
+ * fe-auth-oauth-curl.c
+ *	   The libcurl implementation of OAuth/OIDC authentication, using the
+ *	   OAuth Device Authorization Grant (RFC 8628).
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/interfaces/libpq/fe-auth-oauth-curl.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres_fe.h"
+
+#include <curl/curl.h>
+#include <math.h>
+#ifdef HAVE_SYS_EPOLL_H
+#include <sys/epoll.h>
+#include <sys/timerfd.h>
+#endif
+#ifdef HAVE_SYS_EVENT_H
+#include <sys/event.h>
+#endif
+#include <unistd.h>
+
+#include "common/jsonapi.h"
+#include "fe-auth.h"
+#include "fe-auth-oauth.h"
+#include "libpq-int.h"
+#include "mb/pg_wchar.h"
+
+#define MAX_OAUTH_RESPONSE_SIZE (1024 * 1024)
+
+/*
+ * Parsed JSON Representations
+ *
+ * As a general rule, we parse and cache only the fields we're currently using.
+ * When adding new fields, ensure the corresponding free_*() function is updated
+ * too.
+ */
+
+/*
+ * The OpenID Provider configuration (alternatively named "authorization server
+ * metadata") jointly described by OpenID Connect Discovery 1.0 and RFC 8414:
+ *
+ *     https://openid.net/specs/openid-connect-discovery-1_0.html
+ *     https://www.rfc-editor.org/rfc/rfc8414#section-3.2
+ */
+struct provider
+{
+	char	   *issuer;
+	char	   *token_endpoint;
+	char	   *device_authorization_endpoint;
+	struct curl_slist *grant_types_supported;
+};
+
+static void
+free_provider(struct provider *provider)
+{
+	free(provider->issuer);
+	free(provider->token_endpoint);
+	free(provider->device_authorization_endpoint);
+	curl_slist_free_all(provider->grant_types_supported);
+}
+
+/*
+ * The Device Authorization response, described by RFC 8628:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc8628#section-3.2
+ */
+struct device_authz
+{
+	char	   *device_code;
+	char	   *user_code;
+	char	   *verification_uri;
+	char	   *interval_str;
+
+	/* Fields below are parsed from the corresponding string above. */
+	int			interval;
+};
+
+static void
+free_device_authz(struct device_authz *authz)
+{
+	free(authz->device_code);
+	free(authz->user_code);
+	free(authz->verification_uri);
+	free(authz->interval_str);
+}
+
+/*
+ * The Token Endpoint error response, as described by RFC 6749:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc6749#section-5.2
+ *
+ * Note that this response type can also be returned from the Device
+ * Authorization Endpoint.
+ */
+struct token_error
+{
+	char	   *error;
+	char	   *error_description;
+};
+
+static void
+free_token_error(struct token_error *err)
+{
+	free(err->error);
+	free(err->error_description);
+}
+
+/*
+ * The Access Token response, as described by RFC 6749:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc6749#section-4.1.4
+ *
+ * During the Device Authorization flow, several temporary errors are expected
+ * as part of normal operation. To make it easy to handle these in the happy
+ * path, this contains an embedded token_error that is filled in if needed.
+ */
+struct token
+{
+	/* for successful responses */
+	char	   *access_token;
+	char	   *token_type;
+
+	/* for error responses */
+	struct token_error err;
+};
+
+static void
+free_token(struct token *tok)
+{
+	free(tok->access_token);
+	free(tok->token_type);
+	free_token_error(&tok->err);
+}
+
+/*
+ * Asynchronous State
+ */
+
+/* States for the overall async machine. */
+enum OAuthStep
+{
+	OAUTH_STEP_INIT = 0,
+	OAUTH_STEP_DISCOVERY,
+	OAUTH_STEP_DEVICE_AUTHORIZATION,
+	OAUTH_STEP_TOKEN_REQUEST,
+	OAUTH_STEP_WAIT_INTERVAL,
+};
+
+/*
+ * The async_ctx holds onto state that needs to persist across multiple calls
+ * to pg_fe_run_oauth_flow(). Almost everything interacts with this in some
+ * way.
+ */
+struct async_ctx
+{
+	enum OAuthStep step;		/* where are we in the flow? */
+
+#ifdef HAVE_SYS_EPOLL_H
+	int			timerfd;		/* a timerfd for signaling async timeouts */
+#endif
+	pgsocket	mux;			/* the multiplexer socket containing all
+								 * descriptors tracked by libcurl, plus the
+								 * timerfd */
+	CURLM	   *curlm;			/* top-level multi handle for libcurl
+								 * operations */
+	CURL	   *curl;			/* the (single) easy handle for serial
+								 * requests */
+
+	struct curl_slist *headers; /* common headers for all requests */
+	PQExpBufferData work_data;	/* scratch buffer for general use (remember to
+								 * clear out prior contents first!) */
+
+	/*------
+	 * Since a single logical operation may stretch across multiple calls to
+	 * our entry point, errors have three parts:
+	 *
+	 * - errctx:	an optional static string, describing the global operation
+	 *				currently in progress. It'll be translated for you.
+	 *
+	 * - errbuf:	contains the actual error message. Generally speaking, use
+	 *				actx_error[_str] to manipulate this. This must be filled
+	 *				with something useful on an error.
+	 *
+	 * - curl_err:	an optional static error buffer used by libcurl to put
+	 *				detailed information about failures. Unfortunately
+	 *				untranslatable.
+	 *
+	 * These pieces will be combined into a single error message looking
+	 * something like the following, with errctx and/or curl_err omitted when
+	 * absent:
+	 *
+	 *     connection to server ... failed: errctx: errbuf (curl_err)
+	 */
+	const char *errctx;			/* not freed; must point to static allocation */
+	PQExpBufferData errbuf;
+	char		curl_err[CURL_ERROR_SIZE];
+
+	/*
+	 * These documents need to survive over multiple calls, and are therefore
+	 * cached directly in the async_ctx.
+	 */
+	struct provider provider;
+	struct device_authz authz;
+
+	int			running;		/* is asynchronous work in progress? */
+	bool		user_prompted;	/* have we already sent the authz prompt? */
+	bool		used_basic_auth;	/* did we send a client secret? */
+	bool		debugging;		/* can we give unsafe developer assistance? */
+};
+
+/*
+ * Tears down the Curl handles and frees the async_ctx.
+ */
+static void
+free_async_ctx(PGconn *conn, struct async_ctx *actx)
+{
+	/*
+	 * TODO: in general, none of the error cases below should ever happen if
+	 * we have no bugs above. But if we do hit them, surfacing those errors
+	 * somehow might be the only way to have a chance to debug them. What's
+	 * the best way to do that? Assertions? Spraying messages on stderr?
+	 * Bubbling an error code to the top? Appending to the connection's error
+	 * message only helps if the bug caused a connection failure; otherwise
+	 * it'll be buried...
+	 */
+
+	if (actx->curlm && actx->curl)
+	{
+		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));
+	}
+
+	if (actx->curl)
+	{
+		/*
+		 * curl_multi_cleanup() doesn't free any associated easy handles; we
+		 * need to do that separately. We only ever have one easy handle per
+		 * multi handle.
+		 */
+		curl_easy_cleanup(actx->curl);
+	}
+
+	if (actx->curlm)
+	{
+		CURLMcode	err = curl_multi_cleanup(actx->curlm);
+
+		if (err)
+			libpq_append_conn_error(conn,
+									"libcurl multi handle cleanup failed: %s",
+									curl_multi_strerror(err));
+	}
+
+	free_provider(&actx->provider);
+	free_device_authz(&actx->authz);
+
+	curl_slist_free_all(actx->headers);
+	termPQExpBuffer(&actx->work_data);
+	termPQExpBuffer(&actx->errbuf);
+
+	if (actx->mux != PGINVALID_SOCKET)
+		close(actx->mux);
+#ifdef HAVE_SYS_EPOLL_H
+	if (actx->timerfd >= 0)
+		close(actx->timerfd);
+#endif
+
+	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.
+ */
+void
+pg_fe_cleanup_oauth_flow(PGconn *conn)
+{
+	fe_oauth_state *state = conn->sasl_state;
+
+	if (state->async_ctx)
+	{
+		free_async_ctx(conn, state->async_ctx);
+		state->async_ctx = NULL;
+	}
+
+	conn->altsock = PGINVALID_SOCKET;
+}
+
+/*
+ * Macros for manipulating actx->errbuf. actx_error() translates and formats a
+ * string for you; actx_error_str() appends a string directly without
+ * translation.
+ */
+
+#define actx_error(ACTX, FMT, ...) \
+	appendPQExpBuffer(&(ACTX)->errbuf, libpq_gettext(FMT), ##__VA_ARGS__)
+
+#define actx_error_str(ACTX, S) \
+	appendPQExpBufferStr(&(ACTX)->errbuf, S)
+
+/*
+ * Macros for getting and setting state for the connection's two libcurl
+ * handles, so you don't have to write out the error handling every time.
+ */
+
+#define CHECK_MSETOPT(ACTX, OPT, VAL, FAILACTION) \
+	do { \
+		struct async_ctx *_actx = (ACTX); \
+		CURLMcode	_setopterr = curl_multi_setopt(_actx->curlm, OPT, VAL); \
+		if (_setopterr) { \
+			actx_error(_actx, "failed to set %s on OAuth connection: %s",\
+					   #OPT, curl_multi_strerror(_setopterr)); \
+			FAILACTION; \
+		} \
+	} while (0)
+
+#define CHECK_SETOPT(ACTX, OPT, VAL, FAILACTION) \
+	do { \
+		struct async_ctx *_actx = (ACTX); \
+		CURLcode	_setopterr = curl_easy_setopt(_actx->curl, OPT, VAL); \
+		if (_setopterr) { \
+			actx_error(_actx, "failed to set %s on OAuth connection: %s",\
+					   #OPT, curl_easy_strerror(_setopterr)); \
+			FAILACTION; \
+		} \
+	} while (0)
+
+#define CHECK_GETINFO(ACTX, INFO, OUT, FAILACTION) \
+	do { \
+		struct async_ctx *_actx = (ACTX); \
+		CURLcode	_getinfoerr = curl_easy_getinfo(_actx->curl, INFO, OUT); \
+		if (_getinfoerr) { \
+			actx_error(_actx, "failed to get %s from OAuth response: %s",\
+					   #INFO, curl_easy_strerror(_getinfoerr)); \
+			FAILACTION; \
+		} \
+	} while (0)
+
+/*
+ * General JSON Parsing for OAuth Responses
+ */
+
+/*
+ * Represents a single name/value pair in a JSON object. This is the primary
+ * interface to parse_oauth_json().
+ *
+ * All fields are stored internally as strings or lists of strings, so clients
+ * have to explicitly parse other scalar types (though they will have gone
+ * through basic lexical validation). Storing nested objects is not currently
+ * supported, nor is parsing arrays of anything other than strings.
+ */
+struct json_field
+{
+	const char *name;			/* name (key) of the member */
+
+	JsonTokenType type;			/* currently supports JSON_TOKEN_STRING,
+								 * JSON_TOKEN_NUMBER, and
+								 * JSON_TOKEN_ARRAY_START */
+	union
+	{
+		char	  **scalar;		/* for all scalar types */
+		struct curl_slist **array;	/* for type == JSON_TOKEN_ARRAY_START */
+	}			target;
+
+	bool		required;		/* REQUIRED field, or just OPTIONAL? */
+};
+
+/* Documentation macros for json_field.required. */
+#define REQUIRED true
+#define OPTIONAL false
+
+/* Parse state for parse_oauth_json(). */
+struct oauth_parse
+{
+	PQExpBuffer errbuf;			/* detail message for JSON_SEM_ACTION_FAILED */
+	int			nested;			/* nesting level (zero is the top) */
+
+	const struct json_field *fields;	/* field definition array */
+	const struct json_field *active;	/* points inside the fields array */
+};
+
+#define oauth_parse_set_error(ctx, fmt, ...) \
+	appendPQExpBuffer((ctx)->errbuf, libpq_gettext(fmt), ##__VA_ARGS__)
+
+static void
+report_type_mismatch(struct oauth_parse *ctx)
+{
+	char	   *msgfmt;
+
+	Assert(ctx->active);
+
+	/*
+	 * At the moment, the only fields we're interested in are strings,
+	 * numbers, and arrays of strings.
+	 */
+	switch (ctx->active->type)
+	{
+		case JSON_TOKEN_STRING:
+			msgfmt = "field \"%s\" must be a string";
+			break;
+
+		case JSON_TOKEN_NUMBER:
+			msgfmt = "field \"%s\" must be a number";
+			break;
+
+		case JSON_TOKEN_ARRAY_START:
+			msgfmt = "field \"%s\" must be an array of strings";
+			break;
+
+		default:
+			Assert(false);
+			msgfmt = "field \"%s\" has unexpected type";
+	}
+
+	oauth_parse_set_error(ctx, msgfmt, ctx->active->name);
+}
+
+static JsonParseErrorType
+oauth_json_object_start(void *state)
+{
+	struct oauth_parse *ctx = state;
+
+	if (ctx->active)
+	{
+		/*
+		 * Currently, none of the fields we're interested in can be or contain
+		 * objects, so we can reject this case outright.
+		 */
+		report_type_mismatch(ctx);
+		return JSON_SEM_ACTION_FAILED;
+	}
+
+	++ctx->nested;
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_object_field_start(void *state, char *name, bool isnull)
+{
+	struct oauth_parse *ctx = state;
+
+	/* We care only about the top-level fields. */
+	if (ctx->nested == 1)
+	{
+		const struct json_field *field = ctx->fields;
+
+		/*
+		 * We should never start parsing a new field while a previous one is
+		 * still active.
+		 *
+		 * TODO: this code relies on assertions too much. We need to exit
+		 * sanely on internal logic errors, to avoid turning bugs into
+		 * vulnerabilities.
+		 */
+		Assert(!ctx->active);
+
+		while (field->name)
+		{
+			if (strcmp(name, field->name) == 0)
+			{
+				ctx->active = field;
+				break;
+			}
+
+			++field;
+		}
+
+		/*
+		 * We don't allow duplicate field names; error out if the target has
+		 * already been set.
+		 */
+		if (ctx->active)
+		{
+			field = ctx->active;
+
+			if ((field->type == JSON_TOKEN_ARRAY_START && *field->target.array)
+				|| (field->type != JSON_TOKEN_ARRAY_START && *field->target.scalar))
+			{
+				oauth_parse_set_error(ctx, "field \"%s\" is duplicated",
+									  field->name);
+				return JSON_SEM_ACTION_FAILED;
+			}
+		}
+	}
+
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_object_end(void *state)
+{
+	struct oauth_parse *ctx = state;
+
+	--ctx->nested;
+	if (!ctx->nested)
+		Assert(!ctx->active);	/* all fields should be fully processed */
+
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_array_start(void *state)
+{
+	struct oauth_parse *ctx = state;
+
+	if (!ctx->nested)
+	{
+		oauth_parse_set_error(ctx, "top-level element must be an object");
+		return JSON_SEM_ACTION_FAILED;
+	}
+
+	if (ctx->active)
+	{
+		if (ctx->active->type != JSON_TOKEN_ARRAY_START
+		/* The arrays we care about must not have arrays as values. */
+			|| ctx->nested > 1)
+		{
+			report_type_mismatch(ctx);
+			return JSON_SEM_ACTION_FAILED;
+		}
+	}
+
+	++ctx->nested;
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_array_end(void *state)
+{
+	struct oauth_parse *ctx = state;
+
+	if (ctx->active)
+	{
+		/*
+		 * This assumes that no target arrays can contain other arrays, which
+		 * we check in the array_start callback.
+		 */
+		Assert(ctx->nested == 2);
+		Assert(ctx->active->type == JSON_TOKEN_ARRAY_START);
+
+		ctx->active = NULL;
+	}
+
+	--ctx->nested;
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_scalar(void *state, char *token, JsonTokenType type)
+{
+	struct oauth_parse *ctx = state;
+
+	if (!ctx->nested)
+	{
+		oauth_parse_set_error(ctx, "top-level element must be an object");
+		return JSON_SEM_ACTION_FAILED;
+	}
+
+	if (ctx->active)
+	{
+		const struct json_field *field = ctx->active;
+		JsonTokenType expected = field->type;
+
+		/* Make sure this matches what the active field expects. */
+		if (expected == JSON_TOKEN_ARRAY_START)
+		{
+			/* Are we actually inside an array? */
+			if (ctx->nested < 2)
+			{
+				report_type_mismatch(ctx);
+				return JSON_SEM_ACTION_FAILED;
+			}
+
+			/* Currently, arrays can only contain strings. */
+			expected = JSON_TOKEN_STRING;
+		}
+
+		if (type != expected)
+		{
+			report_type_mismatch(ctx);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		if (field->type != JSON_TOKEN_ARRAY_START)
+		{
+			Assert(ctx->nested == 1);
+			Assert(!*field->target.scalar);
+
+			*field->target.scalar = strdup(token);
+			if (!*field->target.scalar)
+				return JSON_OUT_OF_MEMORY;
+
+			ctx->active = NULL;
+
+			return JSON_SUCCESS;
+		}
+		else
+		{
+			struct curl_slist *temp;
+
+			Assert(ctx->nested == 2);
+
+			/* Note that curl_slist_append() makes a copy of the token. */
+			temp = curl_slist_append(*field->target.array, token);
+			if (!temp)
+				return JSON_OUT_OF_MEMORY;
+
+			*field->target.array = temp;
+		}
+	}
+	else
+	{
+		/* otherwise we just ignore it */
+	}
+
+	return JSON_SUCCESS;
+}
+
+/*
+ * Checks the Content-Type header against the expected type. Parameters are
+ * allowed but ignored.
+ */
+static bool
+check_content_type(struct async_ctx *actx, const char *type)
+{
+	const size_t type_len = strlen(type);
+	char	   *content_type;
+
+	CHECK_GETINFO(actx, CURLINFO_CONTENT_TYPE, &content_type, return false);
+
+	if (!content_type)
+	{
+		actx_error(actx, "no content type was provided");
+		return false;
+	}
+
+	/*
+	 * We need to perform a length limited comparison and not compare the
+	 * whole string.
+	 */
+	if (pg_strncasecmp(content_type, type, type_len) != 0)
+		goto fail;
+
+	/* On an exact match, we're done. */
+	Assert(strlen(content_type) >= type_len);
+	if (content_type[type_len] == '\0')
+		return true;
+
+	/*
+	 * Only a semicolon (optionally preceded by HTTP optional whitespace) is
+	 * acceptable after the prefix we checked. This marks the start of media
+	 * type parameters, which we currently have no use for.
+	 */
+	for (size_t i = type_len; content_type[i]; ++i)
+	{
+		switch (content_type[i])
+		{
+			case ';':
+				return true;	/* success! */
+
+			case ' ':
+			case '\t':
+				/* HTTP optional whitespace allows only spaces and htabs. */
+				break;
+
+			default:
+				goto fail;
+		}
+	}
+
+fail:
+	actx_error(actx, "unexpected content type: \"%s\"", content_type);
+	return false;
+}
+
+/*
+ * A helper function for general JSON parsing. fields is the array of field
+ * definitions with their backing pointers. The response will be parsed from
+ * actx->curl and actx->work_data (as set up by start_request()), and any
+ * parsing errors will be placed into actx->errbuf.
+ */
+static bool
+parse_oauth_json(struct async_ctx *actx, const struct json_field *fields)
+{
+	PQExpBuffer resp = &actx->work_data;
+	JsonLexContext lex = {0};
+	JsonSemAction sem = {0};
+	JsonParseErrorType err;
+	struct oauth_parse ctx = {0};
+	bool		success = false;
+
+	if (!check_content_type(actx, "application/json"))
+		return false;
+
+	if (strlen(resp->data) != resp->len)
+	{
+		actx_error(actx, "response contains embedded NULLs");
+		return false;
+	}
+
+	/*
+	 * pg_parse_json doesn't validate the incoming UTF-8, so we have to check
+	 * that up front.
+	 */
+	if (pg_encoding_verifymbstr(PG_UTF8, resp->data, resp->len) != resp->len)
+	{
+		actx_error(actx, "response is not valid UTF-8");
+		return false;
+	}
+
+	makeJsonLexContextCstringLen(&lex, resp->data, resp->len, PG_UTF8, true);
+	setJsonLexContextOwnsTokens(&lex, true);	/* must not leak on error */
+
+	ctx.errbuf = &actx->errbuf;
+	ctx.fields = fields;
+	sem.semstate = &ctx;
+
+	sem.object_start = oauth_json_object_start;
+	sem.object_field_start = oauth_json_object_field_start;
+	sem.object_end = oauth_json_object_end;
+	sem.array_start = oauth_json_array_start;
+	sem.array_end = oauth_json_array_end;
+	sem.scalar = oauth_json_scalar;
+
+	err = pg_parse_json(&lex, &sem);
+
+	if (err != JSON_SUCCESS)
+	{
+		/*
+		 * For JSON_SEM_ACTION_FAILED, we've already written the error
+		 * message. Other errors come directly from pg_parse_json(), already
+		 * translated.
+		 */
+		if (err != JSON_SEM_ACTION_FAILED)
+			actx_error_str(actx, json_errdetail(err, &lex));
+
+		goto cleanup;
+	}
+
+	/* Check all required fields. */
+	while (fields->name)
+	{
+		if (fields->required
+			&& !*fields->target.scalar
+			&& !*fields->target.array)
+		{
+			actx_error(actx, "field \"%s\" is missing", fields->name);
+			goto cleanup;
+		}
+
+		fields++;
+	}
+
+	success = true;
+
+cleanup:
+	freeJsonLexContext(&lex);
+	return success;
+}
+
+/*
+ * JSON Parser Definitions
+ */
+
+/*
+ * Parses authorization server metadata. Fields are defined by OIDC Discovery
+ * 1.0 and RFC 8414.
+ */
+static bool
+parse_provider(struct async_ctx *actx, struct provider *provider)
+{
+	struct json_field fields[] = {
+		{"issuer", JSON_TOKEN_STRING, {&provider->issuer}, REQUIRED},
+		{"token_endpoint", JSON_TOKEN_STRING, {&provider->token_endpoint}, REQUIRED},
+
+		/*----
+		 * The following fields are technically REQUIRED, but we don't use
+		 * them anywhere yet:
+		 *
+		 * - jwks_uri
+		 * - response_types_supported
+		 * - subject_types_supported
+		 * - id_token_signing_alg_values_supported
+		 */
+
+		{"device_authorization_endpoint", JSON_TOKEN_STRING, {&provider->device_authorization_endpoint}, OPTIONAL},
+		{"grant_types_supported", JSON_TOKEN_ARRAY_START, {.array = &provider->grant_types_supported}, OPTIONAL},
+
+		{0},
+	};
+
+	return parse_oauth_json(actx, fields);
+}
+
+/*
+ * Parses the "interval" JSON number, corresponding to the number of seconds to
+ * wait between token endpoint requests.
+ *
+ * RFC 8628 is pretty silent on sanity checks for the interval. As a matter of
+ * practicality, round any fractional intervals up to the next second, and clamp
+ * the result at a minimum of one. (Zero-second intervals would result in an
+ * expensive network polling loop.) Tests may remove the lower bound with
+ * PGOAUTHDEBUG, for improved performance.
+ *
+ * TODO: maybe clamp the upper bound too, based on the libpq timeout and/or the
+ * code expiration time?
+ */
+static int
+parse_interval(struct async_ctx *actx, const char *interval_str)
+{
+	double		parsed;
+	int			cnt;
+
+	/*
+	 * The JSON lexer has already validated the number, which is stricter than
+	 * the %f format, so we should be good to use sscanf().
+	 */
+	cnt = sscanf(interval_str, "%lf", &parsed);
+
+	if (cnt != 1)
+	{
+		/*
+		 * Either the lexer screwed up or our assumption above isn't true, and
+		 * either way a developer needs to take a look.
+		 */
+		Assert(cnt == 1);
+		return 1;				/* don't fall through in release builds */
+	}
+
+	parsed = ceil(parsed);
+
+	if (parsed < 1)
+		return actx->debugging ? 0 : 1;
+
+	else if (INT_MAX <= parsed)
+		return INT_MAX;
+
+	return parsed;
+}
+
+/*
+ * Parses the Device Authorization Response (RFC 8628, Sec. 3.2).
+ */
+static bool
+parse_device_authz(struct async_ctx *actx, struct device_authz *authz)
+{
+	struct json_field fields[] = {
+		{"device_code", JSON_TOKEN_STRING, {&authz->device_code}, REQUIRED},
+		{"user_code", JSON_TOKEN_STRING, {&authz->user_code}, REQUIRED},
+		{"verification_uri", JSON_TOKEN_STRING, {&authz->verification_uri}, REQUIRED},
+
+		/*
+		 * Some services (Google, Azure) spell verification_uri differently.
+		 * We accept either.
+		 */
+		{"verification_url", JSON_TOKEN_STRING, {&authz->verification_uri}, REQUIRED},
+
+		/*
+		 * The following fields are technically REQUIRED, but we don't use
+		 * them anywhere yet:
+		 *
+		 * - expires_in
+		 */
+
+		{"interval", JSON_TOKEN_NUMBER, {&authz->interval_str}, OPTIONAL},
+
+		{0},
+	};
+
+	if (!parse_oauth_json(actx, fields))
+		return false;
+
+	/*
+	 * Parse our numeric fields. Lexing has already completed by this time, so
+	 * we at least know they're valid JSON numbers.
+	 */
+	if (authz->interval_str)
+		authz->interval = parse_interval(actx, authz->interval_str);
+	else
+	{
+		/*
+		 * RFC 8628 specifies 5 seconds as the default value if the server
+		 * doesn't provide an interval.
+		 */
+		authz->interval = 5;
+	}
+
+	return true;
+}
+
+/*
+ * Parses the device access token error response (RFC 8628, Sec. 3.5, which
+ * uses the error response defined in RFC 6749, Sec. 5.2).
+ */
+static bool
+parse_token_error(struct async_ctx *actx, struct token_error *err)
+{
+	bool		result;
+	struct json_field fields[] = {
+		{"error", JSON_TOKEN_STRING, {&err->error}, REQUIRED},
+
+		{"error_description", JSON_TOKEN_STRING, {&err->error_description}, OPTIONAL},
+
+		{0},
+	};
+
+	result = parse_oauth_json(actx, fields);
+
+	/*
+	 * Since token errors are parsed during other active error paths, only
+	 * override the errctx if parsing explicitly fails.
+	 */
+	if (!result)
+		actx->errctx = "failed to parse token error response";
+
+	return result;
+}
+
+/*
+ * Constructs a message from the token error response and puts it into
+ * actx->errbuf.
+ */
+static void
+record_token_error(struct async_ctx *actx, const struct token_error *err)
+{
+	if (err->error_description)
+		appendPQExpBuffer(&actx->errbuf, "%s ", err->error_description);
+	else
+	{
+		/*
+		 * Try to get some more helpful detail into the error string. A 401
+		 * status in particular implies that the oauth_client_secret is
+		 * missing or wrong.
+		 */
+		long		response_code;
+
+		CHECK_GETINFO(actx, CURLINFO_RESPONSE_CODE, &response_code, response_code = 0);
+
+		if (response_code == 401)
+		{
+			actx_error(actx, actx->used_basic_auth
+					   ? "provider rejected the oauth_client_secret"
+					   : "provider requires client authentication, and no oauth_client_secret is set");
+			actx_error_str(actx, " ");
+		}
+	}
+
+	appendPQExpBuffer(&actx->errbuf, "(%s)", err->error);
+}
+
+/*
+ * Parses the device access token response (RFC 8628, Sec. 3.5, which uses the
+ * success response defined in RFC 6749, Sec. 5.1).
+ */
+static bool
+parse_access_token(struct async_ctx *actx, struct token *tok)
+{
+	struct json_field fields[] = {
+		{"access_token", JSON_TOKEN_STRING, {&tok->access_token}, REQUIRED},
+		{"token_type", JSON_TOKEN_STRING, {&tok->token_type}, REQUIRED},
+
+		/*
+		 * The following fields are technically REQUIRED, but we don't use
+		 * them anywhere yet:
+		 *
+		 * - scope (only required if different than requested -- TODO check)
+		 */
+
+		{0},
+	};
+
+	return parse_oauth_json(actx, fields);
+}
+
+/*
+ * libcurl Multi Setup/Callbacks
+ */
+
+/*
+ * Sets up the actx->mux, which is the altsock that PQconnectPoll clients will
+ * select() on instead of the Postgres socket during OAuth negotiation.
+ *
+ * This is just an epoll set or kqueue abstracting multiple other descriptors.
+ * A timerfd is always part of the set when using epoll; it's just disabled
+ * when we're not using it.
+ */
+static bool
+setup_multiplexer(struct async_ctx *actx)
+{
+#ifdef HAVE_SYS_EPOLL_H
+	struct epoll_event ev = {.events = EPOLLIN};
+
+	actx->mux = epoll_create1(EPOLL_CLOEXEC);
+	if (actx->mux < 0)
+	{
+		actx_error(actx, "failed to create epoll set: %m");
+		return false;
+	}
+
+	actx->timerfd = timerfd_create(CLOCK_MONOTONIC, TFD_CLOEXEC);
+	if (actx->timerfd < 0)
+	{
+		actx_error(actx, "failed to create timerfd: %m");
+		return false;
+	}
+
+	if (epoll_ctl(actx->mux, EPOLL_CTL_ADD, actx->timerfd, &ev) < 0)
+	{
+		actx_error(actx, "failed to add timerfd to epoll set: %m");
+		return false;
+	}
+
+	return true;
+#endif
+#ifdef HAVE_SYS_EVENT_H
+	actx->mux = kqueue();
+	if (actx->mux < 0)
+	{
+		actx_error(actx, "failed to create kqueue: %m");
+		return false;
+	}
+
+	return true;
+#endif
+
+	actx_error(actx, "libpq does not support the Device Authorization flow on this platform");
+	return false;
+}
+
+/*
+ * Adds and removes sockets from the multiplexer set, as directed by the
+ * libcurl multi handle.
+ */
+static int
+register_socket(CURL *curl, curl_socket_t socket, int what, void *ctx,
+				void *socketp)
+{
+#ifdef HAVE_SYS_EPOLL_H
+	struct async_ctx *actx = ctx;
+	struct epoll_event ev = {0};
+	int			res;
+	int			op = EPOLL_CTL_ADD;
+
+	switch (what)
+	{
+		case CURL_POLL_IN:
+			ev.events = EPOLLIN;
+			break;
+
+		case CURL_POLL_OUT:
+			ev.events = EPOLLOUT;
+			break;
+
+		case CURL_POLL_INOUT:
+			ev.events = EPOLLIN | EPOLLOUT;
+			break;
+
+		case CURL_POLL_REMOVE:
+			op = EPOLL_CTL_DEL;
+			break;
+
+		default:
+			actx_error(actx, "unknown libcurl socket operation: %d", what);
+			return -1;
+	}
+
+	res = epoll_ctl(actx->mux, op, socket, &ev);
+	if (res < 0 && errno == EEXIST)
+	{
+		/* We already had this socket in the pollset. */
+		op = EPOLL_CTL_MOD;
+		res = epoll_ctl(actx->mux, op, socket, &ev);
+	}
+
+	if (res < 0)
+	{
+		switch (op)
+		{
+			case EPOLL_CTL_ADD:
+				actx_error(actx, "could not add to epoll set: %m");
+				break;
+
+			case EPOLL_CTL_DEL:
+				actx_error(actx, "could not delete from epoll set: %m");
+				break;
+
+			default:
+				actx_error(actx, "could not update epoll set: %m");
+		}
+
+		return -1;
+	}
+#endif
+#ifdef HAVE_SYS_EVENT_H
+	struct async_ctx *actx = ctx;
+	struct kevent ev[2] = {{0}};
+	struct kevent ev_out[2];
+	struct timespec timeout = {0};
+	int			nev = 0;
+	int			res;
+
+	switch (what)
+	{
+		case CURL_POLL_IN:
+			EV_SET(&ev[nev], socket, EVFILT_READ, EV_ADD | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			break;
+
+		case CURL_POLL_OUT:
+			EV_SET(&ev[nev], socket, EVFILT_WRITE, EV_ADD | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			break;
+
+		case CURL_POLL_INOUT:
+			EV_SET(&ev[nev], socket, EVFILT_READ, EV_ADD | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			EV_SET(&ev[nev], socket, EVFILT_WRITE, EV_ADD | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			break;
+
+		case CURL_POLL_REMOVE:
+
+			/*
+			 * We don't know which of these is currently registered, perhaps
+			 * both, so we try to remove both.  This means we need to tolerate
+			 * ENOENT below.
+			 */
+			EV_SET(&ev[nev], socket, EVFILT_READ, EV_DELETE | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			EV_SET(&ev[nev], socket, EVFILT_WRITE, EV_DELETE | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			break;
+
+		default:
+			actx_error(actx, "unknown libcurl socket operation: %d", what);
+			return -1;
+	}
+
+	res = kevent(actx->mux, ev, nev, ev_out, lengthof(ev_out), &timeout);
+	if (res < 0)
+	{
+		actx_error(actx, "could not modify kqueue: %m");
+		return -1;
+	}
+
+	/*
+	 * We can't use the simple errno version of kevent, because we need to
+	 * skip over ENOENT while still allowing a second change to be processed.
+	 * So we need a longer-form error checking loop.
+	 */
+	for (int i = 0; i < res; ++i)
+	{
+		/*
+		 * EV_RECEIPT should guarantee one EV_ERROR result for every change,
+		 * whether successful or not. Failed entries contain a non-zero errno
+		 * in the data field.
+		 */
+		Assert(ev_out[i].flags & EV_ERROR);
+
+		errno = ev_out[i].data;
+		if (errno && errno != ENOENT)
+		{
+			switch (what)
+			{
+				case CURL_POLL_REMOVE:
+					actx_error(actx, "could not delete from kqueue: %m");
+					break;
+				default:
+					actx_error(actx, "could not add to kqueue: %m");
+			}
+			return -1;
+		}
+	}
+#endif
+
+	return 0;
+}
+
+/*
+ * Enables or disables the timer in the multiplexer set. The timeout value is
+ * in milliseconds (negative values disable the timer). Rather than continually
+ * adding and removing the timer, we keep it in the set at all times and just
+ * disarm it when it's not needed.
+ */
+static bool
+set_timer(struct async_ctx *actx, long timeout)
+{
+#if HAVE_SYS_EPOLL_H
+	struct itimerspec spec = {0};
+
+	if (timeout < 0)
+	{
+		/* the zero itimerspec will disarm the timer below */
+	}
+	else if (timeout == 0)
+	{
+		/*
+		 * A zero timeout means libcurl wants us to call back immediately.
+		 * That's not technically an option for timerfd, but we can make the
+		 * timeout ridiculously short.
+		 */
+		spec.it_value.tv_nsec = 1;
+	}
+	else
+	{
+		spec.it_value.tv_sec = timeout / 1000;
+		spec.it_value.tv_nsec = (timeout % 1000) * 1000000;
+	}
+
+	if (timerfd_settime(actx->timerfd, 0 /* no flags */ , &spec, NULL) < 0)
+	{
+		actx_error(actx, "setting timerfd to %ld: %m", timeout);
+		return false;
+	}
+#endif
+#ifdef HAVE_SYS_EVENT_H
+	struct kevent ev;
+
+	EV_SET(&ev, 1, EVFILT_TIMER, timeout < 0 ? EV_DELETE : EV_ADD,
+		   0, timeout, 0);
+	if (kevent(actx->mux, &ev, 1, NULL, 0, NULL) < 0 && errno != ENOENT)
+	{
+		actx_error(actx, "setting kqueue timer to %ld: %m", timeout);
+		return false;
+	}
+#endif
+
+	return true;
+}
+
+/*
+ * Adds or removes timeouts from the multiplexer set, as directed by the
+ * libcurl multi handle.
+ */
+static int
+register_timer(CURLM *curlm, long timeout, void *ctx)
+{
+	struct async_ctx *actx = ctx;
+
+	/*
+	 * TODO: maybe just signal drive_request() to immediately call back in the
+	 * (timeout == 0) case?
+	 */
+	if (!set_timer(actx, timeout))
+		return -1;				/* actx_error already called */
+
+	return 0;
+}
+
+/*
+ * Prints Curl request debugging information to stderr.
+ *
+ * Note that this will expose a number of critical secrets, so users have to opt
+ * into this (see PGOAUTHDEBUG).
+ */
+static int
+debug_callback(CURL *handle, curl_infotype type, char *data, size_t size,
+			   void *clientp)
+{
+	const char *const end = data + size;
+	const char *prefix;
+
+	/* Prefixes are modeled off of the default libcurl debug output. */
+	switch (type)
+	{
+		case CURLINFO_TEXT:
+			prefix = "*";
+			break;
+
+		case CURLINFO_HEADER_IN:	/* fall through */
+		case CURLINFO_DATA_IN:
+			prefix = "<";
+			break;
+
+		case CURLINFO_HEADER_OUT:	/* fall through */
+		case CURLINFO_DATA_OUT:
+			prefix = ">";
+			break;
+
+		default:
+			return 0;
+	}
+
+	/*
+	 * Split the output into lines for readability; sometimes multiple headers
+	 * are included in a single call.
+	 */
+	while (data < end)
+	{
+		size_t		len = end - data;
+		char	   *eol = memchr(data, '\n', len);
+
+		if (eol)
+			len = eol - data + 1;
+
+		/* TODO: handle unprintables */
+		fprintf(stderr, "%s %.*s%s", prefix, (int) len, data,
+				eol ? "" : "\n");
+
+		data += len;
+	}
+
+	return 0;
+}
+
+/*
+ * Initializes the two libcurl handles in the async_ctx. The multi handle,
+ * actx->curlm, is what drives the asynchronous engine and tells us what to do
+ * next. The easy handle, actx->curl, encapsulates the state for a single
+ * request/response. It's added to the multi handle as needed, during
+ * start_request().
+ */
+static bool
+setup_curl_handles(struct async_ctx *actx)
+{
+	curl_version_info_data *curl_info;
+
+	/*
+	 * Create our multi handle. This encapsulates the entire conversation with
+	 * libcurl for this connection.
+	 */
+	actx->curlm = curl_multi_init();
+	if (!actx->curlm)
+	{
+		/* We don't get a lot of feedback on the failure reason. */
+		actx_error(actx, "failed to create libcurl multi handle");
+		return false;
+	}
+
+	/*
+	 * Extract information about the libcurl we are linked against.
+	 */
+	curl_info = curl_version_info(CURLVERSION_NOW);
+
+	/*
+	 * The multi handle tells us what to wait on using two callbacks. These
+	 * will manipulate actx->mux as needed.
+	 */
+	CHECK_MSETOPT(actx, CURLMOPT_SOCKETFUNCTION, register_socket, return false);
+	CHECK_MSETOPT(actx, CURLMOPT_SOCKETDATA, actx, return false);
+	CHECK_MSETOPT(actx, CURLMOPT_TIMERFUNCTION, register_timer, return false);
+	CHECK_MSETOPT(actx, CURLMOPT_TIMERDATA, actx, return false);
+
+	/*
+	 * Set up an easy handle. All of our requests are made serially, so we
+	 * only ever need to keep track of one.
+	 */
+	actx->curl = curl_easy_init();
+	if (!actx->curl)
+	{
+		actx_error(actx, "failed to create libcurl handle");
+		return false;
+	}
+
+	/*
+	 * Multi-threaded applications must set CURLOPT_NOSIGNAL. This requires us
+	 * to handle the possibility of SIGPIPE ourselves using pq_block_sigpipe;
+	 * see pg_fe_run_oauth_flow().
+	 */
+	CHECK_SETOPT(actx, CURLOPT_NOSIGNAL, 1L, return false);
+	if (!curl_info->ares_num)
+	{
+		/* No alternative resolver, TODO: warn about timeouts */
+	}
+
+	if (actx->debugging)
+	{
+		/*
+		 * Set a callback for retrieving error information from libcurl, the
+		 * function only takes effect when CURLOPT_VERBOSE has been set so
+		 * make sure the order is kept.
+		 */
+		CHECK_SETOPT(actx, CURLOPT_DEBUGFUNCTION, debug_callback, return false);
+		CHECK_SETOPT(actx, CURLOPT_VERBOSE, 1L, return false);
+	}
+
+	CHECK_SETOPT(actx, CURLOPT_ERRORBUFFER, actx->curl_err, return false);
+
+	/*
+	 * Only HTTPS is allowed. (Debug mode additionally allows HTTP; this is
+	 * intended for testing only.)
+	 *
+	 * There's a bit of unfortunate complexity around the choice of
+	 * CURLoption. CURLOPT_PROTOCOLS is deprecated in modern Curls, but its
+	 * replacement didn't show up until relatively recently.
+	 */
+	{
+#if CURL_AT_LEAST_VERSION(7, 85, 0)
+		const CURLoption popt = CURLOPT_PROTOCOLS_STR;
+		const char *protos = "https";
+		const char *const unsafe = "https,http";
+#else
+		const CURLoption popt = CURLOPT_PROTOCOLS;
+		long		protos = CURLPROTO_HTTPS;
+		const long	unsafe = CURLPROTO_HTTPS | CURLPROTO_HTTP;
+#endif
+
+		if (actx->debugging)
+			protos = unsafe;
+
+		CHECK_SETOPT(actx, popt, protos, return false);
+	}
+
+	/* TODO: would anyone use this in "real" situations, or just testing? */
+	if (actx->debugging)
+	{
+		const char *env;
+
+		if ((env = getenv("PGOAUTHCAFILE")) != NULL)
+			CHECK_SETOPT(actx, CURLOPT_CAINFO, env, return false);
+	}
+
+	/*
+	 * Suppress the Accept header to make our request as minimal as possible.
+	 * (Ideally we would set it to "application/json" instead, but OpenID is
+	 * pretty strict when it comes to provider behavior, so we have to check
+	 * what comes back anyway.)
+	 */
+	actx->headers = curl_slist_append(actx->headers, "Accept:");
+	if (actx->headers == NULL)
+	{
+		actx_error(actx, "out of memory");
+		return false;
+	}
+	CHECK_SETOPT(actx, CURLOPT_HTTPHEADER, actx->headers, return false);
+
+	return true;
+}
+
+/*
+ * Generic HTTP Request Handlers
+ */
+
+/*
+ * Response callback from libcurl which appends the response body into
+ * actx->work_data (see start_request()). The maximum size of the data is
+ * defined by CURL_MAX_WRITE_SIZE which by default is 16kb (and can only be
+ * changed by recompiling libcurl).
+ */
+static size_t
+append_data(char *buf, size_t size, size_t nmemb, void *userdata)
+{
+	struct async_ctx *actx = userdata;
+	PQExpBuffer resp = &actx->work_data;
+	size_t		len = size * nmemb;
+
+	/* In case we receive data over the threshold, abort the transfer */
+	if ((resp->len + len) > MAX_OAUTH_RESPONSE_SIZE)
+	{
+		actx_error(actx, "response is too large");
+		return 0;
+	}
+
+	/* The data passed from libcurl is not null-terminated */
+	appendBinaryPQExpBuffer(resp, buf, len);
+
+	/*
+	 * Signal an error in order to abort the transfer in case we ran out of
+	 * memory in accepting the data.
+	 */
+	if (PQExpBufferBroken(resp))
+	{
+		actx_error(actx, "out of memory");
+		return 0;
+	}
+
+	return len;
+}
+
+/*
+ * Begins an HTTP request on the multi handle. The caller should have set up all
+ * request-specific options on actx->curl first. The server's response body will
+ * be accumulated in actx->work_data (which will be reset, so don't store
+ * anything important there across this call).
+ *
+ * Once a request is queued, it can be driven to completion via drive_request().
+ * If actx->running is zero upon return, the request has already finished and
+ * drive_request() can be called without returning control to the client.
+ */
+static bool
+start_request(struct async_ctx *actx)
+{
+	CURLMcode	err;
+
+	resetPQExpBuffer(&actx->work_data);
+	CHECK_SETOPT(actx, CURLOPT_WRITEFUNCTION, append_data, return false);
+	CHECK_SETOPT(actx, CURLOPT_WRITEDATA, actx, return false);
+
+	err = curl_multi_add_handle(actx->curlm, actx->curl);
+	if (err)
+	{
+		actx_error(actx, "failed to queue HTTP request: %s",
+				   curl_multi_strerror(err));
+		return false;
+	}
+
+	/*
+	 * actx->running tracks the number of running handles, so we can
+	 * immediately call back if no waiting is needed.
+	 *
+	 * Even though this is nominally an asynchronous process, there are some
+	 * operations that can synchronously fail by this point (e.g. connections
+	 * to closed local ports) or even synchronously succeed if the stars align
+	 * (all the libcurl connection caches hit and the server is fast).
+	 */
+	err = curl_multi_socket_action(actx->curlm, CURL_SOCKET_TIMEOUT, 0, &actx->running);
+	if (err)
+	{
+		actx_error(actx, "asynchronous HTTP request failed: %s",
+				   curl_multi_strerror(err));
+		return false;
+	}
+
+	return true;
+}
+
+/*
+ * CURL_IGNORE_DEPRECATION was added in 7.87.0. If it's not defined, we can make
+ * it a no-op.
+ */
+#ifndef CURL_IGNORE_DEPRECATION
+#define CURL_IGNORE_DEPRECATION(x) x
+#endif
+
+/*
+ * Drives the multi handle towards completion. The caller should have already
+ * set up an asynchronous request via start_request().
+ */
+static PostgresPollingStatusType
+drive_request(struct async_ctx *actx)
+{
+	CURLMcode	err;
+	CURLMsg    *msg;
+	int			msgs_left;
+	bool		done;
+
+	if (actx->running)
+	{
+		/*---
+		 * There's an async request in progress. Pump the multi handle.
+		 *
+		 * curl_multi_socket_all() is officially deprecated, because it's
+		 * inefficient and pointless if your event loop has already handed you
+		 * the exact sockets that are ready. But that's not our use case --
+		 * our client has no way to tell us which sockets are ready. (They
+		 * don't even know there are sockets to begin with.)
+		 *
+		 * We can grab the list of triggered events from the multiplexer
+		 * ourselves, but that's effectively what curl_multi_socket_all() is
+		 * going to do. And there are currently no plans for the Curl project
+		 * to remove or break this API, so ignore the deprecation. See
+		 *
+		 *    https://curl.se/mail/lib-2024-11/0028.html
+		 *
+		 */
+		CURL_IGNORE_DEPRECATION(
+			err = curl_multi_socket_all(actx->curlm, &actx->running);
+		)
+
+		if (err)
+		{
+			actx_error(actx, "asynchronous HTTP request failed: %s",
+					   curl_multi_strerror(err));
+			return PGRES_POLLING_FAILED;
+		}
+
+		if (actx->running)
+		{
+			/* We'll come back again. */
+			return PGRES_POLLING_READING;
+		}
+	}
+
+	done = false;
+	while ((msg = curl_multi_info_read(actx->curlm, &msgs_left)) != NULL)
+	{
+		if (msg->msg != CURLMSG_DONE)
+		{
+			/*
+			 * Future libcurl versions may define new message types; we don't
+			 * know how to handle them, so we'll ignore them.
+			 */
+			continue;
+		}
+
+		/* First check the status of the request itself. */
+		if (msg->data.result != CURLE_OK)
+		{
+			/*
+			 * If a more specific error hasn't already been reported, use
+			 * libcurl's description.
+			 */
+			if (actx->errbuf.len == 0)
+				actx_error_str(actx, curl_easy_strerror(msg->data.result));
+
+			return PGRES_POLLING_FAILED;
+		}
+
+		/* Now remove the finished handle; we'll add it back later if needed. */
+		err = curl_multi_remove_handle(actx->curlm, msg->easy_handle);
+		if (err)
+		{
+			actx_error(actx, "libcurl easy handle removal failed: %s",
+					   curl_multi_strerror(err));
+			return PGRES_POLLING_FAILED;
+		}
+
+		done = true;
+	}
+
+	/* Sanity check. */
+	if (!done)
+	{
+		actx_error(actx, "no result was retrieved for the finished handle");
+		return PGRES_POLLING_FAILED;
+	}
+
+	return PGRES_POLLING_OK;
+}
+
+/*
+ * URL-Encoding Helpers
+ */
+
+/*
+ * Encodes a string using the application/x-www-form-urlencoded format, and
+ * appends it to the given buffer.
+ */
+static void
+append_urlencoded(PQExpBuffer buf, const char *s)
+{
+	char	   *escaped;
+	char	   *haystack;
+	char	   *match;
+
+	escaped = curl_easy_escape(NULL, s, 0);
+	if (!escaped)
+	{
+		termPQExpBuffer(buf);	/* mark the buffer broken */
+		return;
+	}
+
+	/*
+	 * curl_easy_escape() almost does what we want, but we need the
+	 * query-specific flavor which uses '+' instead of '%20' for spaces. The
+	 * Curl command-line tool does this with a simple search-and-replace, so
+	 * follow its lead.
+	 */
+	haystack = escaped;
+
+	while ((match = strstr(haystack, "%20")) != NULL)
+	{
+		/* Append the unmatched portion, followed by the plus sign. */
+		appendBinaryPQExpBuffer(buf, haystack, match - haystack);
+		appendPQExpBufferChar(buf, '+');
+
+		/* Keep searching after the match. */
+		haystack = match + 3 /* strlen("%20") */ ;
+	}
+
+	/* Push the remainder of the string onto the buffer. */
+	appendPQExpBufferStr(buf, haystack);
+
+	curl_free(escaped);
+}
+
+/*
+ * Convenience wrapper for encoding a single string. Returns NULL on allocation
+ * failure.
+ */
+static char *
+urlencode(const char *s)
+{
+	PQExpBufferData buf;
+
+	initPQExpBuffer(&buf);
+	append_urlencoded(&buf, s);
+
+	return PQExpBufferDataBroken(buf) ? NULL : buf.data;
+}
+
+/*
+ * Appends a key/value pair to the end of an application/x-www-form-urlencoded
+ * list.
+ */
+static void
+build_urlencoded(PQExpBuffer buf, const char *key, const char *value)
+{
+	if (buf->len)
+		appendPQExpBufferChar(buf, '&');
+
+	append_urlencoded(buf, key);
+	appendPQExpBufferChar(buf, '=');
+	append_urlencoded(buf, value);
+}
+
+/*
+ * Specific HTTP Request Handlers
+ *
+ * This is finally the beginning of the actual application logic. Generally
+ * speaking, a single request consists of a start_* and a finish_* step, with
+ * drive_request() pumping the machine in between.
+ */
+
+/*
+ * Queue an OpenID Provider Configuration Request:
+ *
+ *     https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationRequest
+ *     https://www.rfc-editor.org/rfc/rfc8414#section-3.1
+ *
+ * This is done first to get the endpoint URIs we need to contact and to make
+ * sure the provider provides a device authorization flow. finish_discovery()
+ * will fill in actx->provider.
+ */
+static bool
+start_discovery(struct async_ctx *actx, const char *discovery_uri)
+{
+	CHECK_SETOPT(actx, CURLOPT_HTTPGET, 1L, return false);
+	CHECK_SETOPT(actx, CURLOPT_URL, discovery_uri, return false);
+
+	return start_request(actx);
+}
+
+static bool
+finish_discovery(struct async_ctx *actx)
+{
+	long		response_code;
+
+	/*----
+	 * Now check the response. OIDC Discovery 1.0 is pretty strict:
+	 *
+	 *     A successful response MUST use the 200 OK HTTP status code and
+	 *     return a JSON object using the application/json content type that
+	 *     contains a set of Claims as its members that are a subset of the
+	 *     Metadata values defined in Section 3.
+	 *
+	 * Compared to standard HTTP semantics, this makes life easy -- we don't
+	 * need to worry about redirections (which would call the Issuer host
+	 * validation into question), or non-authoritative responses, or any other
+	 * complications.
+	 */
+	CHECK_GETINFO(actx, CURLINFO_RESPONSE_CODE, &response_code, return false);
+
+	if (response_code != 200)
+	{
+		actx_error(actx, "unexpected response code %ld", response_code);
+		return false;
+	}
+
+	/*
+	 * Pull the fields we care about from the document.
+	 */
+	actx->errctx = "failed to parse OpenID discovery document";
+	if (!parse_provider(actx, &actx->provider))
+		return false;			/* error message already set */
+
+	/*
+	 * Fill in any defaults for OPTIONAL/RECOMMENDED fields we care about.
+	 */
+	if (!actx->provider.grant_types_supported)
+	{
+		/*
+		 * Per Section 3, the default is ["authorization_code", "implicit"].
+		 */
+		struct curl_slist *temp = actx->provider.grant_types_supported;
+
+		temp = curl_slist_append(temp, "authorization_code");
+		if (temp)
+		{
+			temp = curl_slist_append(temp, "implicit");
+		}
+
+		if (!temp)
+		{
+			actx_error(actx, "out of memory");
+			return false;
+		}
+
+		actx->provider.grant_types_supported = temp;
+	}
+
+	return true;
+}
+
+/*
+ * Ensure that the discovery document is provided by the expected issuer.
+ * Currently, issuers are statically configured in the connection string.
+ */
+static bool
+check_issuer(struct async_ctx *actx, PGconn *conn)
+{
+	const struct provider *provider = &actx->provider;
+
+	Assert(conn->oauth_issuer_id);	/* ensured by setup_oauth_parameters() */
+	Assert(provider->issuer);	/* ensured by parse_provider() */
+
+	/*---
+	 * We require strict equality for issuer identifiers -- no path or case
+	 * normalization, no substitution of default ports and schemes, etc. This
+	 * is done to match the rules in OIDC Discovery Sec. 4.3 for config
+	 * validation:
+	 *
+	 *    The issuer value returned MUST be identical to the Issuer URL that
+	 *    was used as the prefix to /.well-known/openid-configuration to
+	 *    retrieve the configuration information.
+	 *
+	 * as well as the rules set out in RFC 9207 for avoiding mix-up attacks:
+	 *
+	 *    Clients MUST then [...] compare the result to the issuer identifier
+	 *    of the authorization server where the authorization request was
+	 *    sent to. This comparison MUST use simple string comparison as defined
+	 *    in Section 6.2.1 of [RFC3986].
+	 *
+	 * TODO: Encoding support?
+	 */
+	if (strcmp(conn->oauth_issuer_id, provider->issuer) != 0)
+	{
+		actx_error(actx,
+				   "the issuer identifier (%s) does not match oauth_issuer (%s)",
+				   provider->issuer, conn->oauth_issuer_id);
+		return false;
+	}
+
+	return true;
+}
+
+#define HTTPS_SCHEME "https://"
+#define OAUTH_GRANT_TYPE_DEVICE_CODE "urn:ietf:params:oauth:grant-type:device_code"
+
+/*
+ * Ensure that the provider supports the Device Authorization flow (i.e. it
+ * provides an authorization endpoint, and both the token and authorization
+ * endpoint URLs seem reasonable).
+ */
+static bool
+check_for_device_flow(struct async_ctx *actx)
+{
+	const struct provider *provider = &actx->provider;
+
+	Assert(provider->issuer);	/* ensured by parse_provider() */
+	Assert(provider->token_endpoint);	/* ensured by parse_provider() */
+
+	if (!provider->device_authorization_endpoint)
+	{
+		actx_error(actx,
+				   "issuer \"%s\" does not provide a device authorization endpoint",
+				   provider->issuer);
+		return false;
+	}
+
+	/*
+	 * The original implementation checked that OAUTH_GRANT_TYPE_DEVICE_CODE
+	 * was present in the discovery document's grant_types_supported list. MS
+	 * Entra does not advertise this grant type, though, and since it doesn't
+	 * make sense to stand up a device_authorization_endpoint without also
+	 * accepting device codes at the token_endpoint, that's the only thing we
+	 * currently require.
+	 */
+
+	/*
+	 * Although libcurl will fail later if the URL contains an unsupported
+	 * scheme, that error message is going to be a bit opaque. This is a
+	 * decent time to bail out if we're not using HTTPS for the endpoints
+	 * we'll use for the flow.
+	 */
+	if (!actx->debugging)
+	{
+		if (pg_strncasecmp(provider->device_authorization_endpoint,
+						   HTTPS_SCHEME, strlen(HTTPS_SCHEME)) != 0)
+		{
+			actx_error(actx,
+					   "device authorization endpoint \"%s\" must use HTTPS",
+					   provider->device_authorization_endpoint);
+			return false;
+		}
+
+		if (pg_strncasecmp(provider->token_endpoint,
+						   HTTPS_SCHEME, strlen(HTTPS_SCHEME)) != 0)
+		{
+			actx_error(actx,
+					   "token endpoint \"%s\" must use HTTPS",
+					   provider->token_endpoint);
+			return false;
+		}
+	}
+
+	return true;
+}
+
+/*
+ * Adds the client ID (and secret, if provided) to the current request, using
+ * either HTTP headers or the request body.
+ */
+static bool
+add_client_identification(struct async_ctx *actx, PQExpBuffer reqbody, PGconn *conn)
+{
+	bool		success = false;
+	char	   *username = NULL;
+	char	   *password = NULL;
+
+	if (conn->oauth_client_secret)	/* Zero-length secrets are permitted! */
+	{
+		/*----
+		 * Use HTTP Basic auth to send the client_id and secret. Per RFC 6749,
+		 * Sec. 2.3.1,
+		 *
+		 *   Including the client credentials in the request-body using the
+		 *   two parameters is NOT RECOMMENDED and SHOULD be limited to
+		 *   clients unable to directly utilize the HTTP Basic authentication
+		 *   scheme (or other password-based HTTP authentication schemes).
+		 *
+		 * Additionally:
+		 *
+		 *   The client identifier is encoded using the
+		 *   "application/x-www-form-urlencoded" encoding algorithm per Appendix
+		 *   B, and the encoded value is used as the username; the client
+		 *   password is encoded using the same algorithm and used as the
+		 *   password.
+		 *
+		 * (Appendix B modifies application/x-www-form-urlencoded by requiring
+		 * an initial UTF-8 encoding step. Since the client ID and secret must
+		 * both be 7-bit ASCII -- RFC 6749 Appendix A -- we don't worry about
+		 * that in this function.)
+		 *
+		 * client_id is not added to the request body in this case. Not only
+		 * would it be redundant, but some providers in the wild (e.g. Okta)
+		 * refuse to accept it.
+		 */
+		username = urlencode(conn->oauth_client_id);
+		password = urlencode(conn->oauth_client_secret);
+
+		if (!username || !password)
+		{
+			actx_error(actx, "out of memory");
+			goto cleanup;
+		}
+
+		CHECK_SETOPT(actx, CURLOPT_HTTPAUTH, CURLAUTH_BASIC, goto cleanup);
+		CHECK_SETOPT(actx, CURLOPT_USERNAME, username, goto cleanup);
+		CHECK_SETOPT(actx, CURLOPT_PASSWORD, password, goto cleanup);
+
+		actx->used_basic_auth = true;
+	}
+	else
+	{
+		/*
+		 * If we're not otherwise authenticating, client_id is REQUIRED in the
+		 * request body.
+		 */
+		build_urlencoded(reqbody, "client_id", conn->oauth_client_id);
+
+		CHECK_SETOPT(actx, CURLOPT_HTTPAUTH, CURLAUTH_NONE, goto cleanup);
+		actx->used_basic_auth = false;
+	}
+
+	success = true;
+
+cleanup:
+	free(username);
+	free(password);
+
+	return success;
+}
+
+/*
+ * Queue a Device Authorization Request:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc8628#section-3.1
+ *
+ * This is the second step. We ask the provider to verify the end user out of
+ * band and authorize us to act on their behalf; it will give us the required
+ * nonces for us to later poll the request status, which we'll grab in
+ * finish_device_authz().
+ */
+static bool
+start_device_authz(struct async_ctx *actx, PGconn *conn)
+{
+	const char *device_authz_uri = actx->provider.device_authorization_endpoint;
+	PQExpBuffer work_buffer = &actx->work_data;
+
+	Assert(conn->oauth_client_id);	/* ensured by setup_oauth_parameters() */
+	Assert(device_authz_uri);	/* ensured by check_for_device_flow() */
+
+	/* Construct our request body. */
+	resetPQExpBuffer(work_buffer);
+	if (conn->oauth_scope && conn->oauth_scope[0])
+		build_urlencoded(work_buffer, "scope", conn->oauth_scope);
+
+	if (!add_client_identification(actx, work_buffer, conn))
+		return false;
+
+	if (PQExpBufferBroken(work_buffer))
+	{
+		actx_error(actx, "out of memory");
+		return false;
+	}
+
+	/* Make our request. */
+	CHECK_SETOPT(actx, CURLOPT_URL, device_authz_uri, return false);
+	CHECK_SETOPT(actx, CURLOPT_COPYPOSTFIELDS, work_buffer->data, return false);
+
+	return start_request(actx);
+}
+
+static bool
+finish_device_authz(struct async_ctx *actx)
+{
+	long		response_code;
+
+	CHECK_GETINFO(actx, CURLINFO_RESPONSE_CODE, &response_code, return false);
+
+	/*
+	 * Per RFC 8628, Section 3, a successful device authorization response
+	 * uses 200 OK.
+	 */
+	if (response_code == 200)
+	{
+		actx->errctx = "failed to parse device authorization";
+		if (!parse_device_authz(actx, &actx->authz))
+			return false;		/* error message already set */
+
+		return true;
+	}
+
+	/*
+	 * The device authorization endpoint uses the same error response as the
+	 * token endpoint, so the error handling roughly follows
+	 * finish_token_request(). The key difference is that an error here is
+	 * immediately fatal.
+	 */
+	if (response_code == 400 || response_code == 401)
+	{
+		struct token_error err = {0};
+
+		if (!parse_token_error(actx, &err))
+		{
+			free_token_error(&err);
+			return false;
+		}
+
+		record_token_error(actx, &err);
+
+		free_token_error(&err);
+		return false;
+	}
+
+	/* Any other response codes are considered invalid */
+	actx_error(actx, "unexpected response code %ld", response_code);
+	return false;
+}
+
+/*
+ * Queue an Access Token Request:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc6749#section-4.1.3
+ *
+ * This is the final step. We continually poll the token endpoint to see if the
+ * user has authorized us yet. finish_token_request() will pull either the token
+ * or a (ideally temporary) error status from the provider.
+ */
+static bool
+start_token_request(struct async_ctx *actx, PGconn *conn)
+{
+	const char *token_uri = actx->provider.token_endpoint;
+	const char *device_code = actx->authz.device_code;
+	PQExpBuffer work_buffer = &actx->work_data;
+
+	Assert(conn->oauth_client_id);	/* ensured by setup_oauth_parameters() */
+	Assert(token_uri);			/* ensured by parse_provider() */
+	Assert(device_code);		/* ensured by parse_device_authz() */
+
+	/* Construct our request body. */
+	resetPQExpBuffer(work_buffer);
+	build_urlencoded(work_buffer, "device_code", device_code);
+	build_urlencoded(work_buffer, "grant_type", OAUTH_GRANT_TYPE_DEVICE_CODE);
+
+	if (!add_client_identification(actx, work_buffer, conn))
+		return false;
+
+	if (PQExpBufferBroken(work_buffer))
+	{
+		actx_error(actx, "out of memory");
+		return false;
+	}
+
+	/* Make our request. */
+	CHECK_SETOPT(actx, CURLOPT_URL, token_uri, return false);
+	CHECK_SETOPT(actx, CURLOPT_COPYPOSTFIELDS, work_buffer->data, return false);
+
+	return start_request(actx);
+}
+
+static bool
+finish_token_request(struct async_ctx *actx, struct token *tok)
+{
+	long		response_code;
+
+	CHECK_GETINFO(actx, CURLINFO_RESPONSE_CODE, &response_code, return false);
+
+	/*
+	 * Per RFC 6749, Section 5, a successful response uses 200 OK.
+	 */
+	if (response_code == 200)
+	{
+		actx->errctx = "failed to parse access token response";
+		if (!parse_access_token(actx, tok))
+			return false;		/* error message already set */
+
+		return true;
+	}
+
+	/*
+	 * An error response uses either 400 Bad Request or 401 Unauthorized.
+	 * There are references online to implementations using 403 for error
+	 * return which would violate the specification. For now we stick to the
+	 * specification but we might have to revisit this.
+	 */
+	if (response_code == 400 || response_code == 401)
+	{
+		if (!parse_token_error(actx, &tok->err))
+			return false;
+
+		return true;
+	}
+
+	/* Any other response codes are considered invalid */
+	actx_error(actx, "unexpected response code %ld", response_code);
+	return false;
+}
+
+/*
+ * Finishes the token request and examines the response. If the flow has
+ * completed, a valid token will be returned via the parameter list. Otherwise,
+ * the token parameter remains unchanged, and the caller needs to wait for
+ * another interval (which will have been increased in response to a slow_down
+ * message from the server) before starting a new token request.
+ *
+ * False is returned only for permanent error conditions.
+ */
+static bool
+handle_token_response(struct async_ctx *actx, char **token)
+{
+	bool		success = false;
+	struct token tok = {0};
+	const struct token_error *err;
+
+	if (!finish_token_request(actx, &tok))
+		goto token_cleanup;
+
+	/* A successful token request gives either a token or an in-band error. */
+	Assert(tok.access_token || tok.err.error);
+
+	if (tok.access_token)
+	{
+		*token = tok.access_token;
+		tok.access_token = NULL;
+
+		success = true;
+		goto token_cleanup;
+	}
+
+	/*
+	 * authorization_pending and slow_down are the only acceptable errors;
+	 * anything else and we bail. These are defined in RFC 8628, Sec. 3.5.
+	 */
+	err = &tok.err;
+	if (strcmp(err->error, "authorization_pending") != 0 &&
+		strcmp(err->error, "slow_down") != 0)
+	{
+		record_token_error(actx, err);
+		goto token_cleanup;
+	}
+
+	/*
+	 * A slow_down error requires us to permanently increase our retry
+	 * interval by five seconds.
+	 */
+	if (strcmp(err->error, "slow_down") == 0)
+	{
+		int			prev_interval = actx->authz.interval;
+
+		actx->authz.interval += 5;
+		if (actx->authz.interval < prev_interval)
+		{
+			actx_error(actx, "slow_down interval overflow");
+			goto token_cleanup;
+		}
+	}
+
+	success = true;
+
+token_cleanup:
+	free_token(&tok);
+	return success;
+}
+
+/*
+ * Displays a device authorization prompt for action by the end user, either via
+ * the PQauthDataHook, or by a message on standard error if no hook is set.
+ */
+static bool
+prompt_user(struct async_ctx *actx, PGconn *conn)
+{
+	int			res;
+	PGpromptOAuthDevice prompt = {
+		.verification_uri = actx->authz.verification_uri,
+		.user_code = actx->authz.user_code,
+		/* TODO: optional fields */
+	};
+
+	res = PQauthDataHook(PQAUTHDATA_PROMPT_OAUTH_DEVICE, conn, &prompt);
+
+	if (!res)
+	{
+		/*
+		 * translator: The first %s is a URL for the user to visit in a
+		 * browser, and the second %s is a code to be copy-pasted there.
+		 */
+		fprintf(stderr, libpq_gettext("Visit %s and enter the code: %s\n"),
+				prompt.verification_uri, prompt.user_code);
+	}
+	else if (res < 0)
+	{
+		actx_error(actx, "device prompt failed");
+		return false;
+	}
+
+	return true;
+}
+
+/*
+ * Calls curl_global_init() in a thread-safe way.
+ *
+ * libcurl has stringent requirements for the thread context in which you call
+ * curl_global_init(), because it's going to try initializing a bunch of other
+ * libraries (OpenSSL, Winsock, etc). Recent versions of libcurl have improved
+ * the thread-safety situation, but there's a chicken-and-egg problem at
+ * runtime: you can't check the thread safety until you've initialized libcurl,
+ * which you can't do from within a thread unless you know it's thread-safe...
+ *
+ * Returns true if initialization was successful. Successful or not, this
+ * function will not try to reinitialize Curl on successive calls.
+ */
+static bool
+initialize_curl(PGconn *conn)
+{
+	/*
+	 * Don't let the compiler play tricks with this variable. In the
+	 * HAVE_THREADSAFE_CURL_GLOBAL_INIT case, we don't care if two threads
+	 * enter simultaneously, but we do care if this gets set transiently to
+	 * PG_BOOL_YES/NO in cases where that's not the final answer.
+	 */
+	static volatile PGTernaryBool init_successful = PG_BOOL_UNKNOWN;
+#if HAVE_THREADSAFE_CURL_GLOBAL_INIT
+	curl_version_info_data *info;
+#endif
+
+#if !HAVE_THREADSAFE_CURL_GLOBAL_INIT
+
+	/*
+	 * Lock around the whole function. If a libpq client performs its own work
+	 * with libcurl, it must either ensure that Curl is initialized safely
+	 * before calling us (in which case our call will be a no-op), or else it
+	 * must guard its own calls to curl_global_init() with a registered
+	 * threadlock handler. See PQregisterThreadLock().
+	 */
+	pglock_thread();
+#endif
+
+	/*
+	 * Skip initialization if we've already done it. (Curl tracks the number
+	 * of calls; there's no point in incrementing the counter every time we
+	 * connect.)
+	 */
+	if (init_successful == PG_BOOL_YES)
+		goto done;
+	else if (init_successful == PG_BOOL_NO)
+	{
+		libpq_append_conn_error(conn,
+								"curl_global_init previously failed during OAuth setup");
+		goto done;
+	}
+
+	/*
+	 * We know we've already initialized Winsock by this point (see
+	 * pqMakeEmptyPGconn()), so we should be able to safely skip that bit. But
+	 * we have to tell libcurl to initialize everything else, because other
+	 * pieces of our client executable may already be using libcurl for their
+	 * own purposes. If we initialize libcurl with only a subset of its
+	 * features, we could break those other clients nondeterministically, and
+	 * that would probably be a nightmare to debug.
+	 *
+	 * If some other part of the program has already called this, it's a
+	 * no-op.
+	 */
+	if (curl_global_init(CURL_GLOBAL_ALL & ~CURL_GLOBAL_WIN32) != CURLE_OK)
+	{
+		libpq_append_conn_error(conn,
+								"curl_global_init failed during OAuth setup");
+		init_successful = PG_BOOL_NO;
+		goto done;
+	}
+
+#if HAVE_THREADSAFE_CURL_GLOBAL_INIT
+
+	/*
+	 * If we determined at configure time that the Curl installation is
+	 * threadsafe, our job here is much easier. We simply initialize above
+	 * without any locking (concurrent or duplicated calls are fine in that
+	 * situation), then double-check to make sure the runtime setting agrees,
+	 * to try to catch silent downgrades.
+	 */
+	info = curl_version_info(CURLVERSION_NOW);
+	if (!(info->features & CURL_VERSION_THREADSAFE))
+	{
+		/*
+		 * 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 threadsafe\n"
+								"\tCurl initialization was reported threadsafe 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;
+	}
+#endif
+
+	init_successful = PG_BOOL_YES;
+
+done:
+#if !HAVE_THREADSAFE_CURL_GLOBAL_INIT
+	pgunlock_thread();
+#endif
+	return (init_successful == PG_BOOL_YES);
+}
+
+/*
+ * The core nonblocking libcurl implementation. This will be called several
+ * times to pump the async engine.
+ *
+ * The architecture is based on PQconnectPoll(). The first half drives the
+ * connection state forward as necessary, returning if we're not ready to
+ * proceed to the next step yet. The second half performs the actual transition
+ * between states.
+ *
+ * You can trace the overall OAuth flow through the second half. It's linear
+ * until we get to the end, where we flip back and forth between
+ * OAUTH_STEP_TOKEN_REQUEST and OAUTH_STEP_WAIT_INTERVAL to regularly ping the
+ * provider.
+ */
+static PostgresPollingStatusType
+pg_fe_run_oauth_flow_impl(PGconn *conn)
+{
+	fe_oauth_state *state = conn->sasl_state;
+	struct async_ctx *actx;
+
+	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;
+#ifdef HAVE_SYS_EPOLL_H
+		actx->timerfd = -1;
+#endif
+
+		/* 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. */
+		conn->altsock = actx->mux;
+
+		switch (actx->step)
+		{
+			case OAUTH_STEP_INIT:
+				break;
+
+			case OAUTH_STEP_DISCOVERY:
+			case OAUTH_STEP_DEVICE_AUTHORIZATION:
+			case OAUTH_STEP_TOKEN_REQUEST:
+				{
+					PostgresPollingStatusType status;
+
+					status = drive_request(actx);
+
+					if (status == PGRES_POLLING_FAILED)
+						goto error_return;
+					else if (status != PGRES_POLLING_OK)
+					{
+						/* not done yet */
+						return status;
+					}
+				}
+
+			case OAUTH_STEP_WAIT_INTERVAL:
+				/* TODO check that the timer has expired */
+				break;
+		}
+
+		/*
+		 * Each case here must ensure that actx->running is set while we're
+		 * waiting on some asynchronous work. Most cases rely on
+		 * start_request() to do that for them.
+		 */
+		switch (actx->step)
+		{
+			case OAUTH_STEP_INIT:
+				actx->errctx = "failed to fetch OpenID discovery document";
+				if (!start_discovery(actx, conn->oauth_discovery_uri))
+					goto error_return;
+
+				actx->step = OAUTH_STEP_DISCOVERY;
+				break;
+
+			case OAUTH_STEP_DISCOVERY:
+				if (!finish_discovery(actx))
+					goto error_return;
+
+				if (!check_issuer(actx, conn))
+					goto error_return;
+
+				actx->errctx = "cannot run OAuth device authorization";
+				if (!check_for_device_flow(actx))
+					goto error_return;
+
+				actx->errctx = "failed to obtain device authorization";
+				if (!start_device_authz(actx, conn))
+					goto error_return;
+
+				actx->step = OAUTH_STEP_DEVICE_AUTHORIZATION;
+				break;
+
+			case OAUTH_STEP_DEVICE_AUTHORIZATION:
+				if (!finish_device_authz(actx))
+					goto error_return;
+
+				actx->errctx = "failed to obtain access token";
+				if (!start_token_request(actx, conn))
+					goto error_return;
+
+				actx->step = OAUTH_STEP_TOKEN_REQUEST;
+				break;
+
+			case OAUTH_STEP_TOKEN_REQUEST:
+				if (!handle_token_response(actx, &conn->oauth_token))
+					goto error_return;
+
+				if (!actx->user_prompted)
+				{
+					/*
+					 * Now that we know the token endpoint isn't broken, give
+					 * the user the login instructions.
+					 */
+					if (!prompt_user(actx, conn))
+						goto error_return;
+
+					actx->user_prompted = true;
+				}
+
+				if (conn->oauth_token)
+					break;		/* done! */
+
+				/*
+				 * Wait for the required interval before issuing the next
+				 * request.
+				 */
+				if (!set_timer(actx, actx->authz.interval * 1000))
+					goto error_return;
+
+#ifdef HAVE_SYS_EPOLL_H
+
+				/*
+				 * No Curl requests are running, so we can simplify by having
+				 * the client wait directly on the timerfd rather than the
+				 * multiplexer. (This isn't possible for kqueue.)
+				 */
+				conn->altsock = actx->timerfd;
+#endif
+
+				actx->step = OAUTH_STEP_WAIT_INTERVAL;
+				actx->running = 1;
+				break;
+
+			case OAUTH_STEP_WAIT_INTERVAL:
+				actx->errctx = "failed to obtain access token";
+				if (!start_token_request(actx, conn))
+					goto error_return;
+
+				actx->step = OAUTH_STEP_TOKEN_REQUEST;
+				break;
+		}
+
+		/*
+		 * The vast majority of the time, if we don't have a token at this
+		 * point, actx->running will be set. But there are some corner cases
+		 * where we can immediately loop back around; see start_request().
+		 */
+	} while (!conn->oauth_token && !actx->running);
+
+	/* If we've stored a token, we're done. Otherwise come back later. */
+	return conn->oauth_token ? PGRES_POLLING_OK : PGRES_POLLING_READING;
+
+error_return:
+
+	/*
+	 * Assemble the three parts of our error: context, body, and detail. See
+	 * also the documentation for struct async_ctx.
+	 */
+	if (actx->errctx)
+	{
+		appendPQExpBufferStr(&conn->errorMessage,
+							 libpq_gettext(actx->errctx));
+		appendPQExpBufferStr(&conn->errorMessage, ": ");
+	}
+
+	if (PQExpBufferDataBroken(actx->errbuf))
+		appendPQExpBufferStr(&conn->errorMessage,
+							 libpq_gettext("out of memory"));
+	else
+		appendPQExpBufferStr(&conn->errorMessage, actx->errbuf.data);
+
+	if (actx->curl_err[0])
+	{
+		size_t		len;
+
+		appendPQExpBuffer(&conn->errorMessage, " (%s)", actx->curl_err);
+
+		/* Sometimes libcurl adds a newline to the error buffer. :( */
+		len = conn->errorMessage.len;
+		if (len >= 2 && conn->errorMessage.data[len - 2] == '\n')
+		{
+			conn->errorMessage.data[len - 2] = ')';
+			conn->errorMessage.data[len - 1] = '\0';
+			conn->errorMessage.len--;
+		}
+	}
+
+	appendPQExpBufferStr(&conn->errorMessage, "\n");
+
+	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.
+ */
+PostgresPollingStatusType
+pg_fe_run_oauth_flow(PGconn *conn)
+{
+	PostgresPollingStatusType result;
+#ifndef WIN32
+	sigset_t	osigset;
+	bool		sigpipe_pending;
+	bool		masked;
+
+	/*---
+	 * Ignore SIGPIPE on this thread during all Curl processing.
+	 *
+	 * Because we support multiple threads, we have to set up libcurl with
+	 * CURLOPT_NOSIGNAL, which disables its default global handling of
+	 * SIGPIPE. From the Curl docs:
+	 *
+	 *     libcurl makes an effort to never cause such SIGPIPE signals to
+	 *     trigger, but some operating systems have no way to avoid them and
+	 *     even on those that have there are some corner cases when they may
+	 *     still happen, contrary to our desire.
+	 *
+	 * Note that libcurl is also at the mercy of its DNS resolution and SSL
+	 * libraries; if any of them forget a MSG_NOSIGNAL then we're in trouble.
+	 * Modern platforms and libraries seem to get it right, so this is a
+	 * difficult corner case to exercise in practice, and unfortunately it's
+	 * not really clear whether it's necessary in all cases.
+	 */
+	masked = (pq_block_sigpipe(&osigset, &sigpipe_pending) == 0);
+#endif
+
+	result = pg_fe_run_oauth_flow_impl(conn);
+
+#ifndef WIN32
+	if (masked)
+	{
+		/*
+		 * Undo the SIGPIPE mask. Assume we may have gotten EPIPE (we have no
+		 * way of knowing at this level).
+		 */
+		pq_reset_sigpipe(&osigset, sigpipe_pending, true /* EPIPE, maybe */ );
+	}
+#endif
+
+	return result;
+}
diff --git a/src/interfaces/libpq/fe-auth-oauth.c b/src/interfaces/libpq/fe-auth-oauth.c
new file mode 100644
index 0000000000..cc53e2bdd1
--- /dev/null
+++ b/src/interfaces/libpq/fe-auth-oauth.c
@@ -0,0 +1,1141 @@
+/*-------------------------------------------------------------------------
+ *
+ * fe-auth-oauth.c
+ *	   The front-end (client) implementation of OAuth/OIDC authentication
+ *	   using the SASL OAUTHBEARER mechanism.
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/interfaces/libpq/fe-auth-oauth.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres_fe.h"
+
+#include "common/base64.h"
+#include "common/hmac.h"
+#include "common/jsonapi.h"
+#include "common/oauth-common.h"
+#include "fe-auth.h"
+#include "fe-auth-oauth.h"
+#include "mb/pg_wchar.h"
+
+/* The exported OAuth callback mechanism. */
+static void *oauth_init(PGconn *conn, const char *password,
+						const char *sasl_mechanism);
+static SASLStatus oauth_exchange(void *opaq, bool final,
+								 char *input, int inputlen,
+								 char **output, int *outputlen);
+static bool oauth_channel_bound(void *opaq);
+static void oauth_free(void *opaq);
+
+const pg_fe_sasl_mech pg_oauth_mech = {
+	oauth_init,
+	oauth_exchange,
+	oauth_channel_bound,
+	oauth_free,
+};
+
+/*
+ * Initializes mechanism state for OAUTHBEARER.
+ *
+ * For a full description of the API, see libpq/fe-auth-sasl.h.
+ */
+static void *
+oauth_init(PGconn *conn, const char *password,
+		   const char *sasl_mechanism)
+{
+	fe_oauth_state *state;
+
+	/*
+	 * We only support one SASL mechanism here; anything else is programmer
+	 * error.
+	 */
+	Assert(sasl_mechanism != NULL);
+	Assert(strcmp(sasl_mechanism, OAUTHBEARER_NAME) == 0);
+
+	state = calloc(1, sizeof(*state));
+	if (!state)
+		return NULL;
+
+	state->step = FE_OAUTH_INIT;
+	state->conn = conn;
+
+	return state;
+}
+
+/*
+ * Frees the state allocated by oauth_init().
+ *
+ * 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().
+ */
+static void
+oauth_free(void *opaq)
+{
+	fe_oauth_state *state = opaq;
+
+	/* Any async authentication state should have been cleaned up already. */
+	Assert(!state->async_ctx);
+
+	free(state);
+}
+
+#define kvsep "\x01"
+
+/*
+ * Constructs an OAUTHBEARER client initial response (RFC 7628, Sec. 3.1).
+ *
+ * If discover is true, the initial response will contain a request for the
+ * server's required OAuth parameters (Sec. 4.3). Otherwise, conn->token must
+ * be set; it will be sent as the connection's bearer token.
+ *
+ * Returns the response as a null-terminated string, or NULL on error.
+ */
+static char *
+client_initial_response(PGconn *conn, bool discover)
+{
+	static const char *const resp_format = "n,," kvsep "auth=%s%s" kvsep kvsep;
+
+	PQExpBufferData buf;
+	const char *authn_scheme;
+	char	   *response = NULL;
+	const char *token = conn->oauth_token;
+
+	if (discover)
+	{
+		/* Parameter discovery uses a completely empty auth value. */
+		authn_scheme = token = "";
+	}
+	else
+	{
+		/*
+		 * Use a Bearer authentication scheme (RFC 6750, Sec. 2.1). A trailing
+		 * space is used as a separator.
+		 */
+		authn_scheme = "Bearer ";
+
+		/* conn->token must have been set in this case. */
+		if (!token)
+		{
+			Assert(false);
+			libpq_append_conn_error(conn,
+									"internal error: no OAuth token was set for the connection");
+			return NULL;
+		}
+	}
+
+	initPQExpBuffer(&buf);
+	appendPQExpBuffer(&buf, resp_format, authn_scheme, token);
+
+	if (!PQExpBufferDataBroken(buf))
+		response = strdup(buf.data);
+	termPQExpBuffer(&buf);
+
+	if (!response)
+		libpq_append_conn_error(conn, "out of memory");
+
+	return response;
+}
+
+/*
+ * JSON Parser (for the OAUTHBEARER error result)
+ */
+
+/* Relevant JSON fields in the error result object. */
+#define ERROR_STATUS_FIELD "status"
+#define ERROR_SCOPE_FIELD "scope"
+#define ERROR_OPENID_CONFIGURATION_FIELD "openid-configuration"
+
+struct json_ctx
+{
+	char	   *errmsg;			/* any non-NULL value stops all processing */
+	PQExpBufferData errbuf;		/* backing memory for errmsg */
+	int			nested;			/* nesting level (zero is the top) */
+
+	const char *target_field_name;	/* points to a static allocation */
+	char	  **target_field;	/* see below */
+
+	/* target_field, if set, points to one of the following: */
+	char	   *status;
+	char	   *scope;
+	char	   *discovery_uri;
+};
+
+#define oauth_json_has_error(ctx) \
+	(PQExpBufferDataBroken((ctx)->errbuf) || (ctx)->errmsg)
+
+#define oauth_json_set_error(ctx, ...) \
+	do { \
+		appendPQExpBuffer(&(ctx)->errbuf, __VA_ARGS__); \
+		(ctx)->errmsg = (ctx)->errbuf.data; \
+	} while (0)
+
+static JsonParseErrorType
+oauth_json_object_start(void *state)
+{
+	struct json_ctx *ctx = state;
+
+	if (ctx->target_field)
+	{
+		Assert(ctx->nested == 1);
+
+		oauth_json_set_error(ctx,
+							 libpq_gettext("field \"%s\" must be a string"),
+							 ctx->target_field_name);
+	}
+
+	++ctx->nested;
+	return oauth_json_has_error(ctx) ? JSON_SEM_ACTION_FAILED : JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_object_end(void *state)
+{
+	struct json_ctx *ctx = state;
+
+	--ctx->nested;
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_object_field_start(void *state, char *name, bool isnull)
+{
+	struct json_ctx *ctx = state;
+
+	if (ctx->nested == 1)
+	{
+		if (strcmp(name, ERROR_STATUS_FIELD) == 0)
+		{
+			ctx->target_field_name = ERROR_STATUS_FIELD;
+			ctx->target_field = &ctx->status;
+		}
+		else if (strcmp(name, ERROR_SCOPE_FIELD) == 0)
+		{
+			ctx->target_field_name = ERROR_SCOPE_FIELD;
+			ctx->target_field = &ctx->scope;
+		}
+		else if (strcmp(name, ERROR_OPENID_CONFIGURATION_FIELD) == 0)
+		{
+			ctx->target_field_name = ERROR_OPENID_CONFIGURATION_FIELD;
+			ctx->target_field = &ctx->discovery_uri;
+		}
+	}
+
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_array_start(void *state)
+{
+	struct json_ctx *ctx = state;
+
+	if (!ctx->nested)
+	{
+		ctx->errmsg = libpq_gettext("top-level element must be an object");
+	}
+	else if (ctx->target_field)
+	{
+		Assert(ctx->nested == 1);
+
+		oauth_json_set_error(ctx,
+							 libpq_gettext("field \"%s\" must be a string"),
+							 ctx->target_field_name);
+	}
+
+	return oauth_json_has_error(ctx) ? JSON_SEM_ACTION_FAILED : JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_scalar(void *state, char *token, JsonTokenType type)
+{
+	struct json_ctx *ctx = state;
+
+	if (!ctx->nested)
+	{
+		ctx->errmsg = libpq_gettext("top-level element must be an object");
+		return JSON_SEM_ACTION_FAILED;
+	}
+
+	if (ctx->target_field)
+	{
+		Assert(ctx->nested == 1);
+
+		/*
+		 * We don't allow duplicate field names; error out if the target has
+		 * already been set.
+		 */
+		if (*ctx->target_field)
+		{
+			oauth_json_set_error(ctx,
+								 libpq_gettext("field \"%s\" is duplicated"),
+								 ctx->target_field_name);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		/* The only fields we support are strings. */
+		if (type != JSON_TOKEN_STRING)
+		{
+			oauth_json_set_error(ctx,
+								 libpq_gettext("field \"%s\" must be a string"),
+								 ctx->target_field_name);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		*ctx->target_field = strdup(token);
+		if (!*ctx->target_field)
+			return JSON_OUT_OF_MEMORY;
+
+		ctx->target_field = NULL;
+		ctx->target_field_name = NULL;
+	}
+	else
+	{
+		/* otherwise we just ignore it */
+	}
+
+	return JSON_SUCCESS;
+}
+
+#define HTTPS_SCHEME "https://"
+#define HTTP_SCHEME "http://"
+
+/* We support both well-known suffixes defined by RFC 8414. */
+#define WK_PREFIX "/.well-known/"
+#define OPENID_WK_SUFFIX "openid-configuration"
+#define OAUTH_WK_SUFFIX "oauth-authorization-server"
+
+/*
+ * Derives an issuer identifier from one of our recognized .well-known URIs,
+ * using the rules in RFC 8414.
+ */
+static char *
+issuer_from_well_known_uri(PGconn *conn, const char *wkuri)
+{
+	const char *authority_start = NULL;
+	const char *wk_start;
+	const char *wk_end;
+	char	   *issuer;
+	ptrdiff_t	start_offset,
+				end_offset;
+	size_t		end_len;
+
+	/*
+	 * https:// is required for issuer identifiers (RFC 8414, Sec. 2; OIDC
+	 * Discovery 1.0, Sec. 3). This is a case-insensitive comparison at this
+	 * level (but issuer identifier comparison at the level above this is
+	 * case-sensitive, so in practice it's probably moot).
+	 */
+	if (pg_strncasecmp(wkuri, HTTPS_SCHEME, strlen(HTTPS_SCHEME)) == 0)
+		authority_start = wkuri + strlen(HTTPS_SCHEME);
+
+	if (!authority_start
+		&& oauth_unsafe_debugging_enabled()
+		&& pg_strncasecmp(wkuri, HTTP_SCHEME, strlen(HTTP_SCHEME)) == 0)
+	{
+		/* Allow http:// for testing only. */
+		authority_start = wkuri + strlen(HTTP_SCHEME);
+	}
+
+	if (!authority_start)
+	{
+		libpq_append_conn_error(conn,
+								"OAuth discovery URI \"%s\" must use HTTPS",
+								wkuri);
+		return NULL;
+	}
+
+	/*
+	 * Well-known URIs in general may support queries and fragments, but the
+	 * two types we support here do not. (They must be constructed from the
+	 * components of issuer identifiers, which themselves may not contain any
+	 * queries or fragments.)
+	 *
+	 * It's important to check this first, to avoid getting tricked later by a
+	 * prefix buried inside a query or fragment.
+	 */
+	if (strpbrk(authority_start, "?#") != NULL)
+	{
+		libpq_append_conn_error(conn,
+								"OAuth discovery URI \"%s\" must not contain query or fragment components",
+								wkuri);
+		return NULL;
+	}
+
+	/*
+	 * Find the start of the .well-known prefix. IETF rules (RFC 8615) state
+	 * this must be at the beginning of the path component, but OIDC defined
+	 * it at the end instead (OIDC Discovery 1.0, Sec. 4), so we have to
+	 * search for it anywhere.
+	 */
+	wk_start = strstr(authority_start, WK_PREFIX);
+	if (!wk_start)
+	{
+		libpq_append_conn_error(conn,
+								"OAuth discovery URI \"%s\" is not a .well-known URI",
+								wkuri);
+		return NULL;
+	}
+
+	/*
+	 * Now find the suffix type. We only support the two defined in OIDC
+	 * Discovery 1.0 and RFC 8414.
+	 */
+	wk_end = wk_start + strlen(WK_PREFIX);
+
+	if (strncmp(wk_end, OPENID_WK_SUFFIX, strlen(OPENID_WK_SUFFIX)) == 0)
+		wk_end += strlen(OPENID_WK_SUFFIX);
+	else if (strncmp(wk_end, OAUTH_WK_SUFFIX, strlen(OAUTH_WK_SUFFIX)) == 0)
+		wk_end += strlen(OAUTH_WK_SUFFIX);
+	else
+		wk_end = NULL;
+
+	/*
+	 * Even if there's a match, we still need to check to make sure the suffix
+	 * takes up the entire path segment, to weed out constructions like
+	 * "/.well-known/openid-configuration-bad".
+	 */
+	if (!wk_end || (*wk_end != '/' && *wk_end != '\0'))
+	{
+		libpq_append_conn_error(conn,
+								"OAuth discovery URI \"%s\" uses an unsupported .well-known suffix",
+								wkuri);
+		return NULL;
+	}
+
+	/*
+	 * Finally, make sure the .well-known components are provided either as a
+	 * prefix (IETF style) or as a postfix (OIDC style). In other words,
+	 * "https://localhost/a/.well-known/openid-configuration/b" is not allowed
+	 * to claim association with "https://localhost/a/b".
+	 */
+	if (*wk_end != '\0')
+	{
+		/*
+		 * It's not at the end, so it's required to be at the beginning at the
+		 * path. Find the starting slash.
+		 */
+		const char *path_start;
+
+		path_start = strchr(authority_start, '/');
+		Assert(path_start);		/* otherwise we wouldn't have found WK_PREFIX */
+
+		if (wk_start != path_start)
+		{
+			libpq_append_conn_error(conn,
+									"OAuth discovery URI \"%s\" uses an invalid format",
+									wkuri);
+			return NULL;
+		}
+	}
+
+	/* Checks passed! Now build the issuer. */
+	issuer = strdup(wkuri);
+	if (!issuer)
+	{
+		libpq_append_conn_error(conn, "out of memory");
+		return NULL;
+	}
+
+	/*
+	 * The .well-known components are from [wk_start, wk_end). Remove those to
+	 * form the issuer ID, by shifting the path suffix (which may be empty)
+	 * leftwards.
+	 */
+	start_offset = wk_start - wkuri;
+	end_offset = wk_end - wkuri;
+	end_len = strlen(wk_end) + 1;	/* move the NULL terminator too */
+
+	memmove(issuer + start_offset, issuer + end_offset, end_len);
+
+	return issuer;
+}
+
+/*
+ * Parses the server error result (RFC 7628, Sec. 3.2.2) contained in msg and
+ * stores any discovered openid_configuration and scope settings for the
+ * connection.
+ */
+static bool
+handle_oauth_sasl_error(PGconn *conn, const char *msg, int msglen)
+{
+	JsonLexContext lex = {0};
+	JsonSemAction sem = {0};
+	JsonParseErrorType err;
+	struct json_ctx ctx = {0};
+	char	   *errmsg = NULL;
+	bool		success = false;
+
+	Assert(conn->oauth_issuer_id);	/* ensured by setup_oauth_parameters() */
+
+	/* Sanity check. */
+	if (strlen(msg) != msglen)
+	{
+		libpq_append_conn_error(conn,
+								"server's error message contained an embedded NULL, and was discarded");
+		return false;
+	}
+
+	/*
+	 * pg_parse_json doesn't validate the incoming UTF-8, so we have to check
+	 * that up front.
+	 */
+	if (pg_encoding_verifymbstr(PG_UTF8, msg, msglen) != msglen)
+	{
+		libpq_append_conn_error(conn,
+								"server's error response is not valid UTF-8");
+		return false;
+	}
+
+	makeJsonLexContextCstringLen(&lex, msg, msglen, PG_UTF8, true);
+	setJsonLexContextOwnsTokens(&lex, true);	/* must not leak on error */
+
+	initPQExpBuffer(&ctx.errbuf);
+	sem.semstate = &ctx;
+
+	sem.object_start = oauth_json_object_start;
+	sem.object_end = oauth_json_object_end;
+	sem.object_field_start = oauth_json_object_field_start;
+	sem.array_start = oauth_json_array_start;
+	sem.scalar = oauth_json_scalar;
+
+	err = pg_parse_json(&lex, &sem);
+
+	if (err == JSON_SEM_ACTION_FAILED)
+	{
+		if (PQExpBufferDataBroken(ctx.errbuf))
+			errmsg = libpq_gettext("out of memory");
+		else if (ctx.errmsg)
+			errmsg = ctx.errmsg;
+		else
+		{
+			/*
+			 * Developer error: one of the action callbacks didn't call
+			 * oauth_json_set_error() before erroring out.
+			 */
+			Assert(oauth_json_has_error(&ctx));
+			errmsg = "<unexpected empty error>";
+		}
+	}
+	else if (err != JSON_SUCCESS)
+		errmsg = json_errdetail(err, &lex);
+
+	if (errmsg)
+		libpq_append_conn_error(conn,
+								"failed to parse server's error response: %s",
+								errmsg);
+
+	/* Don't need the error buffer or the JSON lexer anymore. */
+	termPQExpBuffer(&ctx.errbuf);
+	freeJsonLexContext(&lex);
+
+	if (errmsg)
+		goto cleanup;
+
+	if (ctx.discovery_uri)
+	{
+		char	   *discovery_issuer;
+
+		/*
+		 * The URI MUST correspond to our existing issuer, to avoid mix-ups.
+		 *
+		 * Issuer comparison is done byte-wise, rather than performing any URL
+		 * normalization; this follows the suggestions for issuer comparison
+		 * in RFC 9207 Sec. 2.4 (which requires simple string comparison) and
+		 * vastly simplifies things. Since this is the key protection against
+		 * a rogue server sending the client to an untrustworthy location,
+		 * simpler is better.
+		 */
+		discovery_issuer = issuer_from_well_known_uri(conn, ctx.discovery_uri);
+		if (!discovery_issuer)
+			goto cleanup;		/* error message already set */
+
+		if (strcmp(conn->oauth_issuer_id, discovery_issuer) != 0)
+		{
+			libpq_append_conn_error(conn,
+									"server's discovery document at %s (issuer \"%s\") is incompatible with oauth_issuer (%s)",
+									ctx.discovery_uri, discovery_issuer,
+									conn->oauth_issuer_id);
+
+			free(discovery_issuer);
+			goto cleanup;
+		}
+
+		free(discovery_issuer);
+
+		if (!conn->oauth_discovery_uri)
+		{
+			conn->oauth_discovery_uri = ctx.discovery_uri;
+			ctx.discovery_uri = NULL;
+		}
+		else
+		{
+			/* This must match the URI we'd previously determined. */
+			if (strcmp(conn->oauth_discovery_uri, ctx.discovery_uri) != 0)
+			{
+				libpq_append_conn_error(conn,
+										"server's discovery document has moved to %s (previous location was %s)",
+										ctx.discovery_uri,
+										conn->oauth_discovery_uri);
+				goto cleanup;
+			}
+		}
+	}
+
+	if (ctx.scope)
+	{
+		/* Servers may not override a previously set oauth_scope. */
+		if (!conn->oauth_scope)
+		{
+			conn->oauth_scope = ctx.scope;
+			ctx.scope = NULL;
+		}
+	}
+
+	if (!ctx.status)
+	{
+		libpq_append_conn_error(conn,
+								"server sent error response without a status");
+		goto cleanup;
+	}
+
+	if (strcmp(ctx.status, "invalid_token") != 0)
+	{
+		/*
+		 * invalid_token is the only error code we'll automatically retry for;
+		 * otherwise, just bail out now.
+		 */
+		libpq_append_conn_error(conn,
+								"server rejected OAuth bearer token: %s",
+								ctx.status);
+		goto cleanup;
+	}
+
+	success = true;
+
+cleanup:
+	free(ctx.status);
+	free(ctx.scope);
+	free(ctx.discovery_uri);
+
+	return success;
+}
+
+/*
+ * Callback implementation of conn->async_auth() for a user-defined OAuth flow.
+ * Delegates the retrieval of the token to the application's 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_*
+ * statuses for use by PQconnectPoll().
+ */
+static PostgresPollingStatusType
+run_user_oauth_flow(PGconn *conn)
+{
+	fe_oauth_state *state = conn->sasl_state;
+	PGoauthBearerRequest *request = state->async_ctx;
+	PostgresPollingStatusType status;
+
+	if (!request->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);
+	if (status == PGRES_POLLING_FAILED)
+	{
+		libpq_append_conn_error(conn, "user-defined OAuth flow failed");
+		return status;
+	}
+	else if (status == PGRES_POLLING_OK)
+	{
+		/*
+		 * 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.)
+		 */
+		if (!request->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);
+		if (!conn->oauth_token)
+		{
+			libpq_append_conn_error(conn, "out of memory");
+			return PGRES_POLLING_FAILED;
+		}
+
+		return PGRES_POLLING_OK;
+	}
+
+	/* The hook wants the client to poll the altsock. Make sure it set one. */
+	if (conn->altsock == PGINVALID_SOCKET)
+	{
+		libpq_append_conn_error(conn,
+								"user-defined OAuth flow did not provide a socket for polling");
+		return PGRES_POLLING_FAILED;
+	}
+
+	return status;
+}
+
+/*
+ * Cleanup callback for the async user flow. Delegates most of its job to the
+ * user-provided cleanup implementation, then disconnects the altsock.
+ */
+static void
+cleanup_user_oauth_flow(PGconn *conn)
+{
+	fe_oauth_state *state = conn->sasl_state;
+	PGoauthBearerRequest *request = state->async_ctx;
+
+	Assert(request);
+
+	if (request->cleanup)
+		request->cleanup(conn, request);
+	conn->altsock = PGINVALID_SOCKET;
+
+	free(request);
+	state->async_ctx = NULL;
+}
+
+/*
+ * Chooses an OAuth client flow for the connection, which will retrieve a Bearer
+ * 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
+ * asynchronous callbacks which will be managed by run_user_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.)
+ *
+ * If neither a custom handler nor the builtin flow is available, the connection
+ * fails here.
+ */
+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,
+	};
+
+	Assert(request.openid_configuration);
+
+	/* The client may have overridden the OAuth flow. */
+	res = PQauthDataHook(PQAUTHDATA_OAUTH_BEARER_TOKEN, conn, &request);
+	if (res > 0)
+	{
+		PGoauthBearerRequest *request_copy;
+
+		if (request.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);
+			if (!conn->oauth_token)
+			{
+				libpq_append_conn_error(conn, "out of memory");
+				goto fail;
+			}
+
+			/* short-circuit */
+			if (request.cleanup)
+				request.cleanup(conn, &request);
+			return true;
+		}
+
+		request_copy = malloc(sizeof(*request_copy));
+		if (!request_copy)
+		{
+			libpq_append_conn_error(conn, "out of memory");
+			goto fail;
+		}
+
+		memcpy(request_copy, &request, sizeof(request));
+
+		conn->async_auth = run_user_oauth_flow;
+		conn->cleanup_async_auth = cleanup_user_oauth_flow;
+		state->async_ctx = request_copy;
+	}
+	else if (res < 0)
+	{
+		libpq_append_conn_error(conn, "user-defined OAuth flow failed");
+		goto fail;
+	}
+	else
+	{
+#if USE_LIBCURL
+		/* Hand off to our built-in OAuth flow. */
+		conn->async_auth = pg_fe_run_oauth_flow;
+		conn->cleanup_async_auth = pg_fe_cleanup_oauth_flow;
+
+#else
+		libpq_append_conn_error(conn, "no custom OAuth flows are available, and libpq was not built with libcurl support");
+		goto fail;
+
+#endif
+	}
+
+	return true;
+
+fail:
+	if (request.cleanup)
+		request.cleanup(conn, &request);
+	return false;
+}
+
+/*
+ * Fill in our issuer identifier and discovery URI, if possible, using the
+ * connection parameters. If conn->oauth_discovery_uri can't be populated in
+ * this function, it will be requested from the server.
+ */
+static bool
+setup_oauth_parameters(PGconn *conn)
+{
+	/*---
+	 * To talk to a server, we require the user to provide issuer and client
+	 * identifiers.
+	 *
+	 * While it's possible for an OAuth client to support multiple issuers, it
+	 * requires additional effort to make sure the flows in use are safe -- to
+	 * quote RFC 9207,
+	 *
+	 *     OAuth clients that interact with only one authorization server are
+	 *     not vulnerable to mix-up attacks. However, when such clients decide
+	 *     to add support for a second authorization server in the future, they
+	 *     become vulnerable and need to apply countermeasures to mix-up
+	 *     attacks.
+	 *
+	 * For now, we allow only one.
+	 */
+	if (!conn->oauth_issuer || !conn->oauth_client_id)
+	{
+		libpq_append_conn_error(conn,
+								"server requires OAuth authentication, but oauth_issuer and oauth_client_id are not both set");
+		return false;
+	}
+
+	/*
+	 * oauth_issuer is interpreted differently if it's a well-known discovery
+	 * URI rather than just an issuer identifier.
+	 */
+	if (strstr(conn->oauth_issuer, WK_PREFIX) != NULL)
+	{
+		/*
+		 * Convert the URI back to an issuer identifier. (This also performs
+		 * validation of the URI format.)
+		 */
+		conn->oauth_issuer_id = issuer_from_well_known_uri(conn,
+														   conn->oauth_issuer);
+		if (!conn->oauth_issuer_id)
+			return false;		/* error message already set */
+
+		conn->oauth_discovery_uri = strdup(conn->oauth_issuer);
+		if (!conn->oauth_discovery_uri)
+		{
+			libpq_append_conn_error(conn, "out of memory");
+			return false;
+		}
+	}
+	else
+	{
+		/*
+		 * Treat oauth_issuer as an issuer identifier. We'll ask the server
+		 * for the discovery URI.
+		 */
+		conn->oauth_issuer_id = strdup(conn->oauth_issuer);
+		if (!conn->oauth_issuer_id)
+		{
+			libpq_append_conn_error(conn, "out of memory");
+			return false;
+		}
+	}
+
+	return true;
+}
+
+/*
+ * Implements the OAUTHBEARER SASL exchange (RFC 7628, Sec. 3.2).
+ *
+ * If the necessary OAuth parameters are set up on the connection, this will run
+ * the client flow asynchronously and present the resulting token to the server.
+ * Otherwise, an empty discovery response will be sent and any parameters sent
+ * back by the server will be stored for a second attempt.
+ *
+ * For a full description of the API, see libpq/sasl.h.
+ */
+static SASLStatus
+oauth_exchange(void *opaq, bool final,
+			   char *input, int inputlen,
+			   char **output, int *outputlen)
+{
+	fe_oauth_state *state = opaq;
+	PGconn	   *conn = state->conn;
+	bool		discover = false;
+
+	*output = NULL;
+	*outputlen = 0;
+
+	switch (state->step)
+	{
+		case FE_OAUTH_INIT:
+			/* We begin in the initial response phase. */
+			Assert(inputlen == -1);
+
+			if (!setup_oauth_parameters(conn))
+				return SASL_FAILED;
+
+			if (conn->oauth_token)
+			{
+				/*
+				 * A previous connection already fetched the token; we'll use
+				 * it below.
+				 */
+			}
+			else if (conn->oauth_discovery_uri)
+			{
+				/*
+				 * We don't have a token, but we have a discovery URI already
+				 * stored. Decide whether we're using a user-provided OAuth
+				 * flow or the one we have built in.
+				 */
+				if (!setup_token_request(conn, state))
+					return SASL_FAILED;
+
+				if (conn->oauth_token)
+				{
+					/*
+					 * A really smart user implementation may have already
+					 * given us the token (e.g. if there was an unexpired copy
+					 * already cached), and we can use it immediately.
+					 */
+				}
+				else
+				{
+					/*
+					 * Otherwise, we'll have to hand the connection over to
+					 * our OAuth implementation.
+					 *
+					 * This could take a while, since it generally involves a
+					 * user in the loop. To avoid consuming the server's
+					 * authentication timeout, we'll continue this handshake
+					 * to the end, so that the server can close its side of
+					 * the connection. We'll open a second connection later
+					 * once we've retrieved a token.
+					 */
+					discover = true;
+				}
+			}
+			else
+			{
+				/*
+				 * If we don't have a token, and we don't have a discovery URI
+				 * to be able to request a token, we ask the server for one
+				 * explicitly.
+				 */
+				discover = true;
+			}
+
+			/*
+			 * Generate an initial response. This either contains a token, if
+			 * we have one, or an empty discovery response which is doomed to
+			 * fail.
+			 */
+			*output = client_initial_response(conn, discover);
+			if (!*output)
+				return SASL_FAILED;
+
+			*outputlen = strlen(*output);
+			state->step = FE_OAUTH_BEARER_SENT;
+
+			if (conn->oauth_token)
+			{
+				/*
+				 * For the purposes of require_auth, our side of
+				 * authentication is done at this point; the server will
+				 * either accept the connection or send an error. Unlike
+				 * SCRAM, there is no additional server data to check upon
+				 * success.
+				 */
+				conn->client_finished_auth = true;
+			}
+
+			return SASL_CONTINUE;
+
+		case FE_OAUTH_BEARER_SENT:
+			if (final)
+			{
+				/*
+				 * OAUTHBEARER does not make use of additional data with a
+				 * successful SASL exchange, so we shouldn't get an
+				 * AuthenticationSASLFinal message.
+				 */
+				libpq_append_conn_error(conn,
+										"server sent unexpected additional OAuth data");
+				return SASL_FAILED;
+			}
+
+			/*
+			 * An error message was sent by the server. Respond with the
+			 * required dummy message (RFC 7628, sec. 3.2.3).
+			 */
+			*output = strdup(kvsep);
+			if (unlikely(!*output))
+			{
+				libpq_append_conn_error(conn, "out of memory");
+				return SASL_FAILED;
+			}
+			*outputlen = strlen(*output);	/* == 1 */
+
+			/* Grab the settings from discovery. */
+			if (!handle_oauth_sasl_error(conn, input, inputlen))
+				return SASL_FAILED;
+
+			if (conn->oauth_token)
+			{
+				/*
+				 * The server rejected our token. Continue onwards towards the
+				 * expected FATAL message, but mark our state to catch any
+				 * unexpected "success" from the server.
+				 */
+				state->step = FE_OAUTH_SERVER_ERROR;
+				return SASL_CONTINUE;
+			}
+
+			if (!conn->async_auth)
+			{
+				/*
+				 * No OAuth flow is set up yet. Did we get enough information
+				 * from the server to create one?
+				 */
+				if (!conn->oauth_discovery_uri)
+				{
+					libpq_append_conn_error(conn,
+											"server requires OAuth authentication, but no discovery metadata was provided");
+					return SASL_FAILED;
+				}
+
+				/* Yes. Set up the flow now. */
+				if (!setup_token_request(conn, state))
+					return SASL_FAILED;
+
+				if (conn->oauth_token)
+				{
+					/*
+					 * A token was available in a custom flow's cache. Skip
+					 * the asynchronous processing.
+					 */
+					goto reconnect;
+				}
+			}
+
+			/*
+			 * Time to retrieve a token. This involves a number of HTTP
+			 * connections and timed waits, so we escape the synchronous auth
+			 * processing and tell PQconnectPoll to transfer control to our
+			 * async implementation.
+			 */
+			Assert(conn->async_auth);	/* should have been set already */
+			state->step = FE_OAUTH_REQUESTING_TOKEN;
+			return SASL_ASYNC;
+
+		case FE_OAUTH_REQUESTING_TOKEN:
+
+			/*
+			 * We've returned successfully from token retrieval. Double-check
+			 * that we have what we need for the next connection.
+			 */
+			if (!conn->oauth_token)
+			{
+				Assert(false);	/* should have failed before this point! */
+				libpq_append_conn_error(conn,
+										"internal error: OAuth flow did not set a token");
+				return SASL_FAILED;
+			}
+
+			goto reconnect;
+
+		case FE_OAUTH_SERVER_ERROR:
+
+			/*
+			 * After an error, the server should send an error response to
+			 * fail the SASL handshake, which is handled in higher layers.
+			 *
+			 * If we get here, the server either sent *another* challenge
+			 * which isn't defined in the RFC, or completed the handshake
+			 * successfully after telling us it was going to fail. Neither is
+			 * acceptable.
+			 */
+			libpq_append_conn_error(conn,
+									"server sent additional OAuth data after error");
+			return SASL_FAILED;
+
+		default:
+			libpq_append_conn_error(conn, "invalid OAuth exchange state");
+			break;
+	}
+
+	Assert(false);				/* should never get here */
+	return SASL_FAILED;
+
+reconnect:
+
+	/*
+	 * Despite being a failure from the point of view of SASL, we have enough
+	 * information to restart with a new connection.
+	 */
+	libpq_append_conn_error(conn, "retrying connection with new bearer token");
+	conn->oauth_want_retry = true;
+	return SASL_FAILED;
+}
+
+static bool
+oauth_channel_bound(void *opaq)
+{
+	/* This mechanism does not support channel binding. */
+	return false;
+}
+
+/*
+ * Fully clears out any stored OAuth token. This is done proactively upon
+ * successful connection as well as during pqClosePGconn().
+ */
+void
+pqClearOAuthToken(PGconn *conn)
+{
+	if (!conn->oauth_token)
+		return;
+
+	explicit_bzero(conn->oauth_token, strlen(conn->oauth_token));
+	free(conn->oauth_token);
+	conn->oauth_token = NULL;
+}
+
+/*
+ * Returns true if the PGOAUTHDEBUG=UNSAFE flag is set in the environment.
+ */
+bool
+oauth_unsafe_debugging_enabled(void)
+{
+	const char *env = getenv("PGOAUTHDEBUG");
+
+	return (env && strcmp(env, "UNSAFE") == 0);
+}
diff --git a/src/interfaces/libpq/fe-auth-oauth.h b/src/interfaces/libpq/fe-auth-oauth.h
new file mode 100644
index 0000000000..3259872168
--- /dev/null
+++ b/src/interfaces/libpq/fe-auth-oauth.h
@@ -0,0 +1,45 @@
+/*-------------------------------------------------------------------------
+ *
+ * fe-auth-oauth.h
+ *
+ *	  Definitions for OAuth authentication implementations
+ *
+ * Portions Copyright (c) 2024, PostgreSQL Global Development Group
+ *
+ * src/interfaces/libpq/fe-auth-oauth.h
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#ifndef FE_AUTH_OAUTH_H
+#define FE_AUTH_OAUTH_H
+
+#include "libpq-fe.h"
+#include "libpq-int.h"
+
+
+enum fe_oauth_step
+{
+	FE_OAUTH_INIT,
+	FE_OAUTH_BEARER_SENT,
+	FE_OAUTH_REQUESTING_TOKEN,
+	FE_OAUTH_SERVER_ERROR,
+};
+
+typedef struct
+{
+	enum fe_oauth_step step;
+
+	PGconn	   *conn;
+	void	   *async_ctx;
+} fe_oauth_state;
+
+extern PostgresPollingStatusType pg_fe_run_oauth_flow(PGconn *conn);
+extern void pg_fe_cleanup_oauth_flow(PGconn *conn);
+extern void pqClearOAuthToken(PGconn *conn);
+extern bool oauth_unsafe_debugging_enabled(void);
+
+/* Mechanisms in fe-auth-oauth.c */
+extern const pg_fe_sasl_mech pg_oauth_mech;
+
+#endif							/* FE_AUTH_OAUTH_H */
diff --git a/src/interfaces/libpq/fe-auth.c b/src/interfaces/libpq/fe-auth.c
index 761ee8f88f..ec7a923604 100644
--- a/src/interfaces/libpq/fe-auth.c
+++ b/src/interfaces/libpq/fe-auth.c
@@ -40,9 +40,11 @@
 #endif
 
 #include "common/md5.h"
+#include "common/oauth-common.h"
 #include "common/scram-common.h"
 #include "fe-auth.h"
 #include "fe-auth-sasl.h"
+#include "fe-auth-oauth.h"
 #include "libpq-fe.h"
 
 #ifdef ENABLE_GSS
@@ -535,6 +537,13 @@ pg_SASL_init(PGconn *conn, int payloadlen, bool *async)
 			conn->sasl = &pg_scram_mech;
 			conn->password_needed = true;
 		}
+		else if (strcmp(mechanism_buf.data, OAUTHBEARER_NAME) == 0 &&
+				 !selected_mechanism)
+		{
+			selected_mechanism = OAUTHBEARER_NAME;
+			conn->sasl = &pg_oauth_mech;
+			conn->password_needed = false;
+		}
 	}
 
 	if (!selected_mechanism)
@@ -559,13 +568,6 @@ pg_SASL_init(PGconn *conn, int payloadlen, bool *async)
 
 		if (!allowed)
 		{
-			/*
-			 * TODO: this is dead code until a second SASL mechanism is added;
-			 * the connection can't have proceeded past check_expected_areq()
-			 * if no SASL methods are allowed.
-			 */
-			Assert(false);
-
 			libpq_append_conn_error(conn, "authentication method requirement \"%s\" failed: server requested %s authentication",
 									conn->require_auth, selected_mechanism);
 			goto error;
@@ -1580,3 +1582,23 @@ PQchangePassword(PGconn *conn, const char *user, const char *passwd)
 		}
 	}
 }
+
+PQauthDataHook_type PQauthDataHook = PQdefaultAuthDataHook;
+
+PQauthDataHook_type
+PQgetAuthDataHook(void)
+{
+	return PQauthDataHook;
+}
+
+void
+PQsetAuthDataHook(PQauthDataHook_type hook)
+{
+	PQauthDataHook = hook ? hook : PQdefaultAuthDataHook;
+}
+
+int
+PQdefaultAuthDataHook(PGauthData type, PGconn *conn, void *data)
+{
+	return 0;					/* handle nothing */
+}
diff --git a/src/interfaces/libpq/fe-auth.h b/src/interfaces/libpq/fe-auth.h
index 1d4991f899..de98e0d20c 100644
--- a/src/interfaces/libpq/fe-auth.h
+++ b/src/interfaces/libpq/fe-auth.h
@@ -18,6 +18,9 @@
 #include "libpq-int.h"
 
 
+extern PQauthDataHook_type PQauthDataHook;
+
+
 /* Prototypes for functions in fe-auth.c */
 extern int	pg_fe_sendauth(AuthRequest areq, int payloadlen, PGconn *conn,
 						   bool *async);
diff --git a/src/interfaces/libpq/fe-connect.c b/src/interfaces/libpq/fe-connect.c
index 85d1ca2864..d5051f5e82 100644
--- a/src/interfaces/libpq/fe-connect.c
+++ b/src/interfaces/libpq/fe-connect.c
@@ -28,6 +28,7 @@
 #include "common/scram-common.h"
 #include "common/string.h"
 #include "fe-auth.h"
+#include "fe-auth-oauth.h"
 #include "libpq-fe.h"
 #include "libpq-int.h"
 #include "mb/pg_wchar.h"
@@ -373,6 +374,23 @@ static const internalPQconninfoOption PQconninfoOptions[] = {
 	{"scram_server_key", NULL, NULL, NULL, "SCRAM-Server-Key", "D", SCRAM_MAX_KEY_LEN * 2,
 	offsetof(struct pg_conn, scram_server_key)},
 
+	/* OAuth v2 */
+	{"oauth_issuer", NULL, NULL, NULL,
+		"OAuth-Issuer", "", 40,
+	offsetof(struct pg_conn, oauth_issuer)},
+
+	{"oauth_client_id", NULL, NULL, NULL,
+		"OAuth-Client-ID", "", 40,
+	offsetof(struct pg_conn, oauth_client_id)},
+
+	{"oauth_client_secret", NULL, NULL, NULL,
+		"OAuth-Client-Secret", "", 40,
+	offsetof(struct pg_conn, oauth_client_secret)},
+
+	{"oauth_scope", NULL, NULL, NULL,
+		"OAuth-Scope", "", 15,
+	offsetof(struct pg_conn, oauth_scope)},
+
 	/* Terminating entry --- MUST BE LAST */
 	{NULL, NULL, NULL, NULL,
 	NULL, NULL, 0}
@@ -399,6 +417,7 @@ static const PQEnvironmentOption EnvironmentOptions[] =
 static const pg_fe_sasl_mech *supported_sasl_mechs[] =
 {
 	&pg_scram_mech,
+	&pg_oauth_mech,
 };
 #define SASL_MECHANISM_COUNT lengthof(supported_sasl_mechs)
 
@@ -655,6 +674,7 @@ pqDropServerData(PGconn *conn)
 	conn->write_failed = false;
 	free(conn->write_err_msg);
 	conn->write_err_msg = NULL;
+	conn->oauth_want_retry = false;
 
 	/*
 	 * Cancel connections need to retain their be_pid and be_key across
@@ -1144,7 +1164,7 @@ static inline void
 fill_allowed_sasl_mechs(PGconn *conn)
 {
 	/*---
-	 * We only support one mechanism at the moment, so rather than deal with a
+	 * We only support two mechanisms at the moment, so rather than deal with a
 	 * linked list, conn->allowed_sasl_mechs is an array of static length. We
 	 * rely on the compile-time assertion here to keep us honest.
 	 *
@@ -1519,6 +1539,10 @@ pqConnectOptions2(PGconn *conn)
 			{
 				mech = &pg_scram_mech;
 			}
+			else if (strcmp(method, "oauth") == 0)
+			{
+				mech = &pg_oauth_mech;
+			}
 
 			/*
 			 * Final group: meta-options.
@@ -4111,7 +4135,19 @@ keep_going:						/* We will come back to here until there is
 				conn->inStart = conn->inCursor;
 
 				if (res != STATUS_OK)
+				{
+					/*
+					 * OAuth connections may perform two-step discovery, where
+					 * the first connection is a dummy.
+					 */
+					if (conn->sasl == &pg_oauth_mech && conn->oauth_want_retry)
+					{
+						need_new_connection = true;
+						goto keep_going;
+					}
+
 					goto error_return;
+				}
 
 				/*
 				 * Just make sure that any data sent by pg_fe_sendauth is
@@ -4390,6 +4426,9 @@ keep_going:						/* We will come back to here until there is
 					}
 				}
 
+				/* Don't hold onto any OAuth tokens longer than necessary. */
+				pqClearOAuthToken(conn);
+
 				/*
 				 * For non cancel requests we can release the address list
 				 * now. For cancel requests we never actually resolve
@@ -5002,6 +5041,12 @@ freePGconn(PGconn *conn)
 	free(conn->load_balance_hosts);
 	free(conn->scram_client_key);
 	free(conn->scram_server_key);
+	free(conn->oauth_issuer);
+	free(conn->oauth_issuer_id);
+	free(conn->oauth_discovery_uri);
+	free(conn->oauth_client_id);
+	free(conn->oauth_client_secret);
+	free(conn->oauth_scope);
 	termPQExpBuffer(&conn->errorMessage);
 	termPQExpBuffer(&conn->workBuffer);
 
@@ -5155,6 +5200,7 @@ pqClosePGconn(PGconn *conn)
 	conn->asyncStatus = PGASYNC_IDLE;
 	conn->xactStatus = PQTRANS_IDLE;
 	conn->pipelineStatus = PQ_PIPELINE_OFF;
+	pqClearOAuthToken(conn);
 	pqClearAsyncResult(conn);	/* deallocate result */
 	pqClearConnErrorState(conn);
 
diff --git a/src/interfaces/libpq/libpq-fe.h b/src/interfaces/libpq/libpq-fe.h
index a3491faf0c..5f8d608261 100644
--- a/src/interfaces/libpq/libpq-fe.h
+++ b/src/interfaces/libpq/libpq-fe.h
@@ -59,6 +59,8 @@ extern "C"
 /* Features added in PostgreSQL v18: */
 /* Indicates presence of PQfullProtocolVersion */
 #define LIBPQ_HAS_FULL_PROTOCOL_VERSION 1
+/* Indicates presence of the PQAUTHDATA_PROMPT_OAUTH_DEVICE authdata hook */
+#define LIBPQ_HAS_PROMPT_OAUTH_DEVICE 1
 
 /*
  * Option flags for PQcopyResult
@@ -186,6 +188,13 @@ typedef enum
 	PQ_PIPELINE_ABORTED
 } PGpipelineStatus;
 
+typedef enum
+{
+	PQAUTHDATA_PROMPT_OAUTH_DEVICE, /* user must visit a device-authorization
+									 * URL */
+	PQAUTHDATA_OAUTH_BEARER_TOKEN,	/* server requests an OAuth Bearer token */
+} PGauthData;
+
 /* PGconn encapsulates a connection to the backend.
  * The contents of this struct are not supposed to be known to applications.
  */
@@ -720,10 +729,83 @@ extern int	PQenv2encoding(void);
 
 /* === in fe-auth.c === */
 
+typedef struct _PGpromptOAuthDevice
+{
+	const char *verification_uri;	/* verification URI to visit */
+	const char *user_code;		/* user code to enter */
+} PGpromptOAuthDevice;
+
+/* for PGoauthBearerRequest.async() */
+#ifdef _WIN32
+#define SOCKTYPE uintptr_t		/* avoids depending on winsock2.h for SOCKET */
+#else
+#define SOCKTYPE int
+#endif
+
+typedef struct _PGoauthBearerRequest
+{
+	/* Hook inputs (constant across all calls) */
+	const char *const openid_configuration; /* OIDC discovery URI */
+	const char *const scope;	/* required scope(s), or NULL */
+
+	/* Hook outputs */
+
+	/*---------
+	 * Callback implementing a custom asynchronous OAuth flow.
+	 *
+	 * The callback may return
+	 * - PGRES_POLLING_READING/WRITING, to indicate that a socket descriptor
+	 *   has been stored in *altsock and libpq should wait until it is
+	 *   readable or writable before calling back;
+	 * - PGRES_POLLING_OK, to indicate that the flow is complete and
+	 *   request->token has been set; or
+	 * - PGRES_POLLING_FAILED, to indicate that token retrieval has failed.
+	 *
+	 * This callback is optional. If the token can be obtained without
+	 * 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.
+	 */
+	PostgresPollingStatusType (*async) (PGconn *conn,
+										struct _PGoauthBearerRequest *request,
+										SOCKTYPE * altsock);
+
+	/*
+	 * Callback to clean up custom allocations. A hook implementation may use
+	 * this to free request->token and any resources in request->user.
+	 *
+	 * This is technically optional, but highly recommended, because there is
+	 * no other indication as to when it is safe to free the token.
+	 */
+	void		(*cleanup) (PGconn *conn, struct _PGoauthBearerRequest *request);
+
+	/*
+	 * The hook should set this to the Bearer token contents for the
+	 * connection, once the flow is completed.  The token contents must remain
+	 * available to libpq until the hook's cleanup callback is called.
+	 */
+	char	   *token;
+
+	/*
+	 * Hook-defined data. libpq will not modify this pointer across calls to
+	 * the async callback, so it can be used to keep track of
+	 * application-specific state. Resources allocated here should be freed by
+	 * the cleanup callback.
+	 */
+	void	   *user;
+} PGoauthBearerRequest;
+
+#undef SOCKTYPE
+
 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);
 
+typedef int (*PQauthDataHook_type) (PGauthData type, PGconn *conn, void *data);
+extern void PQsetAuthDataHook(PQauthDataHook_type hook);
+extern PQauthDataHook_type PQgetAuthDataHook(void);
+extern int	PQdefaultAuthDataHook(PGauthData type, PGconn *conn, void *data);
+
 /* === in encnames.c === */
 
 extern int	pg_char_to_encoding(const char *name);
diff --git a/src/interfaces/libpq/libpq-int.h b/src/interfaces/libpq/libpq-int.h
index 2546f9f8a5..f36f7f19d5 100644
--- a/src/interfaces/libpq/libpq-int.h
+++ b/src/interfaces/libpq/libpq-int.h
@@ -437,6 +437,17 @@ struct pg_conn
 								 * cancel request, instead of being a normal
 								 * connection that's used for queries */
 
+	/* OAuth v2 */
+	char	   *oauth_issuer;	/* token issuer/URL */
+	char	   *oauth_issuer_id;	/* token issuer identifier */
+	char	   *oauth_discovery_uri;	/* URI of the issuer's discovery
+										 * document */
+	char	   *oauth_client_id;	/* client identifier */
+	char	   *oauth_client_secret;	/* client secret */
+	char	   *oauth_scope;	/* access token scope */
+	char	   *oauth_token;	/* access token */
+	bool		oauth_want_retry;	/* should we retry on failure? */
+
 	/* Optional file to write trace info to */
 	FILE	   *Pfdebug;
 	int			traceFlags;
@@ -505,7 +516,7 @@ struct pg_conn
 								 * the server? */
 	uint32		allowed_auth_methods;	/* bitmask of acceptable AuthRequest
 										 * codes */
-	const pg_fe_sasl_mech *allowed_sasl_mechs[1];	/* and acceptable SASL
+	const pg_fe_sasl_mech *allowed_sasl_mechs[2];	/* and acceptable SASL
 													 * mechanisms */
 	bool		client_finished_auth;	/* have we finished our half of the
 										 * authentication exchange? */
diff --git a/src/interfaces/libpq/meson.build b/src/interfaces/libpq/meson.build
index dd64d291b3..19f4a52a97 100644
--- a/src/interfaces/libpq/meson.build
+++ b/src/interfaces/libpq/meson.build
@@ -1,6 +1,7 @@
 # Copyright (c) 2022-2025, PostgreSQL Global Development Group
 
 libpq_sources = files(
+  'fe-auth-oauth.c',
   'fe-auth-scram.c',
   'fe-auth.c',
   'fe-cancel.c',
@@ -37,6 +38,10 @@ if gssapi.found()
   )
 endif
 
+if libcurl.found()
+  libpq_sources += files('fe-auth-oauth-curl.c')
+endif
+
 export_file = custom_target('libpq.exports',
   kwargs: gen_export_kwargs,
 )
diff --git a/src/makefiles/meson.build b/src/makefiles/meson.build
index d49b2079a4..60e13d5023 100644
--- a/src/makefiles/meson.build
+++ b/src/makefiles/meson.build
@@ -229,6 +229,7 @@ pgxs_deps = {
   'gssapi': gssapi,
   'icu': icu,
   'ldap': ldap,
+  'libcurl': libcurl,
   'libxml': libxml,
   'libxslt': libxslt,
   'llvm': llvm,
diff --git a/src/test/authentication/t/001_password.pl b/src/test/authentication/t/001_password.pl
index 1357f806b6..4ce22ccbdf 100644
--- a/src/test/authentication/t/001_password.pl
+++ b/src/test/authentication/t/001_password.pl
@@ -404,11 +404,11 @@ $node->connect_fails(
 $node->connect_fails(
 	"user=scram_role require_auth=!scram-sha-256",
 	"SCRAM authentication forbidden, fails with SCRAM auth",
-	expected_stderr => qr/server requested SASL authentication/);
+	expected_stderr => qr/server requested SCRAM-SHA-256 authentication/);
 $node->connect_fails(
 	"user=scram_role require_auth=!password,!md5,!scram-sha-256",
 	"multiple authentication types forbidden, fails with SCRAM auth",
-	expected_stderr => qr/server requested SASL authentication/);
+	expected_stderr => qr/server requested SCRAM-SHA-256 authentication/);
 
 # Test that bad passwords are rejected.
 $ENV{"PGPASSWORD"} = 'badpass';
@@ -465,13 +465,13 @@ $node->connect_fails(
 	"user=scram_role require_auth=!scram-sha-256",
 	"password authentication forbidden, fails with SCRAM auth",
 	expected_stderr =>
-	  qr/authentication method requirement "!scram-sha-256" failed: server requested SASL authentication/
+	  qr/authentication method requirement "!scram-sha-256" failed: server requested SCRAM-SHA-256 authentication/
 );
 $node->connect_fails(
 	"user=scram_role require_auth=!password,!md5,!scram-sha-256",
 	"multiple authentication types forbidden, fails with SCRAM auth",
 	expected_stderr =>
-	  qr/authentication method requirement "!password,!md5,!scram-sha-256" failed: server requested SASL authentication/
+	  qr/authentication method requirement "!password,!md5,!scram-sha-256" failed: server requested SCRAM-SHA-256 authentication/
 );
 
 # Test SYSTEM_USER <> NULL with parallel workers.
diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile
index c0d3cf0e14..bdfd5f1f8d 100644
--- a/src/test/modules/Makefile
+++ b/src/test/modules/Makefile
@@ -11,6 +11,7 @@ SUBDIRS = \
 		  dummy_index_am \
 		  dummy_seclabel \
 		  libpq_pipeline \
+		  oauth_validator \
 		  plsample \
 		  spgist_name_ops \
 		  test_bloomfilter \
diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build
index 4f544a042d..0c2ccc75a6 100644
--- a/src/test/modules/meson.build
+++ b/src/test/modules/meson.build
@@ -9,6 +9,7 @@ subdir('gin')
 subdir('injection_points')
 subdir('ldap_password_func')
 subdir('libpq_pipeline')
+subdir('oauth_validator')
 subdir('plsample')
 subdir('spgist_name_ops')
 subdir('ssl_passphrase_callback')
diff --git a/src/test/modules/oauth_validator/.gitignore b/src/test/modules/oauth_validator/.gitignore
new file mode 100644
index 0000000000..5dcb3ff972
--- /dev/null
+++ b/src/test/modules/oauth_validator/.gitignore
@@ -0,0 +1,4 @@
+# Generated subdirectories
+/log/
+/results/
+/tmp_check/
diff --git a/src/test/modules/oauth_validator/Makefile b/src/test/modules/oauth_validator/Makefile
new file mode 100644
index 0000000000..f297ed5c96
--- /dev/null
+++ b/src/test/modules/oauth_validator/Makefile
@@ -0,0 +1,40 @@
+#-------------------------------------------------------------------------
+#
+# Makefile for src/test/modules/oauth_validator
+#
+# Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/test/modules/oauth_validator/Makefile
+#
+#-------------------------------------------------------------------------
+
+MODULES = validator fail_validator
+PGFILEDESC = "validator - test OAuth validator module"
+
+PROGRAM = oauth_hook_client
+PGAPPICON = win32
+OBJS = $(WIN32RES) oauth_hook_client.o
+
+PG_CPPFLAGS = -I$(libpq_srcdir)
+PG_LIBS_INTERNAL += $(libpq_pgport)
+
+NO_INSTALLCHECK = 1
+
+TAP_TESTS = 1
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = src/test/modules/oauth_validator
+top_builddir = ../../../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+
+export PYTHON
+export with_libcurl
+export with_python
+
+endif
diff --git a/src/test/modules/oauth_validator/README b/src/test/modules/oauth_validator/README
new file mode 100644
index 0000000000..138a810462
--- /dev/null
+++ b/src/test/modules/oauth_validator/README
@@ -0,0 +1,13 @@
+Test programs and libraries for OAuth
+-------------------------------------
+
+This folder contains tests for the client- and server-side OAuth
+implementations. Most tests are run end-to-end to test both simultaneously. The
+tests in t/001_server use a mock OAuth authorization server, implemented jointly
+by t/OAuth/Server.pm and t/oauth_server.py, to run the libpq Device
+Authorization flow. The tests in t/002_client exercise custom OAuth flows and
+don't need an authorization server.
+
+Tests in this folder generally require 'oauth' to be present in PG_TEST_EXTRA,
+since localhost HTTP servers will be started. A Python installation is required
+to run the mock authorization server.
diff --git a/src/test/modules/oauth_validator/fail_validator.c b/src/test/modules/oauth_validator/fail_validator.c
new file mode 100644
index 0000000000..f77a3e115c
--- /dev/null
+++ b/src/test/modules/oauth_validator/fail_validator.c
@@ -0,0 +1,42 @@
+/*-------------------------------------------------------------------------
+ *
+ * fail_validator.c
+ *	  Test module for serverside OAuth token validation callbacks, which always
+ *	  fails
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/test/modules/oauth_validator/fail_validator.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "fmgr.h"
+#include "libpq/oauth.h"
+
+PG_MODULE_MAGIC;
+
+static ValidatorModuleResult *fail_token(ValidatorModuleState *state,
+										 const char *token,
+										 const char *role);
+
+/* Callback implementations (we only need the main one) */
+static const OAuthValidatorCallbacks validator_callbacks = {
+	.validate_cb = fail_token,
+};
+
+const OAuthValidatorCallbacks *
+_PG_oauth_validator_module_init(void)
+{
+	return &validator_callbacks;
+}
+
+static ValidatorModuleResult *
+fail_token(ValidatorModuleState *state, const char *token, const char *role)
+{
+	elog(FATAL, "fail_validator: sentinel error");
+	pg_unreachable();
+}
diff --git a/src/test/modules/oauth_validator/meson.build b/src/test/modules/oauth_validator/meson.build
new file mode 100644
index 0000000000..4b78c90557
--- /dev/null
+++ b/src/test/modules/oauth_validator/meson.build
@@ -0,0 +1,69 @@
+# Copyright (c) 2024, PostgreSQL Global Development Group
+
+validator_sources = files(
+  'validator.c',
+)
+
+if host_system == 'windows'
+  validator_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'validator',
+    '--FILEDESC', 'validator - test OAuth validator module',])
+endif
+
+validator = shared_module('validator',
+  validator_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += validator
+
+fail_validator_sources = files(
+  'fail_validator.c',
+)
+
+if host_system == 'windows'
+  fail_validator_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'fail_validator',
+    '--FILEDESC', 'fail_validator - failing OAuth validator module',])
+endif
+
+fail_validator = shared_module('fail_validator',
+  fail_validator_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += fail_validator
+
+oauth_hook_client_sources = files(
+  'oauth_hook_client.c',
+)
+
+if host_system == 'windows'
+  oauth_hook_client_sources += rc_bin_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'oauth_hook_client',
+    '--FILEDESC', 'oauth_hook_client - test program for libpq OAuth hooks',])
+endif
+
+oauth_hook_client = executable('oauth_hook_client',
+  oauth_hook_client_sources,
+  dependencies: [frontend_code, libpq],
+  kwargs: default_bin_args + {
+    'install': false,
+  },
+)
+testprep_targets += oauth_hook_client
+
+tests += {
+  'name': 'oauth_validator',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'tap': {
+    'tests': [
+      't/001_server.pl',
+      't/002_client.pl',
+    ],
+    'env': {
+      'PYTHON': python.path(),
+      'with_libcurl': libcurl.found() ? 'yes' : 'no',
+      'with_python': 'yes',
+    },
+  },
+}
diff --git a/src/test/modules/oauth_validator/oauth_hook_client.c b/src/test/modules/oauth_validator/oauth_hook_client.c
new file mode 100644
index 0000000000..12fe70c990
--- /dev/null
+++ b/src/test/modules/oauth_validator/oauth_hook_client.c
@@ -0,0 +1,264 @@
+/*-------------------------------------------------------------------------
+ *
+ * oauth_hook_client.c
+ *		Test driver for t/002_client.pl, which verifies OAuth hook
+ *		functionality in libpq.
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ *
+ * IDENTIFICATION
+ *		src/test/modules/oauth_validator/oauth_hook_client.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres_fe.h"
+
+#include <sys/socket.h>
+
+#include "getopt_long.h"
+#include "libpq-fe.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(" --expected-scope SCOPE	fail if received scopes do not match SCOPE\n");
+	printf(" --expected-uri URI		fail if received configuration link does not match URI\n");
+	printf(" --misbehave=MODE		have the hook fail required postconditions\n"
+		   "						(MODEs: no-hook, fail-async, no-token, no-socket)\n");
+	printf(" --no-hook				don't install OAuth hooks (connection will fail)\n");
+	printf(" --hang-forever			don't ever return a token (combine with connect_timeout)\n");
+	printf(" --token TOKEN			use the provided TOKEN value\n");
+}
+
+/* --options */
+static bool no_hook = false;
+static bool hang_forever = false;
+static const char *expected_uri = NULL;
+static const char *expected_scope = NULL;
+static const char *misbehave_mode = NULL;
+static char *token = NULL;
+
+int
+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},
+		{0}
+	};
+
+	const char *conninfo;
+	PGconn	   *conn;
+	int			c;
+
+	while ((c = getopt_long(argc, argv, "h", long_options, NULL)) != -1)
+	{
+		switch (c)
+		{
+			case 'h':
+				usage(argv);
+				return 0;
+
+			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;
+
+			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);
+
+	/* Connect. (All the actual work is in the hook.) */
+	conn = PQconnectdb(conninfo);
+	if (PQstatus(conn) != CONNECTION_OK)
+	{
+		fprintf(stderr, "Connection to database failed: %s\n",
+				PQerrorMessage(conn));
+		PQfinish(conn);
+		return 1;
+	}
+
+	printf("connection succeeded\n");
+	PQfinish(conn);
+	return 0;
+}
+
+/*
+ * PQauthDataHook implementation. Replaces the default client flow by handling
+ * PQAUTHDATA_OAUTH_BEARER_TOKEN.
+ */
+static int
+handle_auth_data(PGauthData type, PGconn *conn, void *data)
+{
+	PGoauthBearerRequest *req = data;
+
+	if (no_hook || (type != PQAUTHDATA_OAUTH_BEARER_TOKEN))
+		return 0;
+
+	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;
+		}
+	}
+
+	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". */
+		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/001_server.pl b/src/test/modules/oauth_validator/t/001_server.pl
new file mode 100644
index 0000000000..80f5258589
--- /dev/null
+++ b/src/test/modules/oauth_validator/t/001_server.pl
@@ -0,0 +1,551 @@
+
+#
+# Tests the libpq builtin OAuth flow, as well as server-side HBA and validator
+# setup.
+#
+# Copyright (c) 2021-2024, PostgreSQL Global Development Group
+#
+
+use strict;
+use warnings FATAL => 'all';
+
+use JSON::PP     qw(encode_json);
+use MIME::Base64 qw(encode_base64);
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+use FindBin;
+use lib $FindBin::RealBin;
+
+use OAuth::Server;
+
+if (!$ENV{PG_TEST_EXTRA} || $ENV{PG_TEST_EXTRA} !~ /\boauth\b/)
+{
+	plan skip_all =>
+	  'Potentially unsafe test oauth not enabled in PG_TEST_EXTRA';
+}
+
+if ($ENV{with_libcurl} ne 'yes')
+{
+	plan skip_all => 'client-side OAuth not supported by this build';
+}
+
+if ($ENV{with_python} ne 'yes')
+{
+	plan skip_all => 'OAuth tests require --with-python to run';
+}
+
+my $node = PostgreSQL::Test::Cluster->new('primary');
+$node->init;
+$node->append_conf('postgresql.conf', "log_connections = on\n");
+$node->append_conf('postgresql.conf',
+	"oauth_validator_libraries = 'validator'\n");
+$node->start;
+
+$node->safe_psql('postgres', 'CREATE USER test;');
+$node->safe_psql('postgres', 'CREATE USER testalt;');
+$node->safe_psql('postgres', 'CREATE USER testparam;');
+
+# Save a background connection for later configuration changes.
+my $bgconn = $node->background_psql('postgres');
+
+my $webserver = OAuth::Server->new();
+$webserver->run();
+
+END
+{
+	my $exit_code = $?;
+
+	$webserver->stop() if defined $webserver;    # might have been SKIP'd
+
+	$? = $exit_code;
+}
+
+my $port = $webserver->port();
+my $issuer = "http://localhost:$port";
+
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test      oauth issuer="$issuer"       scope="openid postgres"
+local all testalt   oauth issuer="$issuer/.well-known/oauth-authorization-server/alternate" scope="openid postgres alt"
+local all testparam oauth issuer="$issuer/param" scope="openid postgres"
+});
+$node->reload;
+
+my $log_start = $node->wait_for_log(qr/reloading configuration files/);
+
+# Check pg_hba_file_rules() support.
+my $contents = $bgconn->query_safe(
+	qq(SELECT rule_number, auth_method, options
+		 FROM pg_hba_file_rules
+		 ORDER BY rule_number;));
+is( $contents,
+	qq{1|oauth|\{issuer=$issuer,"scope=openid postgres",validator=validator\}
+2|oauth|\{issuer=$issuer/.well-known/oauth-authorization-server/alternate,"scope=openid postgres alt",validator=validator\}
+3|oauth|\{issuer=$issuer/param,"scope=openid postgres",validator=validator\}},
+	"pg_hba_file_rules recreates OAuth HBA settings");
+
+# To test against HTTP rather than HTTPS, we need to enable PGOAUTHDEBUG. But
+# first, check to make sure the client refuses such connections by default.
+$node->connect_fails(
+	"user=test dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635",
+	"HTTPS is required without debug mode",
+	expected_stderr =>
+	  qr@OAuth discovery URI "\Q$issuer\E/.well-known/openid-configuration" must use HTTPS@
+);
+
+$ENV{PGOAUTHDEBUG} = "UNSAFE";
+
+my $user = "test";
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635",
+	"connect as test",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@,
+	log_like => [
+		qr/oauth_validator: token="9243959234", role="$user"/,
+		qr/oauth_validator: issuer="\Q$issuer\E", scope="openid postgres"/,
+		qr/connection authenticated: identity="test" method=oauth/,
+		qr/connection authorized/,
+	]);
+
+# The /alternate issuer uses slightly different parameters, along with an
+# OAuth-style discovery document.
+$user = "testalt";
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer/alternate oauth_client_id=f02c6361-0636",
+	"connect as testalt",
+	expected_stderr =>
+	  qr@Visit https://example\.org/ and enter the code: postgresuser@,
+	log_like => [
+		qr/oauth_validator: token="9243959234-alt", role="$user"/,
+		qr|oauth_validator: issuer="\Q$issuer/.well-known/oauth-authorization-server/alternate\E", scope="openid postgres alt"|,
+		qr/connection authenticated: identity="testalt" method=oauth/,
+		qr/connection authorized/,
+	]);
+
+# The issuer linked by the server must match the client's oauth_issuer setting.
+$node->connect_fails(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0636",
+	"oauth_issuer must match discovery",
+	expected_stderr =>
+	  qr@server's discovery document at \Q$issuer/.well-known/oauth-authorization-server/alternate\E \(issuer "\Q$issuer/alternate\E"\) is incompatible with oauth_issuer \(\Q$issuer\E\)@
+);
+
+# Test require_auth settings against OAUTHBEARER.
+my @cases = (
+	{ require_auth => "oauth" },
+	{ require_auth => "oauth,scram-sha-256" },
+	{ require_auth => "password,oauth" },
+	{ require_auth => "none,oauth" },
+	{ require_auth => "!scram-sha-256" },
+	{ require_auth => "!none" },
+
+	{
+		require_auth => "!oauth",
+		failure => qr/server requested OAUTHBEARER authentication/
+	},
+	{
+		require_auth => "scram-sha-256",
+		failure => qr/server requested OAUTHBEARER authentication/
+	},
+	{
+		require_auth => "!password,!oauth",
+		failure => qr/server requested OAUTHBEARER authentication/
+	},
+	{
+		require_auth => "none",
+		failure => qr/server requested SASL authentication/
+	},
+	{
+		require_auth => "!oauth,!scram-sha-256",
+		failure => qr/server requested SASL authentication/
+	});
+
+$user = "test";
+foreach my $c (@cases)
+{
+	my $connstr =
+	  "user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635 require_auth=$c->{'require_auth'}";
+
+	if (defined $c->{'failure'})
+	{
+		$node->connect_fails(
+			$connstr,
+			"require_auth=$c->{'require_auth'} fails",
+			expected_stderr => $c->{'failure'});
+	}
+	else
+	{
+		$node->connect_ok(
+			$connstr,
+			"require_auth=$c->{'require_auth'} succeeds",
+			expected_stderr =>
+			  qr@Visit https://example\.com/ and enter the code: postgresuser@
+		);
+	}
+}
+
+# Make sure the client_id and secret are correctly encoded. $vschars contains
+# every allowed character for a client_id/_secret (the "VSCHAR" class).
+# $vschars_esc is additionally backslash-escaped for inclusion in a
+# single-quoted connection string.
+my $vschars =
+  " !\"#\$%&'()*+,-./0123456789:;<=>?\@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~";
+my $vschars_esc =
+  " !\"#\$%&\\'()*+,-./0123456789:;<=>?\@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\\\]^_`abcdefghijklmnopqrstuvwxyz{|}~";
+
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id='$vschars_esc'",
+	"escapable characters: client_id",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id='$vschars_esc' oauth_client_secret='$vschars_esc'",
+	"escapable characters: client_id and secret",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+#
+# Further tests rely on support for specific behaviors in oauth_server.py. To
+# trigger these behaviors, we ask for the special issuer .../param (which is set
+# up in HBA for the testparam user) and encode magic instructions into the
+# oauth_client_id.
+#
+
+my $common_connstr =
+  "user=testparam dbname=postgres oauth_issuer=$issuer/param ";
+my $base_connstr = $common_connstr;
+
+sub connstr
+{
+	my (%params) = @_;
+
+	my $json = encode_json(\%params);
+	my $encoded = encode_base64($json, "");
+
+	return "$base_connstr oauth_client_id=$encoded";
+}
+
+# Make sure the param system works end-to-end first.
+$node->connect_ok(
+	connstr(),
+	"connect to /param",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$node->connect_ok(
+	connstr(stage => 'token', retries => 1),
+	"token retry",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(stage => 'token', retries => 2),
+	"token retry (twice)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(stage => 'all', retries => 1, interval => 2),
+	"token retry (two second interval)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(stage => 'all', retries => 1, interval => JSON::PP::null),
+	"token retry (default interval)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$node->connect_ok(
+	connstr(stage => 'all', content_type => 'application/json;charset=utf-8'),
+	"content type with charset",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(
+		stage => 'all',
+		content_type => "application/json \t;\t charset=utf-8"),
+	"content type with charset (whitespace)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(stage => 'device', uri_spelling => "verification_url"),
+	"alternative spelling of verification_uri",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$node->connect_fails(
+	connstr(stage => 'device', huge_response => JSON::PP::true),
+	"bad device authz response: overlarge JSON",
+	expected_stderr =>
+	  qr/failed to obtain device authorization: response is too large/);
+$node->connect_fails(
+	connstr(stage => 'token', huge_response => JSON::PP::true),
+	"bad token response: overlarge JSON",
+	expected_stderr =>
+	  qr/failed to obtain access token: response is too large/);
+
+$node->connect_fails(
+	connstr(stage => 'device', content_type => 'text/plain'),
+	"bad device authz response: wrong content type",
+	expected_stderr =>
+	  qr/failed to parse device authorization: unexpected content type/);
+$node->connect_fails(
+	connstr(stage => 'token', content_type => 'text/plain'),
+	"bad token response: wrong content type",
+	expected_stderr =>
+	  qr/failed to parse access token response: unexpected content type/);
+$node->connect_fails(
+	connstr(stage => 'token', content_type => 'application/jsonx'),
+	"bad token response: wrong content type (correct prefix)",
+	expected_stderr =>
+	  qr/failed to parse access token response: unexpected content type/);
+
+$node->connect_fails(
+	connstr(
+		stage => 'all',
+		interval => ~0,
+		retries => 1,
+		retry_code => "slow_down"),
+	"bad token response: server overflows the device authz interval",
+	expected_stderr =>
+	  qr/failed to obtain access token: slow_down interval overflow/);
+
+$node->connect_fails(
+	connstr(stage => 'token', error_code => "invalid_grant"),
+	"bad token response: invalid_grant, no description",
+	expected_stderr => qr/failed to obtain access token: \(invalid_grant\)/);
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_grant",
+		error_desc => "grant expired"),
+	"bad token response: expired grant",
+	expected_stderr =>
+	  qr/failed to obtain access token: grant expired \(invalid_grant\)/);
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_client",
+		error_status => 401),
+	"bad token response: client authentication failure, default description",
+	expected_stderr =>
+	  qr/failed to obtain access token: provider requires client authentication, and no oauth_client_secret is set \(invalid_client\)/
+);
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_client",
+		error_status => 401,
+		error_desc => "authn failure"),
+	"bad token response: client authentication failure, provided description",
+	expected_stderr =>
+	  qr/failed to obtain access token: authn failure \(invalid_client\)/);
+
+$node->connect_fails(
+	connstr(stage => 'token', token => ""),
+	"server rejects access: empty token",
+	expected_stderr => qr/bearer authentication failed/);
+$node->connect_fails(
+	connstr(stage => 'token', token => "****"),
+	"server rejects access: invalid token contents",
+	expected_stderr => qr/bearer authentication failed/);
+
+# Test behavior of the oauth_client_secret.
+$base_connstr = "$common_connstr oauth_client_secret=''";
+
+$node->connect_ok(
+	connstr(stage => 'all', expected_secret => ''),
+	"empty oauth_client_secret",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$base_connstr = "$common_connstr oauth_client_secret='$vschars_esc'";
+
+$node->connect_ok(
+	connstr(stage => 'all', expected_secret => $vschars),
+	"nonempty oauth_client_secret",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_client",
+		error_status => 401),
+	"bad token response: client authentication failure, default description with oauth_client_secret",
+	expected_stderr =>
+	  qr/failed to obtain access token: provider rejected the oauth_client_secret \(invalid_client\)/
+);
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_client",
+		error_status => 401,
+		error_desc => "mutual TLS required for client"),
+	"bad token response: client authentication failure, provided description with oauth_client_secret",
+	expected_stderr =>
+	  qr/failed to obtain access token: mutual TLS required for client \(invalid_client\)/
+);
+
+#
+# This section of tests reconfigures the validator module itself, rather than
+# the OAuth server.
+#
+
+# Searching the logs is easier if OAuth parameter discovery isn't cluttering
+# things up; hardcode the discovery URI. (Scope is hardcoded to empty to cover
+# that case as well.)
+$common_connstr =
+  "dbname=postgres oauth_issuer=$issuer/.well-known/openid-configuration oauth_scope='' oauth_client_id=f02c6361-0635";
+
+# Misbehaving validators must fail shut.
+$bgconn->query_safe("ALTER SYSTEM SET oauth_validator.authn_id TO ''");
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+$node->connect_fails(
+	"$common_connstr user=test",
+	"validator must set authn_id",
+	expected_stderr => qr/OAuth bearer authentication failed/,
+	log_like => [
+		qr/connection authenticated: identity=""/,
+		qr/DETAIL:\s+Validator provided no identity/,
+		qr/FATAL:\s+OAuth bearer authentication failed/,
+	]);
+
+# Even if a validator authenticates the user, if the token isn't considered
+# valid, the connection fails.
+$bgconn->query_safe(
+	"ALTER SYSTEM SET oauth_validator.authn_id TO 'test\@example.org'");
+$bgconn->query_safe(
+	"ALTER SYSTEM SET oauth_validator.authorize_tokens TO false");
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+$node->connect_fails(
+	"$common_connstr user=test",
+	"validator must authorize token explicitly",
+	expected_stderr => qr/OAuth bearer authentication failed/,
+	log_like => [
+		qr/connection authenticated: identity="test\@example\.org"/,
+		qr/DETAIL:\s+Validator failed to authorize the provided token/,
+		qr/FATAL:\s+OAuth bearer authentication failed/,
+	]);
+
+#
+# Test user mapping.
+#
+
+# Allow "[email protected]" to log in under the test role.
+unlink($node->data_dir . '/pg_ident.conf');
+$node->append_conf(
+	'pg_ident.conf', qq{
+oauthmap	user\@example.com	test
+});
+
+# test and testalt use the map; testparam uses ident delegation.
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test      oauth issuer="$issuer" scope="" map=oauthmap
+local all testalt   oauth issuer="$issuer" scope="" map=oauthmap
+local all testparam oauth issuer="$issuer" scope="" delegate_ident_mapping=1
+});
+
+# To start, have the validator use the role names as authn IDs.
+$bgconn->query_safe("ALTER SYSTEM RESET oauth_validator.authn_id");
+$bgconn->query_safe("ALTER SYSTEM RESET oauth_validator.authorize_tokens");
+
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+# The test and testalt roles should no longer map correctly.
+$node->connect_fails(
+	"$common_connstr user=test",
+	"mismatched username map (test)",
+	expected_stderr => qr/OAuth bearer authentication failed/);
+$node->connect_fails(
+	"$common_connstr user=testalt",
+	"mismatched username map (testalt)",
+	expected_stderr => qr/OAuth bearer authentication failed/);
+
+# Have the validator identify the end user as [email protected].
+$bgconn->query_safe(
+	"ALTER SYSTEM SET oauth_validator.authn_id TO 'user\@example.com'");
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+# Now the test role can be logged into. (testalt still can't be mapped.)
+$node->connect_ok(
+	"$common_connstr user=test",
+	"matched username map (test)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_fails(
+	"$common_connstr user=testalt",
+	"mismatched username map (testalt)",
+	expected_stderr => qr/OAuth bearer authentication failed/);
+
+# testparam ignores the map entirely.
+$node->connect_ok(
+	"$common_connstr user=testparam",
+	"delegated ident (testparam)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$bgconn->query_safe("ALTER SYSTEM RESET oauth_validator.authn_id");
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+#
+# Test multiple validators.
+#
+
+$node->append_conf('postgresql.conf',
+	"oauth_validator_libraries = 'validator, fail_validator'\n");
+
+# With multiple validators, every HBA line must explicitly declare one.
+my $result = $node->restart(fail_ok => 1);
+is($result, 0,
+	'restart fails without explicit validators in oauth HBA entries');
+
+$log_start = $node->wait_for_log(
+	qr/authentication method "oauth" requires argument "validator" to be set/,
+	$log_start);
+
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test    oauth validator=validator      issuer="$issuer"           scope="openid postgres"
+local all testalt oauth validator=fail_validator issuer="$issuer/.well-known/oauth-authorization-server/alternate" scope="openid postgres alt"
+});
+$node->restart;
+
+$log_start = $node->wait_for_log(qr/ready to accept connections/, $log_start);
+
+# The test user should work as before.
+$user = "test";
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635",
+	"validator is used for $user",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@,
+	log_like => [qr/connection authorized/]);
+
+# testalt should be routed through the fail_validator.
+$user = "testalt";
+$node->connect_fails(
+	"user=$user dbname=postgres oauth_issuer=$issuer/.well-known/oauth-authorization-server/alternate oauth_client_id=f02c6361-0636",
+	"fail_validator is used for $user",
+	expected_stderr => qr/FATAL:\s+fail_validator: sentinel error/);
+
+$node->stop;
+
+done_testing();
diff --git a/src/test/modules/oauth_validator/t/002_client.pl b/src/test/modules/oauth_validator/t/002_client.pl
new file mode 100644
index 0000000000..95cccf90dd
--- /dev/null
+++ b/src/test/modules/oauth_validator/t/002_client.pl
@@ -0,0 +1,154 @@
+#
+# Exercises the API for custom OAuth client flows, using the oauth_hook_client
+# test driver.
+#
+# Copyright (c) 2021-2024, PostgreSQL Global Development Group
+#
+
+use strict;
+use warnings FATAL => 'all';
+
+use JSON::PP     qw(encode_json);
+use MIME::Base64 qw(encode_base64);
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+if (!$ENV{PG_TEST_EXTRA} || $ENV{PG_TEST_EXTRA} !~ /\boauth\b/)
+{
+	plan skip_all =>
+	  'Potentially unsafe test oauth not enabled in PG_TEST_EXTRA';
+}
+
+#
+# Cluster Setup
+#
+
+my $node = PostgreSQL::Test::Cluster->new('primary');
+$node->init;
+$node->append_conf('postgresql.conf', "log_connections = on\n");
+$node->append_conf('postgresql.conf',
+	"oauth_validator_libraries = 'validator'\n");
+$node->start;
+
+$node->safe_psql('postgres', 'CREATE USER test;');
+
+# These tests don't use the builtin flow, and we don't have an authorization
+# server running, so the address used here shouldn't matter. Use an invalid IP
+# address, so if there's some cascade of errors that causes the client to
+# attempt a connection, we'll fail noisily.
+my $issuer = "https://256.256.256.256";
+my $scope = "openid postgres";
+
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test oauth issuer="$issuer" scope="$scope"
+});
+$node->reload;
+
+my ($log_start, $log_end);
+$log_start = $node->wait_for_log(qr/reloading configuration files/);
+
+$ENV{PGOAUTHDEBUG} = "UNSAFE";
+
+#
+# Tests
+#
+
+my $user = "test";
+my $base_connstr = $node->connstr() . " user=$user";
+my $common_connstr =
+  "$base_connstr oauth_issuer=$issuer oauth_client_id=myID";
+
+sub test
+{
+	my ($test_name, %params) = @_;
+
+	my $flags = [];
+	if (defined($params{flags}))
+	{
+		$flags = $params{flags};
+	}
+
+	my @cmd = ("oauth_hook_client", @{$flags}, $common_connstr);
+	note "running '" . join("' '", @cmd) . "'";
+
+	my ($stdout, $stderr) = run_command(\@cmd);
+
+	if (defined($params{expected_stdout}))
+	{
+		like($stdout, $params{expected_stdout}, "$test_name: stdout matches");
+	}
+
+	if (defined($params{expected_stderr}))
+	{
+		like($stderr, $params{expected_stderr}, "$test_name: stderr matches");
+	}
+	else
+	{
+		is($stderr, "", "$test_name: no stderr");
+	}
+}
+
+test(
+	"basic synchronous hook can provide a token",
+	flags => [
+		"--token", "my-token",
+		"--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"/, ]);
+
+if ($ENV{with_libcurl} ne 'yes')
+{
+	# libpq should help users out if no OAuth support is built in.
+	test(
+		"fails without custom hook installed",
+		flags => ["--no-hook"],
+		expected_stderr =>
+		  qr/no custom OAuth flows are available, and libpq was not built with libcurl support/
+	);
+}
+
+# connect_timeout should work if the flow doesn't respond.
+$common_connstr = "$common_connstr connect_timeout=1";
+test(
+	"connect_timeout interrupts hung client flow",
+	flags => ["--hang-forever"],
+	expected_stderr => qr/failed: timeout expired/);
+
+# Test various misbehaviors of the client hook.
+my @cases = (
+	{
+		flag => "--misbehave=no-hook",
+		expected_error =>
+		  qr/user-defined OAuth flow provided neither a token nor an async callback/,
+	},
+	{
+		flag => "--misbehave=fail-async",
+		expected_error => qr/user-defined OAuth flow failed/,
+	},
+	{
+		flag => "--misbehave=no-token",
+		expected_error => qr/user-defined OAuth flow did not provide a token/,
+	},
+	{
+		flag => "--misbehave=no-socket",
+		expected_error =>
+		  qr/user-defined OAuth flow did not provide a socket for polling/,
+	});
+
+foreach my $c (@cases)
+{
+	test(
+		"hook misbehavior: $c->{'flag'}",
+		flags => [ $c->{'flag'} ],
+		expected_stderr => $c->{'expected_error'});
+}
+
+done_testing();
diff --git a/src/test/modules/oauth_validator/t/OAuth/Server.pm b/src/test/modules/oauth_validator/t/OAuth/Server.pm
new file mode 100644
index 0000000000..f0f23d1d1a
--- /dev/null
+++ b/src/test/modules/oauth_validator/t/OAuth/Server.pm
@@ -0,0 +1,140 @@
+
+# Copyright (c) 2024, PostgreSQL Global Development Group
+
+=pod
+
+=head1 NAME
+
+OAuth::Server - runs a mock OAuth authorization server for testing
+
+=head1 SYNOPSIS
+
+  use OAuth::Server;
+
+  my $server = OAuth::Server->new();
+  $server->run;
+
+  my $port = $server->port;
+  my $issuer = "http://localhost:$port";
+
+  # test against $issuer...
+
+  $server->stop;
+
+=head1 DESCRIPTION
+
+This is glue API between the Perl tests and the Python authorization server
+daemon implemented in t/oauth_server.py. (Python has a fairly usable HTTP server
+in its standard library, so the implementation was ported from Perl.)
+
+This authorization server does not use TLS (it implements a nonstandard, unsafe
+issuer at "http://localhost:<port>"), so libpq in particular will need to set
+PGOAUTHDEBUG=UNSAFE to be able to talk to it.
+
+=cut
+
+package OAuth::Server;
+
+use warnings;
+use strict;
+use Scalar::Util;
+use Test::More;
+
+=pod
+
+=head1 METHODS
+
+=over
+
+=item SSL::Server->new()
+
+Create a new OAuth Server object.
+
+=cut
+
+sub new
+{
+	my $class = shift;
+
+	my $self = {};
+	bless($self, $class);
+
+	return $self;
+}
+
+=pod
+
+=item $server->port()
+
+Returns the port in use by the server.
+
+=cut
+
+sub port
+{
+	my $self = shift;
+
+	return $self->{'port'};
+}
+
+=pod
+
+=item $server->run()
+
+Runs the authorization server daemon in t/oauth_server.py.
+
+=cut
+
+sub run
+{
+	my $self = shift;
+	my $port;
+
+	my $pid = open(my $read_fh, "-|", $ENV{PYTHON}, "t/oauth_server.py")
+	  or die "failed to start OAuth server: $!";
+
+	# Get the port number from the daemon. It closes stdout afterwards; that way
+	# we can slurp in the entire contents here rather than worrying about the
+	# number of bytes to read.
+	$port = do { local $/ = undef; <$read_fh> }
+	  // die "failed to read port number: $!";
+	chomp $port;
+	die "server did not advertise a valid port"
+	  unless Scalar::Util::looks_like_number($port);
+
+	$self->{'pid'} = $pid;
+	$self->{'port'} = $port;
+	$self->{'child'} = $read_fh;
+
+	note("OAuth provider (PID $pid) is listening on port $port\n");
+}
+
+=pod
+
+=item $server->stop()
+
+Sends SIGTERM to the authorization server and waits for it to exit.
+
+=cut
+
+sub stop
+{
+	my $self = shift;
+
+	note("Sending SIGTERM to OAuth provider PID: $self->{'pid'}\n");
+
+	kill(15, $self->{'pid'});
+	$self->{'pid'} = undef;
+
+	# Closing the popen() handle waits for the process to exit.
+	close($self->{'child'});
+	$self->{'child'} = undef;
+}
+
+=pod
+
+=back
+
+=cut
+
+1;
diff --git a/src/test/modules/oauth_validator/t/oauth_server.py b/src/test/modules/oauth_validator/t/oauth_server.py
new file mode 100755
index 0000000000..8ec0910202
--- /dev/null
+++ b/src/test/modules/oauth_validator/t/oauth_server.py
@@ -0,0 +1,391 @@
+#! /usr/bin/env python3
+#
+# A mock OAuth authorization server, designed to be invoked from
+# OAuth/Server.pm. This listens on an ephemeral port number (printed to stdout
+# so that the Perl tests can contact it) and runs as a daemon until it is
+# signaled.
+#
+
+import base64
+import http.server
+import json
+import os
+import sys
+import time
+import urllib.parse
+from collections import defaultdict
+
+
+class OAuthHandler(http.server.BaseHTTPRequestHandler):
+    """
+    Core implementation of the authorization server. The API is
+    inheritance-based, with entry points at do_GET() and do_POST(). See the
+    documentation for BaseHTTPRequestHandler.
+    """
+
+    JsonObject = dict[str, object]  # TypeAlias is not available until 3.10
+
+    def _check_issuer(self):
+        """
+        Switches the behavior of the provider depending on the issuer URI.
+        """
+        self._alt_issuer = (
+            self.path.startswith("/alternate/")
+            or self.path == "/.well-known/oauth-authorization-server/alternate"
+        )
+        self._parameterized = self.path.startswith("/param/")
+
+        if self._alt_issuer:
+            # The /alternate issuer uses IETF-style .well-known URIs.
+            if self.path.startswith("/.well-known/"):
+                self.path = self.path.removesuffix("/alternate")
+            else:
+                self.path = self.path.removeprefix("/alternate")
+        elif self._parameterized:
+            self.path = self.path.removeprefix("/param")
+
+    def _check_authn(self):
+        """
+        Checks the expected value of the Authorization header, if any.
+        """
+        secret = self._get_param("expected_secret", None)
+        if secret is None:
+            return
+
+        assert "Authorization" in self.headers
+        method, creds = self.headers["Authorization"].split()
+
+        if method != "Basic":
+            raise RuntimeError(f"client used {method} auth; expected Basic")
+
+        username = urllib.parse.quote_plus(self.client_id)
+        password = urllib.parse.quote_plus(secret)
+        expected_creds = f"{username}:{password}"
+
+        if creds.encode() != base64.b64encode(expected_creds.encode()):
+            raise RuntimeError(
+                f"client sent '{creds}'; expected b64encode('{expected_creds}')"
+            )
+
+    def do_GET(self):
+        self._response_code = 200
+        self._check_issuer()
+
+        config_path = "/.well-known/openid-configuration"
+        if self._alt_issuer:
+            config_path = "/.well-known/oauth-authorization-server"
+
+        if self.path == config_path:
+            resp = self.config()
+        else:
+            self.send_error(404, "Not Found")
+            return
+
+        self._send_json(resp)
+
+    def _parse_params(self) -> dict[str, str]:
+        """
+        Parses apart the form-urlencoded request body and returns the resulting
+        dict. For use by do_POST().
+        """
+        size = int(self.headers["Content-Length"])
+        form = self.rfile.read(size)
+
+        assert self.headers["Content-Type"] == "application/x-www-form-urlencoded"
+        return urllib.parse.parse_qs(
+            form.decode("utf-8"),
+            strict_parsing=True,
+            keep_blank_values=True,
+            encoding="utf-8",
+            errors="strict",
+        )
+
+    @property
+    def client_id(self) -> str:
+        """
+        Returns the client_id sent in the POST body or the Authorization header.
+        self._parse_params() must have been called first.
+        """
+        if "client_id" in self._params:
+            return self._params["client_id"][0]
+
+        if "Authorization" not in self.headers:
+            raise RuntimeError("client did not send any client_id")
+
+        _, creds = self.headers["Authorization"].split()
+
+        decoded = base64.b64decode(creds).decode("utf-8")
+        username, _ = decoded.split(":", 1)
+
+        return urllib.parse.unquote_plus(username)
+
+    def do_POST(self):
+        self._response_code = 200
+        self._check_issuer()
+
+        self._params = self._parse_params()
+        if self._parameterized:
+            # Pull encoded test parameters out of the peer's client_id field.
+            # This is expected to be Base64-encoded JSON.
+            js = base64.b64decode(self.client_id)
+            self._test_params = json.loads(js)
+
+        self._check_authn()
+
+        if self.path == "/authorize":
+            resp = self.authorization()
+        elif self.path == "/token":
+            resp = self.token()
+        else:
+            self.send_error(404)
+            return
+
+        self._send_json(resp)
+
+    def _should_modify(self) -> bool:
+        """
+        Returns True if the client has requested a modification to this stage of
+        the exchange.
+        """
+        if not hasattr(self, "_test_params"):
+            return False
+
+        stage = self._test_params.get("stage")
+
+        return (
+            stage == "all"
+            or (
+                stage == "discovery"
+                and self.path == "/.well-known/openid-configuration"
+            )
+            or (stage == "device" and self.path == "/authorize")
+            or (stage == "token" and self.path == "/token")
+        )
+
+    def _get_param(self, name, default):
+        """
+        If the client has requested a modification to this stage (see
+        _should_modify()), this method searches the provided test parameters for
+        a key of the given name, and returns it if found. Otherwise the provided
+        default is returned.
+        """
+        if self._should_modify() and name in self._test_params:
+            return self._test_params[name]
+
+        return default
+
+    @property
+    def _content_type(self) -> str:
+        """
+        Returns "application/json" unless the test has requested something
+        different.
+        """
+        return self._get_param("content_type", "application/json")
+
+    @property
+    def _interval(self) -> int:
+        """
+        Returns 0 unless the test has requested something different.
+        """
+        return self._get_param("interval", 0)
+
+    @property
+    def _retry_code(self) -> str:
+        """
+        Returns "authorization_pending" unless the test has requested something
+        different.
+        """
+        return self._get_param("retry_code", "authorization_pending")
+
+    @property
+    def _uri_spelling(self) -> str:
+        """
+        Returns "verification_uri" unless the test has requested something
+        different.
+        """
+        return self._get_param("uri_spelling", "verification_uri")
+
+    @property
+    def _response_padding(self):
+        """
+        If the huge_response test parameter is set to True, returns a dict
+        containing a gigantic string value, which can then be folded into a JSON
+        response.
+        """
+        if not self._get_param("huge_response", False):
+            return dict()
+
+        return {"_pad_": "x" * 1024 * 1024}
+
+    @property
+    def _access_token(self):
+        """
+        The actual Bearer token sent back to the client on success. Tests may
+        override this with the "token" test parameter.
+        """
+        token = self._get_param("token", None)
+        if token is not None:
+            return token
+
+        token = "9243959234"
+        if self._alt_issuer:
+            token += "-alt"
+
+        return token
+
+    def _send_json(self, js: JsonObject) -> None:
+        """
+        Sends the provided JSON dict as an application/json response.
+        self._response_code can be modified to send JSON error responses.
+        """
+        resp = json.dumps(js).encode("ascii")
+        self.log_message("sending JSON response: %s", resp)
+
+        self.send_response(self._response_code)
+        self.send_header("Content-Type", self._content_type)
+        self.send_header("Content-Length", str(len(resp)))
+        self.end_headers()
+
+        self.wfile.write(resp)
+
+    def config(self) -> JsonObject:
+        port = self.server.socket.getsockname()[1]
+
+        issuer = f"http://localhost:{port}"
+        if self._alt_issuer:
+            issuer += "/alternate"
+        elif self._parameterized:
+            issuer += "/param"
+
+        return {
+            "issuer": issuer,
+            "token_endpoint": issuer + "/token",
+            "device_authorization_endpoint": issuer + "/authorize",
+            "response_types_supported": ["token"],
+            "subject_types_supported": ["public"],
+            "id_token_signing_alg_values_supported": ["RS256"],
+            "grant_types_supported": [
+                "authorization_code",
+                "urn:ietf:params:oauth:grant-type:device_code",
+            ],
+        }
+
+    @property
+    def _token_state(self):
+        """
+        A cached _TokenState object for the connected client (as determined by
+        the request's client_id), or a new one if it doesn't already exist.
+
+        This relies on the existence of a defaultdict attached to the server;
+        see main() below.
+        """
+        return self.server.token_state[self.client_id]
+
+    def _remove_token_state(self):
+        """
+        Removes any cached _TokenState for the current client_id. Call this
+        after the token exchange ends to get rid of unnecessary state.
+        """
+        if self.client_id in self.server.token_state:
+            del self.server.token_state[self.client_id]
+
+    def authorization(self) -> JsonObject:
+        uri = "https://example.com/"
+        if self._alt_issuer:
+            uri = "https://example.org/"
+
+        resp = {
+            "device_code": "postgres",
+            "user_code": "postgresuser",
+            self._uri_spelling: uri,
+            "expires-in": 5,
+            **self._response_padding,
+        }
+
+        interval = self._interval
+        if interval is not None:
+            resp["interval"] = interval
+            self._token_state.min_delay = interval
+        else:
+            self._token_state.min_delay = 5  # default
+
+        # Check the scope.
+        if "scope" in self._params:
+            assert self._params["scope"][0], "empty scopes should be omitted"
+
+        return resp
+
+    def token(self) -> JsonObject:
+        if err := self._get_param("error_code", None):
+            self._response_code = self._get_param("error_status", 400)
+
+            resp = {"error": err}
+            if desc := self._get_param("error_desc", ""):
+                resp["error_description"] = desc
+
+            return resp
+
+        if self._should_modify() and "retries" in self._test_params:
+            retries = self._test_params["retries"]
+
+            # Check to make sure the token interval is being respected.
+            now = time.monotonic()
+            if self._token_state.last_try is not None:
+                delay = now - self._token_state.last_try
+                assert (
+                    delay > self._token_state.min_delay
+                ), f"client waited only {delay} seconds between token requests (expected {self._token_state.min_delay})"
+
+            self._token_state.last_try = now
+
+            # If we haven't reached the required number of retries yet, return a
+            # "pending" response.
+            if self._token_state.retries < retries:
+                self._token_state.retries += 1
+
+                self._response_code = 400
+                return {"error": self._retry_code}
+
+        # Clean up any retry tracking state now that the exchange is ending.
+        self._remove_token_state()
+
+        return {
+            "access_token": self._access_token,
+            "token_type": "bearer",
+            **self._response_padding,
+        }
+
+
+def main():
+    """
+    Starts the authorization server on localhost. The ephemeral port in use will
+    be printed to stdout.
+    """
+
+    s = http.server.HTTPServer(("127.0.0.1", 0), OAuthHandler)
+
+    # Attach a "cache" dictionary to the server to allow the OAuthHandlers to
+    # track state across token requests. The use of defaultdict ensures that new
+    # entries will be created automatically.
+    class _TokenState:
+        retries = 0
+        min_delay = None
+        last_try = None
+
+    s.token_state = defaultdict(_TokenState)
+
+    # Give the parent the port number to contact (this is also the signal that
+    # we're ready to receive requests).
+    port = s.socket.getsockname()[1]
+    print(port)
+
+    # stdout is closed to allow the parent to just "read to the end".
+    stdout = sys.stdout.fileno()
+    sys.stdout.close()
+    os.close(stdout)
+
+    s.serve_forever()  # we expect our parent to send a termination signal
+
+
+if __name__ == "__main__":
+    main()
diff --git a/src/test/modules/oauth_validator/validator.c b/src/test/modules/oauth_validator/validator.c
new file mode 100644
index 0000000000..bf94f091de
--- /dev/null
+++ b/src/test/modules/oauth_validator/validator.c
@@ -0,0 +1,135 @@
+/*-------------------------------------------------------------------------
+ *
+ * validator.c
+ *	  Test module for serverside OAuth token validation callbacks
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/test/modules/oauth_validator/validator.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "fmgr.h"
+#include "libpq/oauth.h"
+#include "miscadmin.h"
+#include "utils/guc.h"
+#include "utils/memutils.h"
+
+PG_MODULE_MAGIC;
+
+static void validator_startup(ValidatorModuleState *state);
+static void validator_shutdown(ValidatorModuleState *state);
+static ValidatorModuleResult *validate_token(ValidatorModuleState *state,
+											 const char *token,
+											 const char *role);
+
+/* Callback implementations (exercise all three) */
+static const OAuthValidatorCallbacks validator_callbacks = {
+	.startup_cb = validator_startup,
+	.shutdown_cb = validator_shutdown,
+	.validate_cb = validate_token
+};
+
+/* GUCs */
+static char *authn_id = NULL;
+static bool authorize_tokens = true;
+
+/*---
+ * Extension entry point. Sets up GUCs for use by tests:
+ *
+ * - oauth_validator.authn_id	Sets the user identifier to return during token
+ *								validation. Defaults to the username in the
+ *								startup packet.
+ *
+ * - oauth_validator.authorize_tokens
+ *								Sets whether to successfully validate incoming
+ *								tokens. Defaults to true.
+ */
+void
+_PG_init(void)
+{
+	DefineCustomStringVariable("oauth_validator.authn_id",
+							   "Authenticated identity to use for future connections",
+							   NULL,
+							   &authn_id,
+							   NULL,
+							   PGC_SIGHUP,
+							   0,
+							   NULL, NULL, NULL);
+	DefineCustomBoolVariable("oauth_validator.authorize_tokens",
+							 "Should tokens be marked valid?",
+							 NULL,
+							 &authorize_tokens,
+							 true,
+							 PGC_SIGHUP,
+							 0,
+							 NULL, NULL, NULL);
+
+	MarkGUCPrefixReserved("oauth_validator");
+}
+
+/*
+ * Validator module entry point.
+ */
+const OAuthValidatorCallbacks *
+_PG_oauth_validator_module_init(void)
+{
+	return &validator_callbacks;
+}
+
+#define PRIVATE_COOKIE ((void *) 13579)
+
+/*
+ * Startup callback, to set up private data for the validator.
+ */
+static void
+validator_startup(ValidatorModuleState *state)
+{
+	state->private_data = PRIVATE_COOKIE;
+}
+
+/*
+ * Shutdown callback, to tear down the validator.
+ */
+static void
+validator_shutdown(ValidatorModuleState *state)
+{
+	/* Check to make sure our private state still exists. */
+	if (state->private_data != PRIVATE_COOKIE)
+		elog(ERROR, "oauth_validator: private state cookie changed to %p in shutdown",
+			 state->private_data);
+}
+
+/*
+ * Validator implementation. Logs the incoming data and authorizes the token by
+ * default; the behavior can be modified via the module's GUC settings.
+ */
+static ValidatorModuleResult *
+validate_token(ValidatorModuleState *state, const char *token, const char *role)
+{
+	ValidatorModuleResult *res;
+
+	/* Check to make sure our private state still exists. */
+	if (state->private_data != PRIVATE_COOKIE)
+		elog(ERROR, "oauth_validator: private state cookie changed to %p in validate",
+			 state->private_data);
+
+	res = palloc(sizeof(ValidatorModuleResult));
+
+	elog(LOG, "oauth_validator: token=\"%s\", role=\"%s\"", token, role);
+	elog(LOG, "oauth_validator: issuer=\"%s\", scope=\"%s\"",
+		 MyProcPort->hba->oauth_issuer,
+		 MyProcPort->hba->oauth_scope);
+
+	res->authorized = authorize_tokens;
+	if (authn_id)
+		res->authn_id = pstrdup(authn_id);
+	else
+		res->authn_id = pstrdup(role);
+
+	return res;
+}
diff --git a/src/test/perl/PostgreSQL/Test/Cluster.pm b/src/test/perl/PostgreSQL/Test/Cluster.pm
index f521ad0b12..ab7d7452ed 100644
--- a/src/test/perl/PostgreSQL/Test/Cluster.pm
+++ b/src/test/perl/PostgreSQL/Test/Cluster.pm
@@ -2515,6 +2515,11 @@ instead of the default.
 
 If this regular expression is set, matches it with the output generated.
 
+=item expected_stderr => B<value>
+
+If this regular expression is set, matches it against the standard error
+stream; otherwise the stderr must be empty.
+
 =item log_like => [ qr/required message/ ]
 
 =item log_unlike => [ qr/prohibited message/ ]
@@ -2558,7 +2563,20 @@ sub connect_ok
 		like($stdout, $params{expected_stdout}, "$test_name: stdout matches");
 	}
 
-	is($stderr, "", "$test_name: no stderr");
+	if (defined($params{expected_stderr}))
+	{
+		if (like($stderr, $params{expected_stderr}, "$test_name: stderr matches")
+			&& ($ret != 0))
+		{
+			# In this case (failing test but matching stderr) we'll have
+			# swallowed the output needed to debug. Put it back into the logs.
+			diag("$test_name: full stderr:\n" . $stderr);
+		}
+	}
+	else
+	{
+		is($stderr, "", "$test_name: no stderr");
+	}
 
 	$self->log_check($test_name, $log_location, %params);
 }
diff --git a/src/tools/pgindent/pgindent b/src/tools/pgindent/pgindent
index d8acce7e92..7dccf4614a 100755
--- a/src/tools/pgindent/pgindent
+++ b/src/tools/pgindent/pgindent
@@ -242,6 +242,14 @@ sub pre_indent
 	# Protect wrapping in CATALOG()
 	$source =~ s!^(CATALOG\(.*)$!/*$1*/!gm;
 
+	# Treat a CURL_IGNORE_DEPRECATION() as braces for the purposes of
+	# indentation. (The recursive regex comes from the perlre documentation; it
+	# matches balanced parentheses as group $1 and the contents as group $2.)
+	my $curlopen = '{ /* CURL_IGNORE_DEPRECATION */';
+	my $curlclose = '} /* CURL_IGNORE_DEPRECATION */';
+	$source =~
+	  s!^[ \t]+CURL_IGNORE_DEPRECATION(\(((?:(?>[^()]+)|(?1))*)\))!$curlopen$2$curlclose!gms;
+
 	return $source;
 }
 
@@ -256,6 +264,12 @@ sub post_indent
 	$source =~ s!^/\* Open extern "C" \*/$!{!gm;
 	$source =~ s!^/\* Close extern "C" \*/$!}!gm;
 
+	# Restore the CURL_IGNORE_DEPRECATION() macro, keeping in mind that our
+	# markers may have been re-indented.
+	$source =~
+	  s!{[ \t]+/\* CURL_IGNORE_DEPRECATION \*/!CURL_IGNORE_DEPRECATION(!gm;
+	$source =~ s!}[ \t]+/\* CURL_IGNORE_DEPRECATION \*/!)!gm;
+
 	## Comments
 
 	# Undo change of dash-protected block comments
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 9a3bee93de..f3e3592eb7 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -371,6 +371,9 @@ CState
 CTECycleClause
 CTEMaterialize
 CTESearchClause
+CURL
+CURLM
+CURLoption
 CV
 CachedExpression
 CachedPlan
@@ -1724,6 +1727,7 @@ NumericDigit
 NumericSortSupport
 NumericSumAccum
 NumericVar
+OAuthValidatorCallbacks
 OM_uint32
 OP
 OSAPerGroupState
@@ -1832,6 +1836,7 @@ PGVerbosity
 PG_Locale_Strategy
 PG_Lock_Status
 PG_init_t
+PGauthData
 PGcancel
 PGcancelConn
 PGcmdQueueEntry
@@ -1839,7 +1844,9 @@ PGconn
 PGdataValue
 PGlobjfuncs
 PGnotify
+PGoauthBearerRequest
 PGpipelineStatus
+PGpromptOAuthDevice
 PGresAttDesc
 PGresAttValue
 PGresParamDesc
@@ -1951,6 +1958,7 @@ PQArgBlock
 PQEnvironmentOption
 PQExpBuffer
 PQExpBufferData
+PQauthDataHook_type
 PQcommMethods
 PQconninfoOption
 PQnoticeProcessor
@@ -3089,6 +3097,8 @@ VacuumRelation
 VacuumStmt
 ValidIOData
 ValidateIndexState
+ValidatorModuleState
+ValidatorModuleResult
 ValuesScan
 ValuesScanState
 Var
@@ -3485,6 +3495,7 @@ explain_get_index_name_hook_type
 f_smgr
 fasthash_state
 fd_set
+fe_oauth_state
 fe_scram_state
 fe_scram_state_enum
 fetch_range_request
-- 
2.39.3 (Apple Git-146)



^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-02-07 05:48  Jacob Champion <[email protected]>
  parent: Daniel Gustafsson <[email protected]>
  0 siblings, 1 reply; 101+ messages in thread

From: Jacob Champion @ 2025-02-07 05:48 UTC (permalink / raw)
  To: Daniel Gustafsson <[email protected]>; +Cc: Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

On Thu, Feb 6, 2025 at 2:02 PM Daniel Gustafsson <[email protected]> wrote:
> Attached is a v46 which is v45 minus the now committed patch.

Thank you! Attached is v47, which creeps ever closer to the finish line.

For ease of review, v47-0001 is identical to v46-0001. The new changes
are split into separate fixup! commits which I'll squash in the next
round. They're ordered roughly in order of increasing complexity:

- 0002 removes and/or rewrites TODO comments that I do not plan to implement.
- 0003 makes the kqueue implementation register a one-shot timer
rather than a repeating timer, to match the epoll implementation.

- 0004 fixes a bug in backend cleanup:

I noticed that there was a "private state cookie changed" error in
some of the test logs, but none of the tests had actually failed.
Changing that to a PANIC revealed that before_shmem_exit() is too late
to run the cleanup function, since the state allocation has already
been released. I've swapped that out for a reset callback.

- 0005 warns at configure time if libcurl doesn't have a nonblocking
DNS implementation.
- 0006 augments bare Asserts during client-side JSON parsing with code
that will fail gracefully in production builds as well.
- 0007 escapes binary data during the printing of libcurl debug
output. (If you're having a bad enough day to need the debug spray,
you're probably not in the mood for the sound of a hundred BELs.)
- 0008 parses and passes through the expires_in and optional
verification_uri_complete fields from the device endpoint to any
custom user prompt. (We do not use them ourselves, at the moment. But
after seeing some nice demos of RHEL/PAM/sssd support for device flow
QR codes at FOSDEM, I think we're definitely going to want to make
those available to devs.)

- 0009 is gold-plating for the OAUTH_STEP_WAIT_INTERVAL state:

If PQconnectPoll client calls us early while we're waiting for the
ping interval to expire, we will immediately send the next request
even if we should be waiting. That bothers me a bit, because if our
implementation gets a tempban from an OAuth provider because one of
our clients accidentally implemented a busy-loop, I think we're likely
to get the blame. Ideally we should kick back up to the caller and
tell them to wait longer, instead.

Checking to see if the timer has expired is easy enough for
epoll/timerfd, but I wasn't able to find an easy way to do that with a
single kqueue. Instead, I split the kqueue in two and treat the second
one as the timer. (If it becomes readable, the timer has expired.)
There is an additional advantage in that I get to remove some `#ifdef
HAVE_SYS_EPOLL_H` sections; the two implementations are closer in
spirit now.

Thanks,
--Jacob

 1:  ac5b3e053a3 =  1:  6747b7cc795 Add OAUTHBEARER SASL mechanism
 -:  ----------- >  2:  483129c1ca9 fixup! Add OAUTHBEARER SASL mechanism
 -:  ----------- >  3:  75d98784ded fixup! Add OAUTHBEARER SASL mechanism
 -:  ----------- >  4:  fd60ceb4c84 fixup! Add OAUTHBEARER SASL mechanism
 -:  ----------- >  5:  595362ef2c1 fixup! Add OAUTHBEARER SASL mechanism
 -:  ----------- >  6:  f73c042adc9 fixup! Add OAUTHBEARER SASL mechanism
 -:  ----------- >  7:  298839b69f0 fixup! Add OAUTHBEARER SASL mechanism
 -:  ----------- >  8:  1cf48a8f835 fixup! Add OAUTHBEARER SASL mechanism
 -:  ----------- >  9:  27135876559 fixup! Add OAUTHBEARER SASL mechanism
 2:  4190ef1bac7 = 10:  d8c1f298080 XXX fix libcurl link error
 3:  11cf045bd21 ! 11:  dbf305d0489 DO NOT MERGE: Add pytest suite for OAuth
    @@ src/test/python/client/test_oauth.py (new)
     +    _fields_ = [
     +        ("verification_uri", ctypes.c_char_p),
     +        ("user_code", ctypes.c_char_p),
    ++        ("verification_uri_complete", ctypes.c_char_p),
    ++        ("expires_in", ctypes.c_int),
     +    ]
     +
     +
    @@ src/test/python/client/test_oauth.py (new)
     +        assert call.type == PQAUTHDATA_PROMPT_OAUTH_DEVICE
     +        assert call.verification_uri.decode() == verification_url
     +        assert call.user_code.decode() == user_code
    ++        assert call.verification_uri_complete is None
    ++        assert call.expires_in == 5
     +
     +    if not success:
     +        # The client should not try to connect again.


Attachments:

  [text/plain] since-v46.diff.txt (1.5K, ../../CAOYmi+nHG7oy+ybHH72WjiXAQG3tE6v_at-K9ebRy2oqo92V+A@mail.gmail.com/2-since-v46.diff.txt)
  download | inline:
 1:  ac5b3e053a3 =  1:  6747b7cc795 Add OAUTHBEARER SASL mechanism
 -:  ----------- >  2:  483129c1ca9 fixup! Add OAUTHBEARER SASL mechanism
 -:  ----------- >  3:  75d98784ded fixup! Add OAUTHBEARER SASL mechanism
 -:  ----------- >  4:  fd60ceb4c84 fixup! Add OAUTHBEARER SASL mechanism
 -:  ----------- >  5:  595362ef2c1 fixup! Add OAUTHBEARER SASL mechanism
 -:  ----------- >  6:  f73c042adc9 fixup! Add OAUTHBEARER SASL mechanism
 -:  ----------- >  7:  298839b69f0 fixup! Add OAUTHBEARER SASL mechanism
 -:  ----------- >  8:  1cf48a8f835 fixup! Add OAUTHBEARER SASL mechanism
 -:  ----------- >  9:  27135876559 fixup! Add OAUTHBEARER SASL mechanism
 2:  4190ef1bac7 = 10:  d8c1f298080 XXX fix libcurl link error
 3:  11cf045bd21 ! 11:  dbf305d0489 DO NOT MERGE: Add pytest suite for OAuth
    @@ src/test/python/client/test_oauth.py (new)
     +    _fields_ = [
     +        ("verification_uri", ctypes.c_char_p),
     +        ("user_code", ctypes.c_char_p),
    ++        ("verification_uri_complete", ctypes.c_char_p),
    ++        ("expires_in", ctypes.c_int),
     +    ]
     +
     +
    @@ src/test/python/client/test_oauth.py (new)
     +        assert call.type == PQAUTHDATA_PROMPT_OAUTH_DEVICE
     +        assert call.verification_uri.decode() == verification_url
     +        assert call.user_code.decode() == user_code
    ++        assert call.verification_uri_complete is None
    ++        assert call.expires_in == 5
     +
     +    if not success:
     +        # The client should not try to connect again.

  [application/octet-stream] v47-0001-Add-OAUTHBEARER-SASL-mechanism.patch (299.4K, ../../CAOYmi+nHG7oy+ybHH72WjiXAQG3tE6v_at-K9ebRy2oqo92V+A@mail.gmail.com/3-v47-0001-Add-OAUTHBEARER-SASL-mechanism.patch)
  download | inline diff:
From 6747b7cc795aab4c8a227ee123349106c5d2ec0a Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Wed, 23 Oct 2024 09:37:33 -0700
Subject: [PATCH v47 01/11] Add OAUTHBEARER SASL mechanism

DO NOT USE THIS PROOF OF CONCEPT IN PRODUCTION.

Implement OAUTHBEARER (RFC 7628) and OAuth 2.0 Device Authorization
Grants (RFC 8628). This adds a new auth method, oauth, to pg_hba. When
speaking to a OAuth-enabled server, it looks a bit like this:

    $ psql 'host=example.org oauth_issuer=... oauth_client_id=...'
    Visit https://oauth.example.org/login and enter the code: FPQ2-M4BG

The OAuth issuer must support device authorization. No other OAuth flows
are currently implemented (but clients may provide their own flows).

The client implementation requires libcurl and its development headers.
Pass --with-libcurl/-Dlibcurl=enabled during configuration. The server
implementation does not require additional build-time dependencies, but
an external validator module must be supplied.

Thomas Munro wrote the kqueue() implementation for oauth-curl; thanks!

Several TODOs:
- perform several sanity checks on the OAuth issuer's responses
- improve error debuggability during the OAuth handshake
- fix libcurl initialization thread-safety
- harden the libcurl flow implementation
- fill in documentation stubs
- support protocol "variants" implemented by major providers
- implement more helpful handling of HBA misconfigurations
- use logdetail during auth failures
- ...and more.

Co-authored-by: Daniel Gustafsson <[email protected]>
---
 .cirrus.tasks.yml                             |   15 +-
 config/programs.m4                            |   42 +
 configure                                     |  279 ++
 configure.ac                                  |   41 +
 doc/src/sgml/client-auth.sgml                 |  252 ++
 doc/src/sgml/config.sgml                      |   26 +
 doc/src/sgml/filelist.sgml                    |    1 +
 doc/src/sgml/installation.sgml                |   27 +
 doc/src/sgml/libpq.sgml                       |  393 +++
 doc/src/sgml/oauth-validators.sgml            |  402 +++
 doc/src/sgml/postgres.sgml                    |    1 +
 doc/src/sgml/protocol.sgml                    |  133 +-
 doc/src/sgml/regress.sgml                     |   10 +
 meson.build                                   |   66 +
 meson_options.txt                             |    3 +
 src/Makefile.global.in                        |    1 +
 src/backend/libpq/Makefile                    |    1 +
 src/backend/libpq/auth-oauth.c                |  860 ++++++
 src/backend/libpq/auth.c                      |   10 +-
 src/backend/libpq/hba.c                       |   64 +-
 src/backend/libpq/meson.build                 |    1 +
 src/backend/libpq/pg_hba.conf.sample          |    4 +-
 src/backend/utils/adt/hbafuncs.c              |   19 +
 src/backend/utils/misc/guc_tables.c           |   12 +
 src/backend/utils/misc/postgresql.conf.sample |    3 +
 src/include/common/oauth-common.h             |   19 +
 src/include/libpq/auth.h                      |    1 +
 src/include/libpq/hba.h                       |    7 +-
 src/include/libpq/oauth.h                     |   54 +
 src/include/pg_config.h.in                    |    9 +
 src/interfaces/libpq/Makefile                 |   11 +-
 src/interfaces/libpq/exports.txt              |    3 +
 src/interfaces/libpq/fe-auth-oauth-curl.c     | 2635 +++++++++++++++++
 src/interfaces/libpq/fe-auth-oauth.c          | 1141 +++++++
 src/interfaces/libpq/fe-auth-oauth.h          |   45 +
 src/interfaces/libpq/fe-auth.c                |   36 +-
 src/interfaces/libpq/fe-auth.h                |    3 +
 src/interfaces/libpq/fe-connect.c             |   48 +-
 src/interfaces/libpq/libpq-fe.h               |   82 +
 src/interfaces/libpq/libpq-int.h              |   13 +-
 src/interfaces/libpq/meson.build              |    5 +
 src/makefiles/meson.build                     |    1 +
 src/test/authentication/t/001_password.pl     |    8 +-
 src/test/modules/Makefile                     |    1 +
 src/test/modules/meson.build                  |    1 +
 src/test/modules/oauth_validator/.gitignore   |    4 +
 src/test/modules/oauth_validator/Makefile     |   40 +
 src/test/modules/oauth_validator/README       |   13 +
 .../modules/oauth_validator/fail_validator.c  |   42 +
 src/test/modules/oauth_validator/meson.build  |   69 +
 .../oauth_validator/oauth_hook_client.c       |  264 ++
 .../modules/oauth_validator/t/001_server.pl   |  551 ++++
 .../modules/oauth_validator/t/002_client.pl   |  154 +
 .../modules/oauth_validator/t/OAuth/Server.pm |  140 +
 .../modules/oauth_validator/t/oauth_server.py |  391 +++
 src/test/modules/oauth_validator/validator.c  |  135 +
 src/test/perl/PostgreSQL/Test/Cluster.pm      |   20 +-
 src/tools/pgindent/pgindent                   |   14 +
 src/tools/pgindent/typedefs.list              |   11 +
 59 files changed, 8598 insertions(+), 39 deletions(-)
 create mode 100644 doc/src/sgml/oauth-validators.sgml
 create mode 100644 src/backend/libpq/auth-oauth.c
 create mode 100644 src/include/common/oauth-common.h
 create mode 100644 src/include/libpq/oauth.h
 create mode 100644 src/interfaces/libpq/fe-auth-oauth-curl.c
 create mode 100644 src/interfaces/libpq/fe-auth-oauth.c
 create mode 100644 src/interfaces/libpq/fe-auth-oauth.h
 create mode 100644 src/test/modules/oauth_validator/.gitignore
 create mode 100644 src/test/modules/oauth_validator/Makefile
 create mode 100644 src/test/modules/oauth_validator/README
 create mode 100644 src/test/modules/oauth_validator/fail_validator.c
 create mode 100644 src/test/modules/oauth_validator/meson.build
 create mode 100644 src/test/modules/oauth_validator/oauth_hook_client.c
 create mode 100644 src/test/modules/oauth_validator/t/001_server.pl
 create mode 100644 src/test/modules/oauth_validator/t/002_client.pl
 create mode 100644 src/test/modules/oauth_validator/t/OAuth/Server.pm
 create mode 100755 src/test/modules/oauth_validator/t/oauth_server.py
 create mode 100644 src/test/modules/oauth_validator/validator.c

diff --git a/.cirrus.tasks.yml b/.cirrus.tasks.yml
index cfe2117e02e..c192a077701 100644
--- a/.cirrus.tasks.yml
+++ b/.cirrus.tasks.yml
@@ -23,7 +23,7 @@ env:
   MTEST_ARGS: --print-errorlogs --no-rebuild -C build
   PGCTLTIMEOUT: 120 # avoids spurious failures during parallel tests
   TEMP_CONFIG: ${CIRRUS_WORKING_DIR}/src/tools/ci/pg_ci_base.conf
-  PG_TEST_EXTRA: kerberos ldap ssl libpq_encryption load_balance
+  PG_TEST_EXTRA: kerberos ldap ssl libpq_encryption load_balance oauth
 
 
 # What files to preserve in case tests fail
@@ -167,7 +167,7 @@ task:
     chown root:postgres /tmp/cores
     sysctl kern.corefile='/tmp/cores/%N.%P.core'
   setup_additional_packages_script: |
-    #pkg install -y ...
+    pkg install -y curl
 
   # NB: Intentionally build without -Dllvm. The freebsd image size is already
   # large enough to make VM startup slow, and even without llvm freebsd
@@ -222,6 +222,7 @@ LINUX_CONFIGURE_FEATURES: &LINUX_CONFIGURE_FEATURES >-
   --with-gssapi
   --with-icu
   --with-ldap
+  --with-libcurl
   --with-libxml
   --with-libxslt
   --with-llvm
@@ -315,8 +316,10 @@ task:
     EOF
 
   setup_additional_packages_script: |
-    #apt-get update
-    #DEBIAN_FRONTEND=noninteractive apt-get -y install ...
+    apt-get update
+    DEBIAN_FRONTEND=noninteractive apt-get -y install \
+      libcurl4-openssl-dev \
+      libcurl4-openssl-dev:i386 \
 
   matrix:
     - name: Linux - Debian Bookworm - Autoconf
@@ -692,8 +695,8 @@ task:
     folder: $CCACHE_DIR
 
   setup_additional_packages_script: |
-    #apt-get update
-    #DEBIAN_FRONTEND=noninteractive apt-get -y install ...
+    apt-get update
+    DEBIAN_FRONTEND=noninteractive apt-get -y install libcurl4-openssl-dev
 
   ###
   # Test that code can be built with gcc/clang without warnings
diff --git a/config/programs.m4 b/config/programs.m4
index 7b55c2664a6..86a3750f9e5 100644
--- a/config/programs.m4
+++ b/config/programs.m4
@@ -274,3 +274,45 @@ AC_DEFUN([PGAC_CHECK_STRIP],
   AC_SUBST(STRIP_STATIC_LIB)
   AC_SUBST(STRIP_SHARED_LIB)
 ])# PGAC_CHECK_STRIP
+
+
+
+# PGAC_CHECK_LIBCURL
+# ------------------
+# Check for required libraries and headers, and test to see whether the current
+# installation of libcurl is threadsafe.
+
+AC_DEFUN([PGAC_CHECK_LIBCURL],
+[
+  AC_CHECK_HEADER(curl/curl.h, [],
+				  [AC_MSG_ERROR([header file <curl/curl.h> is required for --with-libcurl])])
+  AC_CHECK_LIB(curl, curl_multi_init, [],
+			   [AC_MSG_ERROR([library 'curl' does not provide curl_multi_init])])
+
+  # Check to see whether the current platform supports threadsafe Curl
+  # initialization.
+  AC_CACHE_CHECK([for curl_global_init thread safety], [pgac_cv__libcurl_threadsafe_init],
+  [AC_RUN_IFELSE([AC_LANG_PROGRAM([
+#include <curl/curl.h>
+],[
+    curl_version_info_data *info;
+
+    if (curl_global_init(CURL_GLOBAL_ALL))
+        return -1;
+
+    info = curl_version_info(CURLVERSION_NOW);
+#ifdef CURL_VERSION_THREADSAFE
+    if (info->features & CURL_VERSION_THREADSAFE)
+        return 0;
+#endif
+
+    return 1;
+])],
+  [pgac_cv__libcurl_threadsafe_init=yes],
+  [pgac_cv__libcurl_threadsafe_init=no],
+  [pgac_cv__libcurl_threadsafe_init=unknown])])
+  if test x"$pgac_cv__libcurl_threadsafe_init" = xyes ; then
+    AC_DEFINE(HAVE_THREADSAFE_CURL_GLOBAL_INIT, 1,
+              [Define to 1 if curl_global_init() is guaranteed to be threadsafe.])
+  fi
+])# PGAC_CHECK_LIBCURL
diff --git a/configure b/configure
index 0ffcaeb4367..33422d24112 100755
--- a/configure
+++ b/configure
@@ -708,6 +708,9 @@ XML2_LIBS
 XML2_CFLAGS
 XML2_CONFIG
 with_libxml
+LIBCURL_LIBS
+LIBCURL_CFLAGS
+with_libcurl
 with_uuid
 with_readline
 with_systemd
@@ -864,6 +867,7 @@ with_readline
 with_libedit_preferred
 with_uuid
 with_ossp_uuid
+with_libcurl
 with_libxml
 with_libxslt
 with_system_tzdata
@@ -894,6 +898,8 @@ PKG_CONFIG_PATH
 PKG_CONFIG_LIBDIR
 ICU_CFLAGS
 ICU_LIBS
+LIBCURL_CFLAGS
+LIBCURL_LIBS
 XML2_CONFIG
 XML2_CFLAGS
 XML2_LIBS
@@ -1574,6 +1580,7 @@ Optional Packages:
                           prefer BSD Libedit over GNU Readline
   --with-uuid=LIB         build contrib/uuid-ossp using LIB (bsd,e2fs,ossp)
   --with-ossp-uuid        obsolete spelling of --with-uuid=ossp
+  --with-libcurl          build with libcurl support
   --with-libxml           build with XML support
   --with-libxslt          use XSLT support when building contrib/xml2
   --with-system-tzdata=DIR
@@ -1607,6 +1614,10 @@ Some influential environment variables:
               path overriding pkg-config's built-in search path
   ICU_CFLAGS  C compiler flags for ICU, overriding pkg-config
   ICU_LIBS    linker flags for ICU, overriding pkg-config
+  LIBCURL_CFLAGS
+              C compiler flags for LIBCURL, overriding pkg-config
+  LIBCURL_LIBS
+              linker flags for LIBCURL, overriding pkg-config
   XML2_CONFIG path to xml2-config utility
   XML2_CFLAGS C compiler flags for XML2, overriding pkg-config
   XML2_LIBS   linker flags for XML2, overriding pkg-config
@@ -8762,6 +8773,157 @@ fi
 
 
 
+#
+# libcurl
+#
+{ $as_echo "$as_me:${as_lineno-$LINENO}: checking whether to build with libcurl support" >&5
+$as_echo_n "checking whether to build with libcurl support... " >&6; }
+
+
+
+# Check whether --with-libcurl was given.
+if test "${with_libcurl+set}" = set; then :
+  withval=$with_libcurl;
+  case $withval in
+    yes)
+
+$as_echo "#define USE_LIBCURL 1" >>confdefs.h
+
+      ;;
+    no)
+      :
+      ;;
+    *)
+      as_fn_error $? "no argument expected for --with-libcurl option" "$LINENO" 5
+      ;;
+  esac
+
+else
+  with_libcurl=no
+
+fi
+
+
+{ $as_echo "$as_me:${as_lineno-$LINENO}: result: $with_libcurl" >&5
+$as_echo "$with_libcurl" >&6; }
+
+
+if test "$with_libcurl" = yes ; then
+  # Check for libcurl 7.61.0 or higher (corresponding to RHEL8 and the ability
+  # to explicitly set TLS 1.3 ciphersuites).
+
+pkg_failed=no
+{ $as_echo "$as_me:${as_lineno-$LINENO}: checking for libcurl >= 7.61.0" >&5
+$as_echo_n "checking for libcurl >= 7.61.0... " >&6; }
+
+if test -n "$LIBCURL_CFLAGS"; then
+    pkg_cv_LIBCURL_CFLAGS="$LIBCURL_CFLAGS"
+ elif test -n "$PKG_CONFIG"; then
+    if test -n "$PKG_CONFIG" && \
+    { { $as_echo "$as_me:${as_lineno-$LINENO}: \$PKG_CONFIG --exists --print-errors \"libcurl >= 7.61.0\""; } >&5
+  ($PKG_CONFIG --exists --print-errors "libcurl >= 7.61.0") 2>&5
+  ac_status=$?
+  $as_echo "$as_me:${as_lineno-$LINENO}: \$? = $ac_status" >&5
+  test $ac_status = 0; }; then
+  pkg_cv_LIBCURL_CFLAGS=`$PKG_CONFIG --cflags "libcurl >= 7.61.0" 2>/dev/null`
+		      test "x$?" != "x0" && pkg_failed=yes
+else
+  pkg_failed=yes
+fi
+ else
+    pkg_failed=untried
+fi
+if test -n "$LIBCURL_LIBS"; then
+    pkg_cv_LIBCURL_LIBS="$LIBCURL_LIBS"
+ elif test -n "$PKG_CONFIG"; then
+    if test -n "$PKG_CONFIG" && \
+    { { $as_echo "$as_me:${as_lineno-$LINENO}: \$PKG_CONFIG --exists --print-errors \"libcurl >= 7.61.0\""; } >&5
+  ($PKG_CONFIG --exists --print-errors "libcurl >= 7.61.0") 2>&5
+  ac_status=$?
+  $as_echo "$as_me:${as_lineno-$LINENO}: \$? = $ac_status" >&5
+  test $ac_status = 0; }; then
+  pkg_cv_LIBCURL_LIBS=`$PKG_CONFIG --libs "libcurl >= 7.61.0" 2>/dev/null`
+		      test "x$?" != "x0" && pkg_failed=yes
+else
+  pkg_failed=yes
+fi
+ else
+    pkg_failed=untried
+fi
+
+
+
+if test $pkg_failed = yes; then
+        { $as_echo "$as_me:${as_lineno-$LINENO}: result: no" >&5
+$as_echo "no" >&6; }
+
+if $PKG_CONFIG --atleast-pkgconfig-version 0.20; then
+        _pkg_short_errors_supported=yes
+else
+        _pkg_short_errors_supported=no
+fi
+        if test $_pkg_short_errors_supported = yes; then
+	        LIBCURL_PKG_ERRORS=`$PKG_CONFIG --short-errors --print-errors --cflags --libs "libcurl >= 7.61.0" 2>&1`
+        else
+	        LIBCURL_PKG_ERRORS=`$PKG_CONFIG --print-errors --cflags --libs "libcurl >= 7.61.0" 2>&1`
+        fi
+	# Put the nasty error message in config.log where it belongs
+	echo "$LIBCURL_PKG_ERRORS" >&5
+
+	as_fn_error $? "Package requirements (libcurl >= 7.61.0) were not met:
+
+$LIBCURL_PKG_ERRORS
+
+Consider adjusting the PKG_CONFIG_PATH environment variable if you
+installed software in a non-standard prefix.
+
+Alternatively, you may set the environment variables LIBCURL_CFLAGS
+and LIBCURL_LIBS to avoid the need to call pkg-config.
+See the pkg-config man page for more details." "$LINENO" 5
+elif test $pkg_failed = untried; then
+        { $as_echo "$as_me:${as_lineno-$LINENO}: result: no" >&5
+$as_echo "no" >&6; }
+	{ { $as_echo "$as_me:${as_lineno-$LINENO}: error: in \`$ac_pwd':" >&5
+$as_echo "$as_me: error: in \`$ac_pwd':" >&2;}
+as_fn_error $? "The pkg-config script could not be found or is too old.  Make sure it
+is in your PATH or set the PKG_CONFIG environment variable to the full
+path to pkg-config.
+
+Alternatively, you may set the environment variables LIBCURL_CFLAGS
+and LIBCURL_LIBS to avoid the need to call pkg-config.
+See the pkg-config man page for more details.
+
+To get pkg-config, see <http://pkg-config.freedesktop.org/>.
+See \`config.log' for more details" "$LINENO" 5; }
+else
+	LIBCURL_CFLAGS=$pkg_cv_LIBCURL_CFLAGS
+	LIBCURL_LIBS=$pkg_cv_LIBCURL_LIBS
+        { $as_echo "$as_me:${as_lineno-$LINENO}: result: yes" >&5
+$as_echo "yes" >&6; }
+
+fi
+
+  # We only care about -I, -D, and -L switches;
+  # note that -lcurl will be added by PGAC_CHECK_LIBCURL below.
+  for pgac_option in $LIBCURL_CFLAGS; do
+    case $pgac_option in
+      -I*|-D*) CPPFLAGS="$CPPFLAGS $pgac_option";;
+    esac
+  done
+  for pgac_option in $LIBCURL_LIBS; do
+    case $pgac_option in
+      -L*) LDFLAGS="$LDFLAGS $pgac_option";;
+    esac
+  done
+
+  # OAuth requires python for testing
+  if test "$with_python" != yes; then
+    { $as_echo "$as_me:${as_lineno-$LINENO}: WARNING: *** OAuth support tests require --with-python to run" >&5
+$as_echo "$as_me: WARNING: *** OAuth support tests require --with-python to run" >&2;}
+  fi
+fi
+
+
 #
 # XML
 #
@@ -12216,6 +12378,123 @@ fi
 
 fi
 
+# XXX libcurl must link after libgssapi_krb5 on FreeBSD to avoid segfaults
+# during gss_acquire_cred(). This is possibly related to Curl's Heimdal
+# dependency on that platform?
+if test "$with_libcurl" = yes ; then
+
+  ac_fn_c_check_header_mongrel "$LINENO" "curl/curl.h" "ac_cv_header_curl_curl_h" "$ac_includes_default"
+if test "x$ac_cv_header_curl_curl_h" = xyes; then :
+
+else
+  as_fn_error $? "header file <curl/curl.h> is required for --with-libcurl" "$LINENO" 5
+fi
+
+
+  { $as_echo "$as_me:${as_lineno-$LINENO}: checking for curl_multi_init in -lcurl" >&5
+$as_echo_n "checking for curl_multi_init in -lcurl... " >&6; }
+if ${ac_cv_lib_curl_curl_multi_init+:} false; then :
+  $as_echo_n "(cached) " >&6
+else
+  ac_check_lib_save_LIBS=$LIBS
+LIBS="-lcurl  $LIBS"
+cat confdefs.h - <<_ACEOF >conftest.$ac_ext
+/* end confdefs.h.  */
+
+/* Override any GCC internal prototype to avoid an error.
+   Use char because int might match the return type of a GCC
+   builtin and then its argument prototype would still apply.  */
+#ifdef __cplusplus
+extern "C"
+#endif
+char curl_multi_init ();
+int
+main ()
+{
+return curl_multi_init ();
+  ;
+  return 0;
+}
+_ACEOF
+if ac_fn_c_try_link "$LINENO"; then :
+  ac_cv_lib_curl_curl_multi_init=yes
+else
+  ac_cv_lib_curl_curl_multi_init=no
+fi
+rm -f core conftest.err conftest.$ac_objext \
+    conftest$ac_exeext conftest.$ac_ext
+LIBS=$ac_check_lib_save_LIBS
+fi
+{ $as_echo "$as_me:${as_lineno-$LINENO}: result: $ac_cv_lib_curl_curl_multi_init" >&5
+$as_echo "$ac_cv_lib_curl_curl_multi_init" >&6; }
+if test "x$ac_cv_lib_curl_curl_multi_init" = xyes; then :
+  cat >>confdefs.h <<_ACEOF
+#define HAVE_LIBCURL 1
+_ACEOF
+
+  LIBS="-lcurl $LIBS"
+
+else
+  as_fn_error $? "library 'curl' does not provide curl_multi_init" "$LINENO" 5
+fi
+
+
+  # Check to see whether the current platform supports threadsafe Curl
+  # initialization.
+  { $as_echo "$as_me:${as_lineno-$LINENO}: checking for curl_global_init thread safety" >&5
+$as_echo_n "checking for curl_global_init thread safety... " >&6; }
+if ${pgac_cv__libcurl_threadsafe_init+:} false; then :
+  $as_echo_n "(cached) " >&6
+else
+  if test "$cross_compiling" = yes; then :
+  pgac_cv__libcurl_threadsafe_init=unknown
+else
+  cat confdefs.h - <<_ACEOF >conftest.$ac_ext
+/* end confdefs.h.  */
+
+#include <curl/curl.h>
+
+int
+main ()
+{
+
+    curl_version_info_data *info;
+
+    if (curl_global_init(CURL_GLOBAL_ALL))
+        return -1;
+
+    info = curl_version_info(CURLVERSION_NOW);
+#ifdef CURL_VERSION_THREADSAFE
+    if (info->features & CURL_VERSION_THREADSAFE)
+        return 0;
+#endif
+
+    return 1;
+
+  ;
+  return 0;
+}
+_ACEOF
+if ac_fn_c_try_run "$LINENO"; then :
+  pgac_cv__libcurl_threadsafe_init=yes
+else
+  pgac_cv__libcurl_threadsafe_init=no
+fi
+rm -f core *.core core.conftest.* gmon.out bb.out conftest$ac_exeext \
+  conftest.$ac_objext conftest.beam conftest.$ac_ext
+fi
+
+fi
+{ $as_echo "$as_me:${as_lineno-$LINENO}: result: $pgac_cv__libcurl_threadsafe_init" >&5
+$as_echo "$pgac_cv__libcurl_threadsafe_init" >&6; }
+  if test x"$pgac_cv__libcurl_threadsafe_init" = xyes ; then
+
+$as_echo "#define HAVE_THREADSAFE_CURL_GLOBAL_INIT 1" >>confdefs.h
+
+  fi
+
+fi
+
 if test "$with_gssapi" = yes ; then
   if test "$PORTNAME" != "win32"; then
     { $as_echo "$as_me:${as_lineno-$LINENO}: checking for library containing gss_store_cred_into" >&5
diff --git a/configure.ac b/configure.ac
index f56681e0d91..b6d02f5ecc7 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1007,6 +1007,40 @@ fi
 AC_SUBST(with_uuid)
 
 
+#
+# libcurl
+#
+AC_MSG_CHECKING([whether to build with libcurl support])
+PGAC_ARG_BOOL(with, libcurl, no, [build with libcurl support],
+              [AC_DEFINE([USE_LIBCURL], 1, [Define to 1 to build with libcurl support. (--with-libcurl)])])
+AC_MSG_RESULT([$with_libcurl])
+AC_SUBST(with_libcurl)
+
+if test "$with_libcurl" = yes ; then
+  # Check for libcurl 7.61.0 or higher (corresponding to RHEL8 and the ability
+  # to explicitly set TLS 1.3 ciphersuites).
+  PKG_CHECK_MODULES(LIBCURL, [libcurl >= 7.61.0])
+
+  # We only care about -I, -D, and -L switches;
+  # note that -lcurl will be added by PGAC_CHECK_LIBCURL below.
+  for pgac_option in $LIBCURL_CFLAGS; do
+    case $pgac_option in
+      -I*|-D*) CPPFLAGS="$CPPFLAGS $pgac_option";;
+    esac
+  done
+  for pgac_option in $LIBCURL_LIBS; do
+    case $pgac_option in
+      -L*) LDFLAGS="$LDFLAGS $pgac_option";;
+    esac
+  done
+
+  # OAuth requires python for testing
+  if test "$with_python" != yes; then
+    AC_MSG_WARN([*** OAuth support tests require --with-python to run])
+  fi
+fi
+
+
 #
 # XML
 #
@@ -1294,6 +1328,13 @@ failure.  It is possible the compiler isn't looking in the proper directory.
 Use --without-zlib to disable zlib support.])])
 fi
 
+# XXX libcurl must link after libgssapi_krb5 on FreeBSD to avoid segfaults
+# during gss_acquire_cred(). This is possibly related to Curl's Heimdal
+# dependency on that platform?
+if test "$with_libcurl" = yes ; then
+  PGAC_CHECK_LIBCURL
+fi
+
 if test "$with_gssapi" = yes ; then
   if test "$PORTNAME" != "win32"; then
     AC_SEARCH_LIBS(gss_store_cred_into, [gssapi_krb5 gss 'gssapi -lkrb5 -lcrypto'], [],
diff --git a/doc/src/sgml/client-auth.sgml b/doc/src/sgml/client-auth.sgml
index 782b49c85ac..f84085dbac4 100644
--- a/doc/src/sgml/client-auth.sgml
+++ b/doc/src/sgml/client-auth.sgml
@@ -656,6 +656,16 @@ include_dir         <replaceable>directory</replaceable>
          </para>
         </listitem>
        </varlistentry>
+
+       <varlistentry>
+        <term><literal>oauth</literal></term>
+        <listitem>
+         <para>
+          Authorize and optionally authenticate using a third-party OAuth 2.0
+          identity provider. See <xref linkend="auth-oauth"/> for details.
+         </para>
+        </listitem>
+       </varlistentry>
       </variablelist>
 
       </para>
@@ -1143,6 +1153,12 @@ omicron         bryanh                  guest1
       only on OpenBSD).
      </para>
     </listitem>
+    <listitem>
+     <para>
+      <link linkend="auth-oauth">OAuth authorization/authentication</link>,
+      which relies on an external OAuth 2.0 identity provider.
+     </para>
+    </listitem>
    </itemizedlist>
   </para>
 
@@ -2329,6 +2345,242 @@ host ... radius radiusservers="server1,server2" radiussecrets="""secret one"",""
    </note>
   </sect1>
 
+  <sect1 id="auth-oauth">
+   <title>OAuth Authorization/Authentication</title>
+
+   <indexterm zone="auth-oauth">
+    <primary>OAuth Authorization/Authentication</primary>
+   </indexterm>
+
+   <para>
+    OAuth 2.0 is an industry-standard framework, defined in
+    <ulink url="https://datatracker.ietf.org/doc/html/rfc6749">RFC 6749</ulink>,
+    to enable third-party applications to obtain limited access to a protected
+    resource.
+
+    OAuth client support has to be enabled when <productname>PostgreSQL</productname>
+    is built, see <xref linkend="installation"/> for more information.
+   </para>
+
+   <para>
+    This documentation uses the following terminology when discussing the OAuth
+    ecosystem:
+
+    <variablelist>
+
+     <varlistentry>
+      <term>Resource Owner (or End User)</term>
+      <listitem>
+       <para>
+        The user or system who owns protected resources and can grant access to
+        them. This documentation also uses the term <emphasis>end user</emphasis>
+        when the resource owner is a person. When you use
+        <application>psql</application> to connect to the database using OAuth,
+        you are the resource owner/end user.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term>Client</term>
+      <listitem>
+       <para>
+        The system which accesses the protected resources using access
+        tokens. Applications using libpq, such as <application>psql</application>,
+        are the OAuth clients when connecting to a
+        <productname>PostgreSQL</productname> cluster.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term>Resource Server</term>
+      <listitem>
+       <para>
+        The system which hosts the protected resources which are
+        accessed by the client. The <productname>PostgreSQL</productname>
+        cluster being connected to is the resource server.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term>Provider</term>
+      <listitem>
+       <para>
+        The organization, product vendor, or other entity which develops and/or
+        administers the OAuth servers and clients for a given application.
+        Different providers typically choose different implementation details
+        for their OAuth systems; a client of one provider is not generally
+        guaranteed to have access to the servers of another.
+       </para>
+       <para>
+        This use of the term "provider" is not standard, but it seems to be in
+        wide use colloquially. (It should not be confused with OpenID's similar
+        term "Identity Provider". While the implementation of OAuth in
+        <productname>PostgreSQL</productname> is intended to be interoperable
+        and compatible with OpenID Connect/OIDC, it is not itself an OIDC client
+        and does not require its use.)
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term>Authorization Server</term>
+      <listitem>
+       <para>
+        The system which receives requests from, and issues access tokens to,
+        the client after the authenticated resource owner has given approval.
+        <productname>PostgreSQL</productname> does not provide an authorization
+        server; it's obtained from the OAuth provider.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term id="auth-oauth-issuer">Issuer</term>
+      <listitem>
+       <para>
+        An identifier for an authorization server, printed as an
+        <literal>https://</literal> URL, which provides a trusted "namespace"
+        for OAuth clients and applications. The issuer identifier allows a
+        single authorization server to talk to the clients of mutually
+        untrusting entities, as long as they maintain separate issuers.
+       </para>
+      </listitem>
+     </varlistentry>
+
+    </variablelist>
+
+    <note>
+     <para>
+      For small deployments, there may not be a meaningful distinction between
+      the "provider", "authorization server", and "issuer". However, for more
+      complicated setups, there may be a one-to-many (or many-to-many)
+      relationship: a provider may rent out multiple issuer identifiers to
+      separate tenants, then provide multiple authorization servers, possibly
+      with different supported feature sets, to interact with their clients.
+     </para>
+    </note>
+   </para>
+
+   <para>
+    <productname>PostgreSQL</productname> supports bearer tokens, defined in
+    <ulink url="https://datatracker.ietf.org/doc/html/rfc6750">RFC 6750</ulink>,
+    which are a type of access token used with OAuth 2.0 where the token is an
+    opaque string.  The format of the access token is implementation specific
+    and is chosen by each authorization server.
+   </para>
+
+   <para>
+    The following configuration options are supported for OAuth:
+    <variablelist>
+     <varlistentry>
+      <term><literal>issuer</literal></term>
+      <listitem>
+       <para>
+        An HTTPS URL which is either the exact
+        <link linkend="auth-oauth-issuer">issuer identifier</link> of the
+        authorization server, as defined by its discovery document, or a
+        well-known URI that points directly to that discovery document. This
+        parameter is required.
+       </para>
+       <para>
+        When an OAuth client connects to the server, a URL for the discovery
+        document will be constructed using the issuer identifier. By default,
+        this URL uses the conventions of OpenID Connect Discovery: the path
+        <literal>/.well-known/openid-configuration</literal> will be appended
+        to the end of the issuer identifier. Alternatively, if the
+        <literal>issuer</literal> contains a <literal>/.well-known/</literal>
+        path segment, that URL will be provided to the client as-is.
+       </para>
+       <warning>
+        <para>
+         The OAuth client in libpq requires the server's issuer setting to
+         exactly match the issuer identifier which is provided in the discovery
+         document, which must in turn match the client's
+         <xref linkend="libpq-connect-oauth-issuer"/> setting. No variations in
+         case or format are permitted.
+        </para>
+       </warning>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term><literal>scope</literal></term>
+      <listitem>
+       <para>
+        A space-separated list of the OAuth scopes needed for the server to
+        both authorize the client and authenticate the user.  Appropriate values
+        are determined by the authorization server and the OAuth validation
+        module used (see <xref linkend="oauth-validators" /> for more
+        information on validators).  This parameter is required.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term><literal>validator</literal></term>
+      <listitem>
+       <para>
+        The library to use for validating bearer tokens. If given, the name must
+        exactly match one of the libraries listed in
+        <xref linkend="guc-oauth-validator-libraries" />.  This parameter is
+        optional unless <literal>oauth_validator_libraries</literal> contains
+        more than one library, in which case it is required.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term><literal>map</literal></term>
+      <listitem>
+       <para>
+        Allows for mapping between OAuth identity provider and database user
+        names.  See <xref linkend="auth-username-maps"/> for details.  If a
+        map is not specified, the user name associated with the token (as
+        determined by the OAuth validator) must exactly match the role name
+        being requested.  This parameter is optional.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term id="auth-oauth-delegate-ident-mapping" xreflabel="delegate_ident_mapping">
+       <literal>delegate_ident_mapping</literal>
+      </term>
+      <listitem>
+       <para>
+        An advanced option which is not intended for common use.
+       </para>
+       <para>
+        When set to <literal>1</literal>, standard user mapping with
+        <filename>pg_ident.conf</filename> is skipped, and the OAuth validator
+        takes full responsibility for mapping end user identities to database
+        roles.  If the validator authorizes the token, the server trusts that
+        the user is allowed to connect under the requested role, and the
+        connection is allowed to proceed regardless of the authentication
+        status of the user.
+       </para>
+       <para>
+        This parameter is incompatible with <literal>map</literal>.
+       </para>
+       <warning>
+        <para>
+         <literal>delegate_ident_mapping</literal> provides additional
+         flexibility in the design of the authentication system, but it also
+         requires careful implementation of the OAuth validator, which must
+         determine whether the provided token carries sufficient end-user
+         privileges in addition to the <link linkend="oauth-validators">standard
+         checks</link> required of all validators.  Use with caution.
+        </para>
+       </warning>
+      </listitem>
+     </varlistentry>
+    </variablelist>
+   </para>
+  </sect1>
+
   <sect1 id="client-authentication-problems">
    <title>Authentication Problems</title>
 
diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml
index 38244409e3c..d53595f8951 100644
--- a/doc/src/sgml/config.sgml
+++ b/doc/src/sgml/config.sgml
@@ -1209,6 +1209,32 @@ include_dir 'conf.d'
        </para>
       </listitem>
      </varlistentry>
+
+     <varlistentry id="guc-oauth-validator-libraries" xreflabel="oauth_validator_libraries">
+      <term><varname>oauth_validator_libraries</varname> (<type>string</type>)
+      <indexterm>
+       <primary><varname>oauth_validator_libraries</varname> configuration parameter</primary>
+      </indexterm>
+      </term>
+      <listitem>
+       <para>
+        The library/libraries to use for validating OAuth connection tokens. If
+        only one validator library is provided, it will be used by default for
+        any OAuth connections; otherwise, all
+        <link linkend="auth-oauth"><literal>oauth</literal> HBA entries</link>
+        must explicitly set a <literal>validator</literal> chosen from this
+        list. If set to an empty string (the default), OAuth connections will be
+        refused. This parameter can only be set in the
+        <filename>postgresql.conf</filename> file.
+       </para>
+       <para>
+        Validator modules must be implemented/obtained separately;
+        <productname>PostgreSQL</productname> does not ship with any default
+        implementations. For more information on implementing OAuth validators,
+        see <xref linkend="oauth-validators" />.
+       </para>
+      </listitem>
+     </varlistentry>
      </variablelist>
      </sect2>
 
diff --git a/doc/src/sgml/filelist.sgml b/doc/src/sgml/filelist.sgml
index 66e6dccd4c9..25fb99cee69 100644
--- a/doc/src/sgml/filelist.sgml
+++ b/doc/src/sgml/filelist.sgml
@@ -111,6 +111,7 @@
 <!ENTITY generic-wal SYSTEM "generic-wal.sgml">
 <!ENTITY custom-rmgr SYSTEM "custom-rmgr.sgml">
 <!ENTITY backup-manifest SYSTEM "backup-manifest.sgml">
+<!ENTITY oauth-validators SYSTEM "oauth-validators.sgml">
 
 <!-- contrib information -->
 <!ENTITY contrib         SYSTEM "contrib.sgml">
diff --git a/doc/src/sgml/installation.sgml b/doc/src/sgml/installation.sgml
index 3f0a7e9c069..96e433179b9 100644
--- a/doc/src/sgml/installation.sgml
+++ b/doc/src/sgml/installation.sgml
@@ -1143,6 +1143,19 @@ build-postgresql:
        </listitem>
       </varlistentry>
 
+      <varlistentry id="configure-option-with-libcurl">
+       <term><option>--with-libcurl</option></term>
+       <listitem>
+        <para>
+         Build with libcurl support for OAuth 2.0 client flows.
+         This requires the <productname>curl</productname> package to be
+         installed.  Building with this will check for the required header files
+         and libraries to make sure that your <productname>curl</productname>
+         installation is sufficient before proceeding.
+        </para>
+       </listitem>
+      </varlistentry>
+
       <varlistentry id="configure-option-with-libxml">
        <term><option>--with-libxml</option></term>
        <listitem>
@@ -2584,6 +2597,20 @@ ninja install
       </listitem>
      </varlistentry>
 
+     <varlistentry id="configure-with-libcurl-meson">
+      <term><option>-Dlibcurl={ auto | enabled | disabled }</option></term>
+      <listitem>
+       <para>
+        Build with libcurl support for OAuth 2.0 client flows.
+        This requires the <productname>curl</productname> package to be
+        installed.  Building with this will check for the required header files
+        and libraries to make sure that your <productname>curl</productname>
+        installation is sufficient before proceeding. The default for this
+        option is auto.
+       </para>
+      </listitem>
+     </varlistentry>
+
      <varlistentry id="configure-with-libxml-meson">
       <term><option>-Dlibxml={ auto | enabled | disabled }</option></term>
       <listitem>
diff --git a/doc/src/sgml/libpq.sgml b/doc/src/sgml/libpq.sgml
index e04acf1c208..9a69ffbc5b3 100644
--- a/doc/src/sgml/libpq.sgml
+++ b/doc/src/sgml/libpq.sgml
@@ -1385,6 +1385,15 @@ postgresql://%2Fvar%2Flib%2Fpostgresql/dbname
           </listitem>
          </varlistentry>
 
+         <varlistentry>
+          <term><literal>oauth</literal></term>
+          <listitem>
+           <para>
+            The server must request an OAuth bearer token from the client.
+           </para>
+          </listitem>
+         </varlistentry>
+
          <varlistentry>
           <term><literal>none</literal></term>
           <listitem>
@@ -2373,6 +2382,106 @@ postgresql://%2Fvar%2Flib%2Fpostgresql/dbname
        </para>
       </listitem>
      </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-issuer" xreflabel="oauth_issuer">
+      <term><literal>oauth_issuer</literal></term>
+      <listitem>
+       <para>
+        The HTTPS URL of a trusted issuer to contact if the server requests an
+        OAuth token for the connection. This parameter is required for all OAuth
+        connections; it should exactly match the <literal>issuer</literal>
+        setting in <link linkend="auth-oauth">the server's HBA configuration.</link>
+       </para>
+       <para>
+        As part of the standard authentication handshake, <application>libpq</application>
+        will ask the server for a <emphasis>discovery document:</emphasis> a URL
+        providing a set of OAuth configuration parameters. The server must
+        provide a URL that is directly constructed from the components of the
+        <literal>oauth_issuer</literal>, and this value must exactly match the
+        issuer identifier that is declared in the discovery document itself, or
+        the connection will fail. This is required to prevent a class of "mix-up
+        attacks" on OAuth clients.
+       </para>
+       <para>
+        You may also explicitly set <literal>oauth_issuer</literal> to the
+        <literal>/.well-known/</literal> URI used for OAuth discovery. In this
+        case, if the server asks for a different URL, the connection will fail,
+        but a <link linkend="libpq-oauth-authdata-hooks">custom OAuth flow</link>
+        may be able to speed up the standard handshake by using previously
+        cached tokens. (In this case, it is recommended that
+        <xref linkend="libpq-connect-oauth-scope"/> be set as well, since the
+        client will not have a chance to ask the server for a correct scope
+        setting, and the default scopes for a token may not be sufficient to
+        connect.) <application>libpq</application> currently supports the
+        following well-known endpoints:
+        <itemizedlist spacing="compact">
+         <listitem><para><literal>/.well-known/openid-configuration</literal></para></listitem>
+         <listitem><para><literal>/.well-known/oauth-authorization-server</literal></para></listitem>
+        </itemizedlist>
+       </para>
+       <warning>
+        <para>
+         Issuers are highly privileged during the OAuth connection handshake. As
+         a rule of thumb, if you would not trust the operator of a URL to handle
+         access to your servers, or to impersonate you directly, that URL should
+         not be trusted as an <literal>oauth_issuer</literal>.
+        </para>
+       </warning>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-client-id" xreflabel="oauth_client_id">
+      <term><literal>oauth_client_id</literal></term>
+      <listitem>
+       <para>
+        An OAuth 2.0 client identifier, as issued by the authorization server.
+        If the <productname>PostgreSQL</productname> server
+        <link linkend="auth-oauth">requests an OAuth token</link> for the
+        connection (and if no <link linkend="libpq-oauth-authdata-hooks">custom
+        OAuth hook</link> is installed to provide one), then this parameter must
+        be set; otherwise, the connection will fail.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-client-secret" xreflabel="oauth_client_secret">
+      <term><literal>oauth_client_secret</literal></term>
+      <listitem>
+       <para>
+        The client password, if any, to use when contacting the OAuth
+        authorization server. Whether this parameter is required or not is
+        determined by the OAuth provider; "public" clients generally do not use
+        a secret, whereas "confidential" clients generally do.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-scope" xreflabel="oauth_scope">
+      <term><literal>oauth_scope</literal></term>
+      <listitem>
+       <para>
+        The scope of the access request sent to the authorization server,
+        specified as a (possibly empty) space-separated list of OAuth scope
+        identifiers. This parameter is optional and intended for advanced usage.
+       </para>
+       <para>
+        Usually the client will obtain appropriate scope settings from the
+        <productname>PostgreSQL</productname> server. If this parameter is used,
+        the server's requested scope list will be ignored. This can prevent a
+        less-trusted server from requesting inappropriate access scopes from the
+        end user. However, if the client's scope setting does not contain the
+        server's required scopes, the server is likely to reject the issued
+        token, and the connection will fail.
+       </para>
+       <para>
+        The meaning of an empty scope list is provider-dependent. An OAuth
+        authorization server may choose to issue a token with "default scope",
+        whatever that happens to be, or it may reject the token request
+        entirely.
+       </para>
+      </listitem>
+     </varlistentry>
+
     </variablelist>
    </para>
   </sect2>
@@ -10020,6 +10129,278 @@ void PQinitSSL(int do_ssl);
 
  </sect1>
 
+ <sect1 id="libpq-oauth">
+  <title>OAuth Support</title>
+
+  <para>
+   TODO
+  </para>
+
+  <sect2 id="libpq-oauth-authdata-hooks">
+   <title>Authdata Hooks</title>
+
+   <para>
+    The behavior of the OAuth flow may be modified or replaced by a client using
+    the following hook API:
+
+    <variablelist>
+     <varlistentry id="libpq-PQsetAuthDataHook">
+      <term><function>PQsetAuthDataHook</function><indexterm><primary>PQsetAuthDataHook</primary></indexterm></term>
+
+      <listitem>
+       <para>
+        Sets the <symbol>PGauthDataHook</symbol>, overriding
+        <application>libpq</application>'s handling of one or more aspects of
+        its OAuth client flow.
+<synopsis>
+void PQsetAuthDataHook(PQauthDataHook_type hook);
+</synopsis>
+        If <replaceable>hook</replaceable> is <literal>NULL</literal>, the
+        default handler will be reinstalled. Otherwise, the application passes
+        a pointer to a callback function with the signature:
+<programlisting>
+int hook_fn(PGauthData type, PGconn *conn, void *data);
+</programlisting>
+        which <application>libpq</application> will call when when action is
+        required of the application. <replaceable>type</replaceable> describes
+        the request being made, <replaceable>conn</replaceable> is the
+        connection handle being authenticated, and <replaceable>data</replaceable>
+        points to request-specific metadata. The contents of this pointer are
+        determined by <replaceable>type</replaceable>; see
+        <xref linkend="libpq-oauth-authdata-hooks-types"/> for the supported
+        list.
+       </para>
+       <para>
+        Hooks can be chained together to allow cooperative and/or fallback
+        behavior. In general, a hook implementation should examine the incoming
+        <replaceable>type</replaceable> (and, potentially, the request metadata
+        and/or the settings for the particular <replaceable>conn</replaceable>
+        in use) to decide whether or not to handle a specific piece of authdata.
+        If not, it should delegate to the previous hook in the chain
+        (retrievable via <function>PQgetAuthDataHook</function>).
+       </para>
+       <para>
+        Success is indicated by returning an integer greater than zero.
+        Returning a negative integer signals an error condition and abandons the
+        connection attempt. (A zero value is reserved for the default
+        implementation.)
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="libpq-PQgetAuthDataHook">
+      <term><function>PQgetAuthDataHook</function><indexterm><primary>PQgetAuthDataHook</primary></indexterm></term>
+
+      <listitem>
+       <para>
+        Retrieves the current value of <symbol>PGauthDataHook</symbol>.
+<synopsis>
+PQauthDataHook_type PQgetAuthDataHook(void);
+</synopsis>
+        At initialization time (before the first call to
+        <function>PQsetAuthDataHook</function>), this function will return
+        <symbol>PQdefaultAuthDataHook</symbol>.
+       </para>
+      </listitem>
+     </varlistentry>
+    </variablelist>
+   </para>
+
+   <sect3 id="libpq-oauth-authdata-hooks-types">
+    <title>Hook Types</title>
+    <para>
+     The following <symbol>PGauthData</symbol> types and their corresponding
+     <replaceable>data</replaceable> structures are defined:
+
+     <variablelist>
+      <varlistentry id="libpq-oauth-authdata-prompt-oauth-device">
+       <term>
+        <symbol>PQAUTHDATA_PROMPT_OAUTH_DEVICE</symbol>
+        <indexterm><primary>PQAUTHDATA_PROMPT_OAUTH_DEVICE</primary></indexterm>
+       </term>
+       <listitem>
+        <para>
+         Replaces the default user prompt during the builtin device
+         authorization client flow. <replaceable>data</replaceable> points to
+         an instance of <symbol>PGpromptOAuthDevice</symbol>:
+<synopsis>
+typedef struct _PGpromptOAuthDevice
+{
+    const char *verification_uri;   /* verification URI to visit */
+    const char *user_code;          /* user code to enter */
+} PGpromptOAuthDevice;
+</synopsis>
+        </para>
+        <para>
+         The OAuth Device Authorization flow included in <application>libpq</application>
+         requires the end user to visit a URL with a browser, then enter a code
+         which permits <application>libpq</application> to connect to the server
+         on their behalf. The default prompt simply prints the
+         <literal>verification_uri</literal> and <literal>user_code</literal>
+         on standard error. Replacement implementations may display this
+         information using any preferred method, for example with a GUI.
+        </para>
+        <para>
+         This callback is only invoked during the builtin device
+         authorization flow. If the application installs a
+         <link linkend="libpq-oauth-authdata-oauth-bearer-token">custom OAuth
+         flow</link>, this authdata type will not be used.
+        </para>
+       </listitem>
+      </varlistentry>
+
+      <varlistentry id="libpq-oauth-authdata-oauth-bearer-token">
+       <term>
+        <symbol>PQAUTHDATA_OAUTH_BEARER_TOKEN</symbol>
+        <indexterm><primary>PQAUTHDATA_OAUTH_BEARER_TOKEN</primary></indexterm>
+       </term>
+       <listitem>
+        <para>
+         Replaces the entire OAuth flow with a custom implementation. The hook
+         should either directly return a Bearer token for the current
+         user/issuer/scope combination, if one is available without blocking, or
+         else set up an asynchronous callback to retrieve one.
+        </para>
+        <para>
+         <replaceable>data</replaceable> points to an instance
+         of <symbol>PGoauthBearerRequest</symbol>, which should be filled in
+         by the implementation:
+<synopsis>
+typedef struct _PGoauthBearerRequest
+{
+    /* Hook inputs (constant across all calls) */
+    const char *const openid_configuration; /* OIDC discovery URL */
+    const char *const scope;                /* required scope(s), or NULL */
+
+    /* Hook outputs */
+
+    /* Callback implementing a custom asynchronous OAuth flow. */
+    PostgresPollingStatusType (*async) (PGconn *conn,
+                                        struct _PGoauthBearerRequest *request,
+                                        SOCKTYPE *altsock);
+
+    /* Callback to clean up custom allocations. */
+    void        (*cleanup) (PGconn *conn, struct _PGoauthBearerRequest *request);
+
+    char       *token;   /* acquired Bearer token */
+    void       *user;    /* hook-defined allocated data */
+} PGoauthBearerRequest;
+</synopsis>
+        </para>
+        <para>
+         Two pieces of information are provided to the hook by
+         <application>libpq</application>:
+         <replaceable>openid_configuration</replaceable> contains the URL of an
+         OAuth discovery document describing the authorization server's
+         supported flows, and <replaceable>scope</replaceable> contains a
+         (possibly empty) space-separated list of OAuth scopes which are
+         required to access the server. Either or both may be
+         <literal>NULL</literal> to indicate that the information was not
+         discoverable. (In this case, implementations may be able to establish
+         the requirements using some other preconfigured knowledge, or they may
+         choose to fail.)
+        </para>
+        <para>
+         The final output of the hook is <replaceable>token</replaceable>, which
+         must point to a valid Bearer token for use on the connection. (This
+         token should be issued by the
+         <xref linkend="libpq-connect-oauth-issuer"/> and hold the requested
+         scopes, or the connection will be rejected by the server's validator
+         module.) The allocated token string must remain valid until
+         <application>libpq</application> is finished connecting; the hook
+         should set a <replaceable>cleanup</replaceable> callback which will be
+         called when <application>libpq</application> no longer requires it.
+        </para>
+        <para>
+         If an implementation cannot immediately produce a
+         <replaceable>token</replaceable> during the initial call to the hook,
+         it should set the <replaceable>async</replaceable> callback to handle
+         nonblocking communication with the authorization server.
+         <footnote>
+          <para>
+           Performing blocking operations during the
+           <symbol>PQAUTHDATA_OAUTH_BEARER_TOKEN</symbol> hook callback will
+           interfere with nonblocking connection APIs such as
+           <function>PQconnectPoll</function> and prevent concurrent connections
+           from making progress. Applications which only ever use the
+           synchronous connection primitives, such as
+           <function>PQconnectdb</function>, may synchronously retrieve a token
+           during the hook instead of implementing the
+           <replaceable>async</replaceable> callback, but they will necessarily
+           be limited to one connection at a time.
+          </para>
+         </footnote>
+         This will be called to begin the flow immediately upon return from the
+         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
+         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>
+         after setting <replaceable>token</replaceable> when the flow is
+         complete, or <symbol>PGRES_POLLING_FAILED</symbol> to indicate failure.
+        </para>
+        <para>
+         Implementations may wish to store additional data for bookkeeping
+         across calls to the <replaceable>async</replaceable> and
+         <replaceable>cleanup</replaceable> callbacks. The
+         <replaceable>user</replaceable> pointer is provided for this purpose;
+         <application>libpq</application> will not touch its contents and the
+         application may use it at its convenience. (Remember to free any
+         allocations during token cleanup.)
+        </para>
+       </listitem>
+      </varlistentry>
+     </variablelist>
+    </para>
+   </sect3>
+  </sect2>
+
+  <sect2 id="libpq-oauth-debugging">
+   <title>Debugging and Developer Settings</title>
+
+   <para>
+    A "dangerous debugging mode" may be enabled by setting the environment
+    variable <envar>PGOAUTHDEBUG=UNSAFE</envar>. This functionality is provided
+    for ease of local development and testing only. It does several things that
+    you will not want a production system to do:
+
+    <itemizedlist spacing="compact">
+     <listitem>
+      <para>
+       permits the use of unencrypted HTTP during the OAuth provider exchange
+      </para>
+     </listitem>
+     <listitem>
+      <para>
+       allows the system's trusted CA list to be completely replaced using the
+       <envar>PGOAUTHCAFILE</envar> environment variable
+      </para>
+     </listitem>
+     <listitem>
+      <para>
+       sprays HTTP traffic (containing several critical secrets) to standard
+       error during the OAuth flow
+      </para>
+     </listitem>
+     <listitem>
+      <para>
+       permits the use of zero-second retry intervals, which can cause the
+       client to busy-loop and pointlessly consume CPU
+      </para>
+     </listitem>
+    </itemizedlist>
+   </para>
+   <warning>
+    <para>
+     Do not share the output of the OAuth flow traffic with third parties. It
+     contains secrets that can be used to attack your clients and servers.
+    </para>
+   </warning>
+  </sect2>
+ </sect1>
+
 
  <sect1 id="libpq-threading">
   <title>Behavior in Threaded Programs</title>
@@ -10092,6 +10473,18 @@ int PQisthreadsafe();
    <application>libpq</application> source code for a way to do cooperative
    locking between <application>libpq</application> and your application.
   </para>
+
+  <para>
+   Similarly, if you are using Curl inside your application,
+   <emphasis>and</emphasis> you do not already
+   <ulink url="https://curl.se/libcurl/c/curl_global_init.html">initialize
+   libcurl globally</ulink> before starting new threads, you will need to
+   cooperatively lock (again via <function>PQregisterThreadLock</function>)
+   around any code that may initialize libcurl. This restriction is lifted for
+   more recent versions of Curl that are built to support threadsafe
+   initialization; those builds can be identified by the advertisement of a
+   <literal>threadsafe</literal> feature in their version metadata.
+  </para>
  </sect1>
 
 
diff --git a/doc/src/sgml/oauth-validators.sgml b/doc/src/sgml/oauth-validators.sgml
new file mode 100644
index 00000000000..d0bca9196d9
--- /dev/null
+++ b/doc/src/sgml/oauth-validators.sgml
@@ -0,0 +1,402 @@
+<!-- doc/src/sgml/oauth-validators.sgml -->
+
+<chapter id="oauth-validators">
+ <title>OAuth Validator Modules</title>
+ <indexterm zone="oauth-validators">
+  <primary>OAuth Validators</primary>
+ </indexterm>
+ <para>
+  <productname>PostgreSQL</productname> provides infrastructure for creating
+  custom modules to perform server-side validation of OAuth bearer tokens.
+  Because OAuth implementations vary so wildly, and bearer token validation is
+  heavily dependent on the issuing party, the server cannot check the token
+  itself; validator modules provide the glue between the server and the OAuth
+  provider in use.
+ </para>
+ <para>
+  OAuth validator modules must at least consist of an initialization function
+  (see <xref linkend="oauth-validator-init"/>) and the required callback for
+  performing validation (see <xref linkend="oauth-validator-callback-validate"/>).
+ </para>
+ <warning>
+  <para>
+   Since a misbehaving validator might let unauthorized users into the database,
+   correct implementation is critical. See
+   <xref linkend="oauth-validator-design"/> for design considerations.
+  </para>
+ </warning>
+
+ <sect1 id="oauth-validator-design">
+  <title>Safely Designing a Validator Module</title>
+  <warning>
+   <para>
+    Read and understand the entirety of this section before implementing a
+    validator module. A malfunctioning validator is potentially worse than no
+    authentication at all, both because of the false sense of security it
+    provides, and because it may contribute to attacks against other pieces of
+    an OAuth ecosystem.
+   </para>
+  </warning>
+
+  <sect2 id="oauth-validator-design-responsibilities">
+   <title>Validator Responsibilities</title>
+   <para>
+    TODO
+   </para>
+   <variablelist>
+    <varlistentry>
+     <term>Validate the Token</term>
+     <listitem>
+      <para>
+       The validator must first ensure that the presented token is in fact a
+       valid Bearer token for use in client authentication. The correct way to
+       do this depends on the provider, but it generally involves either
+       cryptographic operations to prove that the token was created by a trusted
+       party (offline validation), or the presentation of the token to that
+       trusted party so that it can perform validation for you (online
+       validation).
+      </para>
+      <para>
+       Online validation, usually implemented via
+       <ulink url="https://datatracker.ietf.org/doc/html/rfc7662">OAuth Token
+       Introspection</ulink>, requires fewer steps of a validator module and
+       allows central revocation of a token in the event that it is stolen
+       or misissued. However, it does require the module to make at least one
+       network call per authentication attempt (all of which must complete
+       within the configured <xref linkend="guc-authentication-timeout"/>).
+       Additionally, your provider may not provide introspection endpoints for
+       use by external resource servers.
+      </para>
+      <para>
+       Offline validation is much more involved, typically requiring a validator
+       to maintain a list of trusted signing keys for a provider and then
+       check the token's cryptographic signature along with its contents.
+       Implementations must follow the provider's instructions to the letter,
+       including any verification of issuer ("where is this token from?"),
+       audience ("who is this token for?"), and validity period ("when can this
+       token be used?"). Since there is no communication between the module and
+       the provider, tokens cannot be centrally revoked using this method;
+       offline validator implementations may wish to place restrictions on the
+       maximum length of a token's validity period.
+      </para>
+      <para>
+       If the token cannot be validated, the module should immediately fail.
+       Further authentication/authorization is pointless if the bearer token
+       wasn't issued by a trusted party.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Authorize the Client</term>
+     <listitem>
+      <para>
+       Next the validator must ensure that the end user has given the client
+       permission to access the server on their behalf. This generally involves
+       checking the scopes that have been assigned to the token, to make sure
+       that they cover database access for the current HBA parameters.
+      </para>
+      <para>
+       The purpose of this step is to prevent an OAuth client from obtaining a
+       token under false pretenses. If the validator requires all tokens to
+       carry scopes that cover database access, the provider should then loudly
+       prompt the user to grant that access during the flow. This gives them the
+       opportunity to reject the request if the client isn't supposed to be
+       using their credentials to connect to databases.
+      </para>
+      <para>
+       While it is possible to establish client authorization without explicit
+       scopes by using out-of-band knowledge of the deployed architecture, doing
+       so removes the user from the loop, which prevents them from catching
+       deployment mistakes and allows any such mistakes to be exploited
+       silently. Access to the database must be tightly restricted to only
+       trusted clients
+       <footnote>
+        <para>
+         That is, "trusted" in the sense that the OAuth client and the
+         <productname>PostgreSQL</productname> server are controlled by the same
+         entity. Notably, the Device Authorization client flow supported by
+         libpq does not usually meet this bar, since it's designed for use by
+         public/untrusted clients.
+        </para>
+       </footnote>
+       if users are not prompted for additional scopes.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Authenticate the End User</term>
+     <listitem>
+      <para>
+       Finally, the validator should determine a user identifier for the token,
+       either by asking the provider for this information or by extracting it
+       from the token itself, and return that identifier to the server (which
+       will then make a final authorization decision using the HBA
+       configuration). This identifier will be available within the session via
+       <link linkend="functions-info-session-table"><function>system_user</function></link>
+       and recorded in the server logs if <xref linkend="guc-log-connections"/>
+       is enabled.
+      </para>
+      <para>
+       Different providers may record a variety of different authentication
+       information for an end user, typically referred to as
+       <emphasis>claims</emphasis>. Providers usually document which of these
+       claims are trustworthy enough to use for authorization decisions and
+       which are not. (For instance, it would probably not be wise to use an
+       end user's full name as the identifier for authentication, since many
+       providers allow users to change their display names arbitrarily.)
+       Ultimately, the choice of which claim (or combination of claims) to use
+       comes down to the provider implementation and application requirements.
+      </para>
+      <para>
+       Note that anonymous/pseudonymous login is possible as well, by enabling
+       usermap delegation; see
+       <xref linkend="oauth-validator-design-usermap-delegation"/>.
+      </para>
+     </listitem>
+    </varlistentry>
+   </variablelist>
+  </sect2>
+
+  <sect2 id="oauth-validator-design-guidelines">
+   <title>General Coding Guidelines</title>
+   <para>
+    Developers should keep the following in mind when implementing token
+    validation:
+   </para>
+   <variablelist>
+    <varlistentry>
+     <term>Token Confidentiality</term>
+     <listitem>
+      <para>
+       Modules should not write tokens, or pieces of tokens, into the server
+       log. This is true even if the module considers the token invalid; an
+       attacker who confuses a client into communicating with the wrong provider
+       should not be able to retrieve that (otherwise valid) token from the
+       disk.
+      </para>
+      <para>
+       Implementations that send tokens over the network (for example, to
+       perform online token validation with a provider) must authenticate the
+       peer and ensure that strong transport security is in use.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Logging</term>
+     <listitem>
+      <para>
+       Modules may use the same <link linkend="error-message-reporting">logging
+       facilities</link> as standard extensions; however, the rules for emitting
+       log entries to the client are subtly different during the authentication
+       phase of the connection. Generally speaking, modules should log
+       verification problems at the <symbol>COMMERROR</symbol> level and return
+       normally, instead of using <symbol>ERROR</symbol>/<symbol>FATAL</symbol>
+       to unwind the stack, to avoid leaking information to unauthenticated
+       clients.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Interruptibility</term>
+     <listitem>
+      <para>
+       Modules must remain interruptible by signals so that the server can
+       correctly handle authentication timeouts and shutdown signals from
+       <application>pg_ctl</application>. For example, a module receiving
+       <symbol>EINTR</symbol>/<symbol>EAGAIN</symbol> from a blocking call
+       should call <function>CHECK_FOR_INTERRUPTS()</function> before retrying.
+       The same should be done during any long-running loops. Failure to follow
+       this guidance may result in unresponsive backend sessions.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Testing</term>
+     <listitem>
+      <para>
+       The breadth of testing an OAuth system is well beyond the scope of this
+       documentation, but at minimum, negative testing should be considered
+       mandatory. It's trivial to design a module that lets authorized users in;
+       the whole point of the system is to keep unauthorized users out.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Documentation</term>
+     <listitem>
+      <para>
+       Validator implementations should document the contents and format of the
+       authenticated ID that is reported to the server for each end user, since
+       DBAs may need to use this information to construct pg_ident maps. (For
+       instance, is it an email address? an organizational ID number? a UUID?)
+       They should also document whether or not it is safe to use the module in
+       <symbol>delegate_ident_mapping=1</symbol> mode, and what additional
+       configuration is required in order to do so.
+      </para>
+     </listitem>
+    </varlistentry>
+   </variablelist>
+  </sect2>
+
+  <sect2 id="oauth-validator-design-usermap-delegation">
+   <title>Authorizing Users (Usermap Delegation)</title>
+   <para>
+    The standard deliverable of a validation module is the user identifier,
+    which the server will then compare to any configured
+    <link linkend="auth-username-maps"><filename>pg_ident.conf</filename>
+    mappings</link> and determine whether the end user is authorized to connect.
+    However, OAuth is itself an authorization framework, and tokens may carry
+    information about user privileges. For example, a token may be associated
+    with the organizational groups that a user belongs to, or list the roles
+    that a user may assume, and duplicating that knowledge into local usermaps
+    for every server may not be desirable.
+   </para>
+   <para>
+    To bypass username mapping entirely, and have the validator module assume
+    the additional responsibility of authorizing user connections, the HBA may
+    be configured with <xref linkend="auth-oauth-delegate-ident-mapping"/>.
+    The module may then use token scopes or an equivalent method to decide
+    whether the user is allowed to connect under their desired role. The user
+    identifier will still be recorded by the server, but it plays no part in
+    determining whether to continue the connection.
+   </para>
+   <para>
+    Using this scheme, authentication itself is optional. As long as the module
+    reports that the connection is authorized, login will continue even if there
+    is no recorded user identifier at all. This makes it possible to implement
+    anonymous or pseudonymous access to the database, where the third-party
+    provider performs all necessary authentication but does not provide any
+    user-identifying information to the server. (Some providers may create an
+    anonymized ID number that can be recorded instead, for later auditing.)
+   </para>
+   <para>
+    Usermap delegation provides the most architectural flexibility, but it turns
+    the validator module into a single point of failure for connection
+    authorization. Use with caution.
+   </para>
+  </sect2>
+ </sect1>
+
+ <sect1 id="oauth-validator-init">
+  <title>Initialization Functions</title>
+  <indexterm zone="oauth-validator-init">
+   <primary>_PG_oauth_validator_module_init</primary>
+  </indexterm>
+  <para>
+   An OAuth validator module is loaded by dynamically loading one of the shared
+   libraries listed in <xref linkend="guc-oauth-validator-libraries"/>.
+   The normal library search path is used to locate the library. To
+   provide the validator callbacks and to indicate that the library is an OAuth
+   validator module a function named
+   <function>_PG_oauth_validator_module_init</function> must be provided. The
+   return value of the function must be a pointer to a struct of type
+   <structname>OAuthValidatorCallbacks</structname>, which contains pointers to
+   the module's token validation functions. The returned
+   pointer must be of server lifetime, which is typically achieved by defining
+   it as a <literal>static const</literal> variable in global scope.
+<programlisting>
+typedef struct OAuthValidatorCallbacks
+{
+    ValidatorStartupCB startup_cb;
+    ValidatorShutdownCB shutdown_cb;
+    ValidatorValidateCB validate_cb;
+} OAuthValidatorCallbacks;
+
+typedef const OAuthValidatorCallbacks *(*OAuthValidatorModuleInit) (void);
+</programlisting>
+
+   Only the <function>validate_cb</function> callback is required, the others
+   are optional.
+  </para>
+ </sect1>
+
+ <sect1 id="oauth-validator-callbacks">
+  <title>OAuth Validator Callbacks</title>
+  <para>
+   OAuth validator modules implement their functionality by defining a set of
+   callbacks. The server will call them as required to process the
+   authentication request from the user.
+  </para>
+
+  <sect2 id="oauth-validator-callback-startup">
+   <title>Startup Callback</title>
+   <para>
+    The <function>startup_cb</function> callback is executed directly after
+    loading the module. This callback can be used to set up local state and
+    perform additional initialization if required. If the validator module
+    has state it can use <structfield>state->private_data</structfield> to
+    store it.
+
+<programlisting>
+typedef void (*ValidatorStartupCB) (ValidatorModuleState *state);
+</programlisting>
+   </para>
+  </sect2>
+
+  <sect2 id="oauth-validator-callback-validate">
+   <title>Validate Callback</title>
+   <para>
+    The <function>validate_cb</function> callback is executed during the OAuth
+    exchange when a user attempts to authenticate using OAuth.  Any state set in
+    previous calls will be available in <structfield>state->private_data</structfield>.
+
+<programlisting>
+typedef ValidatorModuleResult *(*ValidatorValidateCB) (ValidatorModuleState *state, const char *token, const char *role);
+</programlisting>
+
+    <replaceable>token</replaceable> will contain the bearer token to validate.
+    The server has ensured that the token is well-formed syntactically, but no
+    other validation has been performed.  <replaceable>role</replaceable> will
+    contain the role the user has requested to log in as.  The callback must
+    return a palloc'd <literal>ValidatorModuleResult</literal> struct, which is
+    defined as below:
+
+<programlisting>
+typedef struct ValidatorModuleResult
+{
+    bool        authorized;
+    char       *authn_id;
+} ValidatorModuleResult;
+</programlisting>
+
+    The connection will only proceed if the module sets
+    <structfield>authorized</structfield> to <literal>true</literal>.  To
+    authenticate the user, the authenticated user name (as determined using the
+    token) shall be palloc'd and returned in the <structfield>authn_id</structfield>
+    field.  Alternatively, <structfield>authn_id</structfield> may be set to
+    NULL if the token is valid but the associated user identity cannot be
+    determined.
+   </para>
+   <para>
+    The caller assumes ownership of the returned memory allocation, the
+    validator module should not in any way access the memory after it has been
+    returned.  A validator may instead return NULL to signal an internal
+    error.
+   </para>
+   <para>
+    The behavior after <function>validate_cb</function> returns depends on the
+    specific HBA setup.  Normally, the <structfield>authn_id</structfield> user
+    name must exactly match the role that the user is logging in as.  (This
+    behavior may be modified with a usermap.)  But when authenticating against
+    an HBA rule with <literal>trust_validator_authz</literal> turned on, the
+    server will not perform any checks on the value of
+    <structfield>authn_id</structfield> at all; in this case it is up to the
+    validator to ensure that the token carries enough privileges for the user to
+    log in under the indicated <replaceable>role</replaceable>.
+   </para>
+  </sect2>
+
+  <sect2 id="oauth-validator-callback-shutdown">
+   <title>Shutdown Callback</title>
+   <para>
+    The <function>shutdown_cb</function> callback is executed when the backend
+    process associated with the connection exits. If the validator module has
+    any state, this callback should free it to avoid resource leaks.
+<programlisting>
+typedef void (*ValidatorShutdownCB) (ValidatorModuleState *state);
+</programlisting>
+   </para>
+  </sect2>
+
+ </sect1>
+</chapter>
diff --git a/doc/src/sgml/postgres.sgml b/doc/src/sgml/postgres.sgml
index 7be25c58507..af476c82fcc 100644
--- a/doc/src/sgml/postgres.sgml
+++ b/doc/src/sgml/postgres.sgml
@@ -229,6 +229,7 @@ break is not needed in a wider output rendering.
   &logicaldecoding;
   &replication-origins;
   &archive-modules;
+  &oauth-validators;
 
  </part>
 
diff --git a/doc/src/sgml/protocol.sgml b/doc/src/sgml/protocol.sgml
index fb5dec1172e..3bd9e68e6ce 100644
--- a/doc/src/sgml/protocol.sgml
+++ b/doc/src/sgml/protocol.sgml
@@ -1688,11 +1688,11 @@ SELCT 1/0;<!-- this typo is intentional -->
 
   <para>
    <firstterm>SASL</firstterm> is a framework for authentication in connection-oriented
-   protocols. At the moment, <productname>PostgreSQL</productname> implements two SASL
-   authentication mechanisms, SCRAM-SHA-256 and SCRAM-SHA-256-PLUS. More
-   might be added in the future. The below steps illustrate how SASL
-   authentication is performed in general, while the next subsection gives
-   more details on SCRAM-SHA-256 and SCRAM-SHA-256-PLUS.
+   protocols. At the moment, <productname>PostgreSQL</productname> implements three
+   SASL authentication mechanisms: SCRAM-SHA-256, SCRAM-SHA-256-PLUS, and
+   OAUTHBEARER. More might be added in the future. The below steps illustrate how SASL
+   authentication is performed in general, while the next subsections give
+   more details on particular mechanisms.
   </para>
 
   <procedure>
@@ -1727,7 +1727,7 @@ SELCT 1/0;<!-- this typo is intentional -->
    <step id="sasl-auth-end">
     <para>
      Finally, when the authentication exchange is completed successfully, the
-     server sends an AuthenticationSASLFinal message, followed
+     server sends an optional AuthenticationSASLFinal message, followed
      immediately by an AuthenticationOk message. The AuthenticationSASLFinal
      contains additional server-to-client data, whose content is particular to the
      selected authentication mechanism. If the authentication mechanism doesn't
@@ -1746,9 +1746,9 @@ SELCT 1/0;<!-- this typo is intentional -->
    <title>SCRAM-SHA-256 Authentication</title>
 
    <para>
-    The implemented SASL mechanisms at the moment
-    are <literal>SCRAM-SHA-256</literal> and its variant with channel
-    binding <literal>SCRAM-SHA-256-PLUS</literal>. They are described in
+    <literal>SCRAM-SHA-256</literal>, and its variant with channel
+    binding <literal>SCRAM-SHA-256-PLUS</literal>, are password-based
+    authentication mechanisms. They are described in
     detail in <ulink url="https://datatracker.ietf.org/doc/html/rfc7677">RFC 7677</ulink>
     and <ulink url="https://datatracker.ietf.org/doc/html/rfc5802">RFC 5802</ulink>.
    </para>
@@ -1850,6 +1850,121 @@ SELCT 1/0;<!-- this typo is intentional -->
     </step>
    </procedure>
   </sect2>
+
+  <sect2 id="sasl-oauthbearer">
+   <title>OAUTHBEARER Authentication</title>
+
+   <para>
+    <literal>OAUTHBEARER</literal> is a token-based mechanism for federated
+    authentication. It is described in detail in
+    <ulink url="https://datatracker.ietf.org/doc/html/rfc7628">RFC 7628</ulink>.
+   </para>
+
+   <para>
+    A typical exchange differs depending on whether or not the client already
+    has a bearer token cached for the current user. If it does not, the exchange
+    will take place over two connections: the first "discovery" connection to
+    obtain OAuth metadata from the server, and the second connection to send
+    the token after the client has obtained it. (libpq does not currently
+    implement a caching method as part of its builtin flow, so it uses the
+    two-connection exchange.)
+   </para>
+
+   <para>
+    This mechanism is client-initiated, like SCRAM. The client initial response
+    consists of the standard "GS2" header used by SCRAM, followed by a list of
+    <literal>key=value</literal> pairs. The only key currently supported by
+    the server is <literal>auth</literal>, which contains the bearer token.
+    <literal>OAUTHBEARER</literal> additionally specifies three optional
+    components of the client initial response (the <literal>authzid</literal> of
+    the GS2 header, and the <structfield>host</structfield> and
+    <structfield>port</structfield> keys) which are currently ignored by the
+    server.
+   </para>
+
+   <para>
+    <literal>OAUTHBEARER</literal> does not support channel binding, and there
+    is no "OAUTHBEARER-PLUS" mechanism. This mechanism does not make use of
+    server data during a successful authentication, so the
+    AuthenticationSASLFinal message is not used in the exchange.
+   </para>
+
+   <procedure>
+    <title>Example</title>
+    <step>
+     <para>
+      During the first exchange, the server sends an AuthenticationSASL message
+      with the <literal>OAUTHBEARER</literal> mechanism advertised.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      The client responds by sending a SASLInitialResponse message which
+      indicates the <literal>OAUTHBEARER</literal> mechanism. Assuming the
+      client does not already have a valid bearer token for the current user,
+      the <structfield>auth</structfield> field is empty, indicating a discovery
+      connection.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      Server sends an AuthenticationSASLContinue message containing an error
+      <literal>status</literal> alongside a well-known URI and scopes that the
+      client should use to conduct an OAuth flow.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      Client sends a SASLResponse message containing the empty set (a single
+      <literal>0x01</literal> byte) to finish its half of the discovery
+      exchange.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      Server sends an ErrorMessage to fail the first exchange.
+     </para>
+     <para>
+      At this point, the client conducts one of many possible OAuth flows to
+      obtain a bearer token, using any metadata that it has been configured with
+      in addition to that provided by the server. (This description is left
+      deliberately vague; <literal>OAUTHBEARER</literal> does not specify or
+      mandate any particular method for obtaining a token.)
+     </para>
+     <para>
+      Once it has a token, the client reconnects to the server for the final
+      exchange:
+     </para>
+    </step>
+
+    <step>
+     <para>
+      The server once again sends an AuthenticationSASL message with the
+      <literal>OAUTHBEARER</literal> mechanism advertised.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      The client responds by sending a SASLInitialResponse message, but this
+      time the <structfield>auth</structfield> field in the message contains the
+      bearer token that was obtained during the client flow.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      The server validates the token according to the instructions of the
+      token provider. If the client is authorized to connect, it sends an
+      AuthenticationOk message to end the SASL exchange.
+     </para>
+    </step>
+   </procedure>
+  </sect2>
  </sect1>
 
  <sect1 id="protocol-replication">
diff --git a/doc/src/sgml/regress.sgml b/doc/src/sgml/regress.sgml
index 7c474559bdf..0e5e8e8f309 100644
--- a/doc/src/sgml/regress.sgml
+++ b/doc/src/sgml/regress.sgml
@@ -347,6 +347,16 @@ make check-world PG_TEST_EXTRA='kerberos ldap ssl load_balance libpq_encryption'
       </para>
      </listitem>
     </varlistentry>
+
+    <varlistentry>
+     <term><literal>oauth</literal></term>
+     <listitem>
+      <para>
+       Runs the test suite under <filename>src/test/modules/oauth_validator</filename>.
+       This opens TCP/IP listen sockets for a test-server running HTTPS.
+      </para>
+     </listitem>
+    </varlistentry>
    </variablelist>
 
    Tests for features that are not supported by the current build
diff --git a/meson.build b/meson.build
index 1ceadb9a830..80a6b1d57d6 100644
--- a/meson.build
+++ b/meson.build
@@ -854,6 +854,67 @@ endif
 
 
 
+###############################################################
+# Library: libcurl
+###############################################################
+
+libcurlopt = get_option('libcurl')
+if not libcurlopt.disabled()
+  # Check for libcurl 7.61.0 or higher (corresponding to RHEL8 and the ability
+  # to explicitly set TLS 1.3 ciphersuites).
+  libcurl = dependency('libcurl', version: '>= 7.61.0', required: libcurlopt)
+  if libcurl.found()
+    cdata.set('USE_LIBCURL', 1)
+
+    # Check to see whether the current platform supports threadsafe Curl
+    # initialization.
+    libcurl_threadsafe_init = false
+
+    if not meson.is_cross_build()
+      r = cc.run('''
+        #include <curl/curl.h>
+
+        int main(void)
+        {
+            curl_version_info_data *info;
+
+            if (curl_global_init(CURL_GLOBAL_ALL))
+                return -1;
+
+            info = curl_version_info(CURLVERSION_NOW);
+        #ifdef CURL_VERSION_THREADSAFE
+            if (info->features & CURL_VERSION_THREADSAFE)
+                return 0;
+        #endif
+
+            return 1;
+        }''',
+        name: 'test for curl_global_init thread safety',
+        dependencies: libcurl,
+      )
+
+      assert(r.compiled())
+      if r.returncode() == 0
+        libcurl_threadsafe_init = true
+        message('curl_global_init is threadsafe')
+      elif r.returncode() == 1
+        message('curl_global_init is not threadsafe')
+      else
+        message('curl_global_init failed; assuming not threadsafe')
+      endif
+    endif
+
+    if libcurl_threadsafe_init
+      cdata.set('HAVE_THREADSAFE_CURL_GLOBAL_INIT', 1)
+    endif
+  endif
+
+else
+  libcurl = not_found_dep
+endif
+
+
+
 ###############################################################
 # Library: libxml
 ###############################################################
@@ -3044,6 +3105,10 @@ libpq_deps += [
 
   gssapi,
   ldap_r,
+  # XXX libcurl must link after libgssapi_krb5 on FreeBSD to avoid segfaults
+  # during gss_acquire_cred(). This is possibly related to Curl's Heimdal
+  # dependency on that platform?
+  libcurl,
   libintl,
   ssl,
 ]
@@ -3720,6 +3785,7 @@ if meson.version().version_compare('>=0.57')
       'gss': gssapi,
       'icu': icu,
       'ldap': ldap,
+      'libcurl': libcurl,
       'libxml': libxml,
       'libxslt': libxslt,
       'llvm': llvm,
diff --git a/meson_options.txt b/meson_options.txt
index d9c7ddccbc4..702c4517145 100644
--- a/meson_options.txt
+++ b/meson_options.txt
@@ -100,6 +100,9 @@ option('icu', type: 'feature', value: 'auto',
 option('ldap', type: 'feature', value: 'auto',
   description: 'LDAP support')
 
+option('libcurl', type : 'feature', value: 'auto',
+  description: 'libcurl support')
+
 option('libedit_preferred', type: 'boolean', value: false,
   description: 'Prefer BSD Libedit over GNU Readline')
 
diff --git a/src/Makefile.global.in b/src/Makefile.global.in
index bbe11e75bf0..3b620bac5ac 100644
--- a/src/Makefile.global.in
+++ b/src/Makefile.global.in
@@ -190,6 +190,7 @@ with_systemd	= @with_systemd@
 with_gssapi	= @with_gssapi@
 with_krb_srvnam	= @with_krb_srvnam@
 with_ldap	= @with_ldap@
+with_libcurl	= @with_libcurl@
 with_libxml	= @with_libxml@
 with_libxslt	= @with_libxslt@
 with_llvm	= @with_llvm@
diff --git a/src/backend/libpq/Makefile b/src/backend/libpq/Makefile
index 6d385fd6a45..98eb2a8242d 100644
--- a/src/backend/libpq/Makefile
+++ b/src/backend/libpq/Makefile
@@ -15,6 +15,7 @@ include $(top_builddir)/src/Makefile.global
 # be-fsstubs is here for historical reasons, probably belongs elsewhere
 
 OBJS = \
+	auth-oauth.o \
 	auth-sasl.o \
 	auth-scram.o \
 	auth.o \
diff --git a/src/backend/libpq/auth-oauth.c b/src/backend/libpq/auth-oauth.c
new file mode 100644
index 00000000000..6155d63a116
--- /dev/null
+++ b/src/backend/libpq/auth-oauth.c
@@ -0,0 +1,860 @@
+/*-------------------------------------------------------------------------
+ *
+ * auth-oauth.c
+ *	  Server-side implementation of the SASL OAUTHBEARER mechanism.
+ *
+ * See the following RFC for more details:
+ * - RFC 7628: https://tools.ietf.org/html/rfc7628
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/backend/libpq/auth-oauth.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include <unistd.h>
+#include <fcntl.h>
+
+#include "common/oauth-common.h"
+#include "fmgr.h"
+#include "lib/stringinfo.h"
+#include "libpq/auth.h"
+#include "libpq/hba.h"
+#include "libpq/oauth.h"
+#include "libpq/sasl.h"
+#include "storage/fd.h"
+#include "storage/ipc.h"
+#include "utils/json.h"
+#include "utils/varlena.h"
+
+/* GUC */
+char	   *oauth_validator_libraries_string = NULL;
+
+static void oauth_get_mechanisms(Port *port, StringInfo buf);
+static void *oauth_init(Port *port, const char *selected_mech, const char *shadow_pass);
+static int	oauth_exchange(void *opaq, const char *input, int inputlen,
+						   char **output, int *outputlen, const char **logdetail);
+
+static void load_validator_library(const char *libname);
+static void shutdown_validator_library(int code, Datum arg);
+
+static ValidatorModuleState *validator_module_state;
+static const OAuthValidatorCallbacks *ValidatorCallbacks;
+
+/* Mechanism declaration */
+const pg_be_sasl_mech pg_be_oauth_mech = {
+	.get_mechanisms = oauth_get_mechanisms,
+	.init = oauth_init,
+	.exchange = oauth_exchange,
+
+	.max_message_length = PG_MAX_AUTH_TOKEN_LENGTH,
+};
+
+/* Valid states for the oauth_exchange() machine. */
+enum oauth_state
+{
+	OAUTH_STATE_INIT = 0,
+	OAUTH_STATE_ERROR,
+	OAUTH_STATE_FINISHED,
+};
+
+/* Mechanism callback state. */
+struct oauth_ctx
+{
+	enum oauth_state state;
+	Port	   *port;
+	const char *issuer;
+	const char *scope;
+};
+
+static char *sanitize_char(char c);
+static char *parse_kvpairs_for_auth(char **input);
+static void generate_error_response(struct oauth_ctx *ctx, char **output, int *outputlen);
+static bool validate(Port *port, const char *auth);
+
+/* Constants seen in an OAUTHBEARER client initial response. */
+#define KVSEP 0x01				/* separator byte for key/value pairs */
+#define AUTH_KEY "auth"			/* key containing the Authorization header */
+#define BEARER_SCHEME "Bearer " /* required header scheme (case-insensitive!) */
+
+/*
+ * Retrieves the OAUTHBEARER mechanism list (currently a single item).
+ *
+ * For a full description of the API, see libpq/sasl.h.
+ */
+static void
+oauth_get_mechanisms(Port *port, StringInfo buf)
+{
+	/* Only OAUTHBEARER is supported. */
+	appendStringInfoString(buf, OAUTHBEARER_NAME);
+	appendStringInfoChar(buf, '\0');
+}
+
+/*
+ * Initializes mechanism state and loads the configured validator module.
+ *
+ * For a full description of the API, see libpq/sasl.h.
+ */
+static void *
+oauth_init(Port *port, const char *selected_mech, const char *shadow_pass)
+{
+	struct oauth_ctx *ctx;
+
+	if (strcmp(selected_mech, OAUTHBEARER_NAME) != 0)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("client selected an invalid SASL authentication mechanism"));
+
+	ctx = palloc0(sizeof(*ctx));
+
+	ctx->state = OAUTH_STATE_INIT;
+	ctx->port = port;
+
+	Assert(port->hba);
+	ctx->issuer = port->hba->oauth_issuer;
+	ctx->scope = port->hba->oauth_scope;
+
+	load_validator_library(port->hba->oauth_validator);
+
+	return ctx;
+}
+
+/*
+ * Implements the OAUTHBEARER SASL exchange (RFC 7628, Sec. 3.2). This pulls
+ * apart the client initial response and validates the Bearer token. It also
+ * handles the dummy error response for a failed handshake, as described in
+ * Sec. 3.2.3.
+ *
+ * For a full description of the API, see libpq/sasl.h.
+ */
+static int
+oauth_exchange(void *opaq, const char *input, int inputlen,
+			   char **output, int *outputlen, const char **logdetail)
+{
+	char	   *input_copy;
+	char	   *p;
+	char		cbind_flag;
+	char	   *auth;
+	int			status;
+
+	struct oauth_ctx *ctx = opaq;
+
+	*output = NULL;
+	*outputlen = -1;
+
+	/*
+	 * If the client didn't include an "Initial Client Response" in the
+	 * SASLInitialResponse message, send an empty challenge, to which the
+	 * client will respond with the same data that usually comes in the
+	 * Initial Client Response.
+	 */
+	if (input == NULL)
+	{
+		Assert(ctx->state == OAUTH_STATE_INIT);
+
+		*output = pstrdup("");
+		*outputlen = 0;
+		return PG_SASL_EXCHANGE_CONTINUE;
+	}
+
+	/*
+	 * Check that the input length agrees with the string length of the input.
+	 */
+	if (inputlen == 0)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("The message is empty."));
+	if (inputlen != strlen(input))
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message length does not match input length."));
+
+	switch (ctx->state)
+	{
+		case OAUTH_STATE_INIT:
+			/* Handle this case below. */
+			break;
+
+		case OAUTH_STATE_ERROR:
+
+			/*
+			 * Only one response is valid for the client during authentication
+			 * failure: a single kvsep.
+			 */
+			if (inputlen != 1 || *input != KVSEP)
+				ereport(ERROR,
+						errcode(ERRCODE_PROTOCOL_VIOLATION),
+						errmsg("malformed OAUTHBEARER message"),
+						errdetail("Client did not send a kvsep response."));
+
+			/* The (failed) handshake is now complete. */
+			ctx->state = OAUTH_STATE_FINISHED;
+			return PG_SASL_EXCHANGE_FAILURE;
+
+		default:
+			elog(ERROR, "invalid OAUTHBEARER exchange state");
+			return PG_SASL_EXCHANGE_FAILURE;
+	}
+
+	/* Handle the client's initial message. */
+	p = input_copy = pstrdup(input);
+
+	/*
+	 * OAUTHBEARER does not currently define a channel binding (so there is no
+	 * OAUTHBEARER-PLUS, and we do not accept a 'p' specifier). We accept a
+	 * 'y' specifier purely for the remote chance that a future specification
+	 * could define one; then future clients can still interoperate with this
+	 * server implementation. 'n' is the expected case.
+	 */
+	cbind_flag = *p;
+	switch (cbind_flag)
+	{
+		case 'p':
+			ereport(ERROR,
+					errcode(ERRCODE_PROTOCOL_VIOLATION),
+					errmsg("malformed OAUTHBEARER message"),
+					errdetail("The server does not support channel binding for OAuth, but the client message includes channel binding data."));
+			break;
+
+		case 'y':				/* fall through */
+		case 'n':
+			p++;
+			if (*p != ',')
+				ereport(ERROR,
+						errcode(ERRCODE_PROTOCOL_VIOLATION),
+						errmsg("malformed OAUTHBEARER message"),
+						errdetail("Comma expected, but found character \"%s\".",
+								  sanitize_char(*p)));
+			p++;
+			break;
+
+		default:
+			ereport(ERROR,
+					errcode(ERRCODE_PROTOCOL_VIOLATION),
+					errmsg("malformed OAUTHBEARER message"),
+					errdetail("Unexpected channel-binding flag \"%s\".",
+							  sanitize_char(cbind_flag)));
+	}
+
+	/*
+	 * Forbid optional authzid (authorization identity).  We don't support it.
+	 */
+	if (*p == 'a')
+		ereport(ERROR,
+				errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				errmsg("client uses authorization identity, but it is not supported"));
+	if (*p != ',')
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Unexpected attribute \"%s\" in client-first-message.",
+						  sanitize_char(*p)));
+	p++;
+
+	/* All remaining fields are separated by the RFC's kvsep (\x01). */
+	if (*p != KVSEP)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Key-value separator expected, but found character \"%s\".",
+						  sanitize_char(*p)));
+	p++;
+
+	auth = parse_kvpairs_for_auth(&p);
+	if (!auth)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message does not contain an auth value."));
+
+	/* We should be at the end of our message. */
+	if (*p)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message contains additional data after the final terminator."));
+
+	if (!validate(ctx->port, auth))
+	{
+		generate_error_response(ctx, output, outputlen);
+
+		ctx->state = OAUTH_STATE_ERROR;
+		status = PG_SASL_EXCHANGE_CONTINUE;
+	}
+	else
+	{
+		ctx->state = OAUTH_STATE_FINISHED;
+		status = PG_SASL_EXCHANGE_SUCCESS;
+	}
+
+	/* Don't let extra copies of the bearer token hang around. */
+	explicit_bzero(input_copy, inputlen);
+
+	return status;
+}
+
+/*
+ * Convert an arbitrary byte to printable form.  For error messages.
+ *
+ * If it's a printable ASCII character, print it as a single character.
+ * otherwise, print it in hex.
+ *
+ * The returned pointer points to a static buffer.
+ */
+static char *
+sanitize_char(char c)
+{
+	static char buf[5];
+
+	if (c >= 0x21 && c <= 0x7E)
+		snprintf(buf, sizeof(buf), "'%c'", c);
+	else
+		snprintf(buf, sizeof(buf), "0x%02x", (unsigned char) c);
+	return buf;
+}
+
+/*
+ * Performs syntactic validation of a key and value from the initial client
+ * response. (Semantic validation of interesting values must be performed
+ * later.)
+ */
+static void
+validate_kvpair(const char *key, const char *val)
+{
+	/*-----
+	 * From Sec 3.1:
+	 *     key            = 1*(ALPHA)
+	 */
+	static const char *key_allowed_set =
+		"abcdefghijklmnopqrstuvwxyz"
+		"ABCDEFGHIJKLMNOPQRSTUVWXYZ";
+
+	size_t		span;
+
+	if (!key[0])
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message contains an empty key name."));
+
+	span = strspn(key, key_allowed_set);
+	if (key[span] != '\0')
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message contains an invalid key name."));
+
+	/*-----
+	 * From Sec 3.1:
+	 *     value          = *(VCHAR / SP / HTAB / CR / LF )
+	 *
+	 * The VCHAR (visible character) class is large; a loop is more
+	 * straightforward than strspn().
+	 */
+	for (; *val; ++val)
+	{
+		if (0x21 <= *val && *val <= 0x7E)
+			continue;			/* VCHAR */
+
+		switch (*val)
+		{
+			case ' ':
+			case '\t':
+			case '\r':
+			case '\n':
+				continue;		/* SP, HTAB, CR, LF */
+
+			default:
+				ereport(ERROR,
+						errcode(ERRCODE_PROTOCOL_VIOLATION),
+						errmsg("malformed OAUTHBEARER message"),
+						errdetail("Message contains an invalid value."));
+		}
+	}
+}
+
+/*
+ * Consumes all kvpairs in an OAUTHBEARER exchange message. If the "auth" key is
+ * found, its value is returned.
+ */
+static char *
+parse_kvpairs_for_auth(char **input)
+{
+	char	   *pos = *input;
+	char	   *auth = NULL;
+
+	/*----
+	 * The relevant ABNF, from Sec. 3.1:
+	 *
+	 *     kvsep          = %x01
+	 *     key            = 1*(ALPHA)
+	 *     value          = *(VCHAR / SP / HTAB / CR / LF )
+	 *     kvpair         = key "=" value kvsep
+	 *   ;;gs2-header     = See RFC 5801
+	 *     client-resp    = (gs2-header kvsep *kvpair kvsep) / kvsep
+	 *
+	 * By the time we reach this code, the gs2-header and initial kvsep have
+	 * already been validated. We start at the beginning of the first kvpair.
+	 */
+
+	while (*pos)
+	{
+		char	   *end;
+		char	   *sep;
+		char	   *key;
+		char	   *value;
+
+		/*
+		 * Find the end of this kvpair. Note that input is null-terminated by
+		 * the SASL code, so the strchr() is bounded.
+		 */
+		end = strchr(pos, KVSEP);
+		if (!end)
+			ereport(ERROR,
+					errcode(ERRCODE_PROTOCOL_VIOLATION),
+					errmsg("malformed OAUTHBEARER message"),
+					errdetail("Message contains an unterminated key/value pair."));
+		*end = '\0';
+
+		if (pos == end)
+		{
+			/* Empty kvpair, signifying the end of the list. */
+			*input = pos + 1;
+			return auth;
+		}
+
+		/*
+		 * Find the end of the key name.
+		 */
+		sep = strchr(pos, '=');
+		if (!sep)
+			ereport(ERROR,
+					errcode(ERRCODE_PROTOCOL_VIOLATION),
+					errmsg("malformed OAUTHBEARER message"),
+					errdetail("Message contains a key without a value."));
+		*sep = '\0';
+
+		/* Both key and value are now safely terminated. */
+		key = pos;
+		value = sep + 1;
+		validate_kvpair(key, value);
+
+		if (strcmp(key, AUTH_KEY) == 0)
+		{
+			if (auth)
+				ereport(ERROR,
+						errcode(ERRCODE_PROTOCOL_VIOLATION),
+						errmsg("malformed OAUTHBEARER message"),
+						errdetail("Message contains multiple auth values."));
+
+			auth = value;
+		}
+		else
+		{
+			/*
+			 * The RFC also defines the host and port keys, but they are not
+			 * required for OAUTHBEARER and we do not use them. Also, per Sec.
+			 * 3.1, any key/value pairs we don't recognize must be ignored.
+			 */
+		}
+
+		/* Move to the next pair. */
+		pos = end + 1;
+	}
+
+	ereport(ERROR,
+			errcode(ERRCODE_PROTOCOL_VIOLATION),
+			errmsg("malformed OAUTHBEARER message"),
+			errdetail("Message did not contain a final terminator."));
+
+	pg_unreachable();
+	return NULL;
+}
+
+/*
+ * Builds the JSON response for failed authentication (RFC 7628, Sec. 3.2.2).
+ * This contains the required scopes for entry and a pointer to the OAuth/OpenID
+ * discovery document, which the client may use to conduct its OAuth flow.
+ */
+static void
+generate_error_response(struct oauth_ctx *ctx, char **output, int *outputlen)
+{
+	StringInfoData buf;
+	StringInfoData issuer;
+
+	/*
+	 * The admin needs to set an issuer and scope for OAuth to work. There's
+	 * not really a way to hide this from the user, either, because we can't
+	 * choose a "default" issuer, so be honest in the failure message.
+	 *
+	 * TODO: see if there's a better place to fail, earlier than this.
+	 */
+	if (!ctx->issuer || !ctx->scope)
+		ereport(FATAL,
+				errcode(ERRCODE_INTERNAL_ERROR),
+				errmsg("OAuth is not properly configured for this user"),
+				errdetail_log("The issuer and scope parameters must be set in pg_hba.conf."));
+
+	/*
+	 * Build a default .well-known URI based on our issuer, unless the HBA has
+	 * already provided one.
+	 */
+	initStringInfo(&issuer);
+	appendStringInfoString(&issuer, ctx->issuer);
+	if (strstr(ctx->issuer, "/.well-known/") == NULL)
+		appendStringInfoString(&issuer, "/.well-known/openid-configuration");
+
+	initStringInfo(&buf);
+
+	/*
+	 * Escaping the string here is belt-and-suspenders defensive programming
+	 * since escapable characters aren't valid in either the issuer URI or the
+	 * scope list, but the HBA doesn't enforce that yet.
+	 */
+	appendStringInfoString(&buf, "{ \"status\": \"invalid_token\", ");
+
+	appendStringInfoString(&buf, "\"openid-configuration\": ");
+	escape_json(&buf, issuer.data);
+	pfree(issuer.data);
+
+	appendStringInfoString(&buf, ", \"scope\": ");
+	escape_json(&buf, ctx->scope);
+
+	appendStringInfoString(&buf, " }");
+
+	*output = buf.data;
+	*outputlen = buf.len;
+}
+
+/*-----
+ * Validates the provided Authorization header and returns the token from
+ * within it. NULL is returned on validation failure.
+ *
+ * Only Bearer tokens are accepted. The ABNF is defined in RFC 6750, Sec.
+ * 2.1:
+ *
+ *      b64token    = 1*( ALPHA / DIGIT /
+ *                        "-" / "." / "_" / "~" / "+" / "/" ) *"="
+ *      credentials = "Bearer" 1*SP b64token
+ *
+ * The "credentials" construction is what we receive in our auth value.
+ *
+ * Since that spec is subordinate to HTTP (i.e. the HTTP Authorization
+ * header format; RFC 9110 Sec. 11), the "Bearer" scheme string must be
+ * compared case-insensitively. (This is not mentioned in RFC 6750, but the
+ * OAUTHBEARER spec points it out: RFC 7628 Sec. 4.)
+ *
+ * Invalid formats are technically a protocol violation, but we shouldn't
+ * reflect any information about the sensitive Bearer token back to the
+ * client; log at COMMERROR instead.
+ */
+static const char *
+validate_token_format(const char *header)
+{
+	size_t		span;
+	const char *token;
+	static const char *const b64token_allowed_set =
+		"abcdefghijklmnopqrstuvwxyz"
+		"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+		"0123456789-._~+/";
+
+	/* Missing auth headers should be handled by the caller. */
+	Assert(header);
+
+	if (header[0] == '\0')
+	{
+		/*
+		 * A completely empty auth header represents a query for
+		 * authentication parameters. The client expects it to fail; there's
+		 * no need to make any extra noise in the logs.
+		 *
+		 * TODO: should we find a way to return STATUS_EOF at the top level,
+		 * to suppress the authentication error entirely?
+		 */
+		return NULL;
+	}
+
+	if (pg_strncasecmp(header, BEARER_SCHEME, strlen(BEARER_SCHEME)))
+	{
+		ereport(COMMERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAuth bearer token"),
+				errdetail_log("Client response indicated a non-Bearer authentication scheme."));
+		return NULL;
+	}
+
+	/* Pull the bearer token out of the auth value. */
+	token = header + strlen(BEARER_SCHEME);
+
+	/* Swallow any additional spaces. */
+	while (*token == ' ')
+		token++;
+
+	/* Tokens must not be empty. */
+	if (!*token)
+	{
+		ereport(COMMERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAuth bearer token"),
+				errdetail_log("Bearer token is empty."));
+		return NULL;
+	}
+
+	/*
+	 * Make sure the token contains only allowed characters. Tokens may end
+	 * with any number of '=' characters.
+	 */
+	span = strspn(token, b64token_allowed_set);
+	while (token[span] == '=')
+		span++;
+
+	if (token[span] != '\0')
+	{
+		/*
+		 * This error message could be more helpful by printing the
+		 * problematic character(s), but that'd be a bit like printing a piece
+		 * of someone's password into the logs.
+		 */
+		ereport(COMMERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAuth bearer token"),
+				errdetail_log("Bearer token is not in the correct format."));
+		return NULL;
+	}
+
+	return token;
+}
+
+/*
+ * Checks that the "auth" kvpair in the client response contains a syntactically
+ * valid Bearer token, then passes it along to the loaded validator module for
+ * authorization. Returns true if validation succeeds.
+ */
+static bool
+validate(Port *port, const char *auth)
+{
+	int			map_status;
+	ValidatorModuleResult *ret;
+	const char *token;
+	bool		status;
+
+	/* Ensure that we have a correct token to validate */
+	if (!(token = validate_token_format(auth)))
+		return false;
+
+	/*
+	 * Ensure that we have a validation library loaded, this should always be
+	 * the case and an error here is indicative of a bug.
+	 */
+	if (!ValidatorCallbacks || !ValidatorCallbacks->validate_cb)
+		ereport(FATAL,
+				errcode(ERRCODE_INTERNAL_ERROR),
+				errmsg("validation of OAuth token requested without a validator loaded"));
+
+	/* Call the validation function from the validator module */
+	ret = ValidatorCallbacks->validate_cb(validator_module_state,
+										  token, port->user_name);
+	if (ret == NULL)
+	{
+		ereport(LOG, errmsg("internal error in OAuth validator module"));
+		return false;
+	}
+
+	/*
+	 * Log any authentication results even if the token isn't authorized; it
+	 * might be useful for auditing or troubleshooting.
+	 */
+	if (ret->authn_id)
+		set_authn_id(port, ret->authn_id);
+
+	if (!ret->authorized)
+	{
+		ereport(LOG,
+				errmsg("OAuth bearer authentication failed for user \"%s\"",
+					   port->user_name),
+				errdetail_log("Validator failed to authorize the provided token."));
+
+		status = false;
+		goto cleanup;
+	}
+
+	if (port->hba->oauth_skip_usermap)
+	{
+		/*
+		 * If the validator is our authorization authority, we're done.
+		 * Authentication may or may not have been performed depending on the
+		 * validator implementation; all that matters is that the validator
+		 * says the user can log in with the target role.
+		 */
+		status = true;
+		goto cleanup;
+	}
+
+	/* Make sure the validator authenticated the user. */
+	if (ret->authn_id == NULL || ret->authn_id[0] == '\0')
+	{
+		ereport(LOG,
+				errmsg("OAuth bearer authentication failed for user \"%s\"",
+					   port->user_name),
+				errdetail_log("Validator provided no identity."));
+
+		status = false;
+		goto cleanup;
+	}
+
+	/* Finally, check the user map. */
+	map_status = check_usermap(port->hba->usermap, port->user_name,
+							   MyClientConnectionInfo.authn_id, false);
+	status = (map_status == STATUS_OK);
+
+cleanup:
+
+	/*
+	 * Clear and free the validation result from the validator module once
+	 * we're done with it.
+	 */
+	if (ret->authn_id != NULL)
+		pfree(ret->authn_id);
+	pfree(ret);
+
+	return status;
+}
+
+/*
+ * load_validator_library
+ *
+ * Load the configured validator library in order to perform token validation.
+ * There is no built-in fallback since validation is implementation specific. If
+ * no validator library is configured, or if it fails to load, then error out
+ * since token validation won't be possible.
+ */
+static void
+load_validator_library(const char *libname)
+{
+	OAuthValidatorModuleInit validator_init;
+
+	Assert(libname && *libname);
+
+	validator_init = (OAuthValidatorModuleInit)
+		load_external_function(libname, "_PG_oauth_validator_module_init",
+							   false, NULL);
+
+	/*
+	 * The validator init function is required since it will set the callbacks
+	 * for the validator library.
+	 */
+	if (validator_init == NULL)
+		ereport(ERROR,
+				errmsg("%s module \"%s\" must define the symbol %s",
+					   "OAuth validator", libname, "_PG_oauth_validator_module_init"));
+
+	ValidatorCallbacks = (*validator_init) ();
+	Assert(ValidatorCallbacks);
+
+	/* Allocate memory for validator library private state data */
+	validator_module_state = (ValidatorModuleState *) palloc0(sizeof(ValidatorModuleState));
+	if (ValidatorCallbacks->startup_cb != NULL)
+		ValidatorCallbacks->startup_cb(validator_module_state);
+
+	before_shmem_exit(shutdown_validator_library, 0);
+}
+
+/*
+ * Call the validator module's shutdown callback, if one is provided. This is
+ * invoked via before_shmem_exit().
+ */
+static void
+shutdown_validator_library(int code, Datum arg)
+{
+	if (ValidatorCallbacks->shutdown_cb != NULL)
+		ValidatorCallbacks->shutdown_cb(validator_module_state);
+}
+
+/*
+ * Ensure an OAuth validator named in the HBA is permitted by the configuration.
+ *
+ * If the validator is currently unset and exactly one library is declared in
+ * oauth_validator_libraries, then that library will be used as the validator.
+ * Otherwise the name must be present in the list of oauth_validator_libraries.
+ */
+bool
+check_oauth_validator(HbaLine *hbaline, int elevel, char **err_msg)
+{
+	int			line_num = hbaline->linenumber;
+	char	   *file_name = hbaline->sourcefile;
+	char	   *rawstring;
+	List	   *elemlist = NIL;
+
+	*err_msg = NULL;
+
+	if (oauth_validator_libraries_string[0] == '\0')
+	{
+		ereport(elevel,
+				errcode(ERRCODE_CONFIG_FILE_ERROR),
+				errmsg("oauth_validator_libraries must be set for authentication method %s",
+					   "oauth"),
+				errcontext("line %d of configuration file \"%s\"",
+						   line_num, file_name));
+		*err_msg = psprintf("oauth_validator_libraries must be set for authentication method %s",
+							"oauth");
+		return false;
+	}
+
+	/* SplitDirectoriesString needs a modifiable copy */
+	rawstring = pstrdup(oauth_validator_libraries_string);
+
+	if (!SplitDirectoriesString(rawstring, ',', &elemlist))
+	{
+		/* syntax error in list */
+		ereport(elevel,
+				errcode(ERRCODE_CONFIG_FILE_ERROR),
+				errmsg("invalid list syntax in parameter \"%s\"",
+					   "oauth_validator_libraries"));
+		*err_msg = psprintf("invalid list syntax in parameter \"%s\"",
+							"oauth_validator_libraries");
+		goto done;
+	}
+
+	if (!hbaline->oauth_validator)
+	{
+		if (elemlist->length == 1)
+		{
+			hbaline->oauth_validator = pstrdup(linitial(elemlist));
+			goto done;
+		}
+
+		ereport(elevel,
+				errcode(ERRCODE_CONFIG_FILE_ERROR),
+				errmsg("authentication method \"oauth\" requires argument \"validator\" to be set when oauth_validator_libraries contains multiple options"),
+				errcontext("line %d of configuration file \"%s\"",
+						   line_num, file_name));
+		*err_msg = "authentication method \"oauth\" requires argument \"validator\" to be set when oauth_validator_libraries contains multiple options";
+		goto done;
+	}
+
+	foreach_ptr(char, allowed, elemlist)
+	{
+		if (strcmp(allowed, hbaline->oauth_validator) == 0)
+			goto done;
+	}
+
+	ereport(elevel,
+			errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+			errmsg("validator \"%s\" is not permitted by %s",
+				   hbaline->oauth_validator, "oauth_validator_libraries"),
+			errcontext("line %d of configuration file \"%s\"",
+					   line_num, file_name));
+	*err_msg = psprintf("validator \"%s\" is not permitted by %s",
+						hbaline->oauth_validator, "oauth_validator_libraries");
+
+done:
+	list_free_deep(elemlist);
+	pfree(rawstring);
+
+	return (*err_msg == NULL);
+}
diff --git a/src/backend/libpq/auth.c b/src/backend/libpq/auth.c
index d6ef32cc823..0f65014e64f 100644
--- a/src/backend/libpq/auth.c
+++ b/src/backend/libpq/auth.c
@@ -29,6 +29,7 @@
 #include "libpq/auth.h"
 #include "libpq/crypt.h"
 #include "libpq/libpq.h"
+#include "libpq/oauth.h"
 #include "libpq/pqformat.h"
 #include "libpq/sasl.h"
 #include "libpq/scram.h"
@@ -45,7 +46,6 @@
  */
 static void auth_failed(Port *port, int status, const char *logdetail);
 static char *recv_password_packet(Port *port);
-static void set_authn_id(Port *port, const char *id);
 
 
 /*----------------------------------------------------------------
@@ -289,6 +289,9 @@ auth_failed(Port *port, int status, const char *logdetail)
 		case uaRADIUS:
 			errstr = gettext_noop("RADIUS authentication failed for user \"%s\"");
 			break;
+		case uaOAuth:
+			errstr = gettext_noop("OAuth bearer authentication failed for user \"%s\"");
+			break;
 		default:
 			errstr = gettext_noop("authentication failed for user \"%s\": invalid authentication method");
 			break;
@@ -324,7 +327,7 @@ auth_failed(Port *port, int status, const char *logdetail)
  * lifetime of MyClientConnectionInfo, so it is safe to pass a string that is
  * managed by an external library.
  */
-static void
+void
 set_authn_id(Port *port, const char *id)
 {
 	Assert(id);
@@ -611,6 +614,9 @@ ClientAuthentication(Port *port)
 		case uaTrust:
 			status = STATUS_OK;
 			break;
+		case uaOAuth:
+			status = CheckSASLAuth(&pg_be_oauth_mech, port, NULL, NULL);
+			break;
 	}
 
 	if ((status == STATUS_OK && port->hba->clientcert == clientCertFull)
diff --git a/src/backend/libpq/hba.c b/src/backend/libpq/hba.c
index 510c9ffc6d7..332fad27835 100644
--- a/src/backend/libpq/hba.c
+++ b/src/backend/libpq/hba.c
@@ -32,6 +32,7 @@
 #include "libpq/hba.h"
 #include "libpq/ifaddr.h"
 #include "libpq/libpq-be.h"
+#include "libpq/oauth.h"
 #include "postmaster/postmaster.h"
 #include "regex/regex.h"
 #include "replication/walsender.h"
@@ -114,7 +115,8 @@ static const char *const UserAuthName[] =
 	"ldap",
 	"cert",
 	"radius",
-	"peer"
+	"peer",
+	"oauth",
 };
 
 /*
@@ -1747,6 +1749,8 @@ parse_hba_line(TokenizedAuthLine *tok_line, int elevel)
 #endif
 	else if (strcmp(token->string, "radius") == 0)
 		parsedline->auth_method = uaRADIUS;
+	else if (strcmp(token->string, "oauth") == 0)
+		parsedline->auth_method = uaOAuth;
 	else
 	{
 		ereport(elevel,
@@ -2039,6 +2043,36 @@ parse_hba_line(TokenizedAuthLine *tok_line, int elevel)
 		parsedline->clientcert = clientCertFull;
 	}
 
+	/*
+	 * Enforce proper configuration of OAuth authentication.
+	 */
+	if (parsedline->auth_method == uaOAuth)
+	{
+		MANDATORY_AUTH_ARG(parsedline->oauth_scope, "scope", "oauth");
+		MANDATORY_AUTH_ARG(parsedline->oauth_issuer, "issuer", "oauth");
+
+		/* Ensure a validator library is set and permitted by the config. */
+		if (!check_oauth_validator(parsedline, elevel, err_msg))
+			return NULL;
+
+		/*
+		 * Supplying a usermap combined with the option to skip usermapping is
+		 * nonsensical and indicates a configuration error.
+		 */
+		if (parsedline->oauth_skip_usermap && parsedline->usermap != NULL)
+		{
+			ereport(elevel,
+					errcode(ERRCODE_CONFIG_FILE_ERROR),
+			/* translator: strings are replaced with hba options */
+					errmsg("%s cannot be used in combination with %s",
+						   "map", "delegate_ident_mapping"),
+					errcontext("line %d of configuration file \"%s\"",
+							   line_num, file_name));
+			*err_msg = "map cannot be used in combination with delegate_ident_mapping";
+			return NULL;
+		}
+	}
+
 	return parsedline;
 }
 
@@ -2066,8 +2100,9 @@ parse_hba_auth_opt(char *name, char *val, HbaLine *hbaline,
 			hbaline->auth_method != uaPeer &&
 			hbaline->auth_method != uaGSS &&
 			hbaline->auth_method != uaSSPI &&
-			hbaline->auth_method != uaCert)
-			INVALID_AUTH_OPTION("map", gettext_noop("ident, peer, gssapi, sspi, and cert"));
+			hbaline->auth_method != uaCert &&
+			hbaline->auth_method != uaOAuth)
+			INVALID_AUTH_OPTION("map", gettext_noop("ident, peer, gssapi, sspi, cert, and oauth"));
 		hbaline->usermap = pstrdup(val);
 	}
 	else if (strcmp(name, "clientcert") == 0)
@@ -2450,6 +2485,29 @@ parse_hba_auth_opt(char *name, char *val, HbaLine *hbaline,
 		hbaline->radiusidentifiers = parsed_identifiers;
 		hbaline->radiusidentifiers_s = pstrdup(val);
 	}
+	else if (strcmp(name, "issuer") == 0)
+	{
+		REQUIRE_AUTH_OPTION(uaOAuth, "issuer", "oauth");
+		hbaline->oauth_issuer = pstrdup(val);
+	}
+	else if (strcmp(name, "scope") == 0)
+	{
+		REQUIRE_AUTH_OPTION(uaOAuth, "scope", "oauth");
+		hbaline->oauth_scope = pstrdup(val);
+	}
+	else if (strcmp(name, "validator") == 0)
+	{
+		REQUIRE_AUTH_OPTION(uaOAuth, "validator", "oauth");
+		hbaline->oauth_validator = pstrdup(val);
+	}
+	else if (strcmp(name, "delegate_ident_mapping") == 0)
+	{
+		REQUIRE_AUTH_OPTION(uaOAuth, "delegate_ident_mapping", "oauth");
+		if (strcmp(val, "1") == 0)
+			hbaline->oauth_skip_usermap = true;
+		else
+			hbaline->oauth_skip_usermap = false;
+	}
 	else
 	{
 		ereport(elevel,
diff --git a/src/backend/libpq/meson.build b/src/backend/libpq/meson.build
index 0f0421037e4..31aa2faae1e 100644
--- a/src/backend/libpq/meson.build
+++ b/src/backend/libpq/meson.build
@@ -1,6 +1,7 @@
 # Copyright (c) 2022-2025, PostgreSQL Global Development Group
 
 backend_sources += files(
+  'auth-oauth.c',
   'auth-sasl.c',
   'auth-scram.c',
   'auth.c',
diff --git a/src/backend/libpq/pg_hba.conf.sample b/src/backend/libpq/pg_hba.conf.sample
index bad13497a34..b64c8dea97c 100644
--- a/src/backend/libpq/pg_hba.conf.sample
+++ b/src/backend/libpq/pg_hba.conf.sample
@@ -53,8 +53,8 @@
 # directly connected to.
 #
 # METHOD can be "trust", "reject", "md5", "password", "scram-sha-256",
-# "gss", "sspi", "ident", "peer", "pam", "ldap", "radius" or "cert".
-# Note that "password" sends passwords in clear text; "md5" or
+# "gss", "sspi", "ident", "peer", "pam", "oauth", "ldap", "radius" or
+# "cert".  Note that "password" sends passwords in clear text; "md5" or
 # "scram-sha-256" are preferred since they send encrypted passwords.
 #
 # OPTIONS are a set of options for the authentication in the format
diff --git a/src/backend/utils/adt/hbafuncs.c b/src/backend/utils/adt/hbafuncs.c
index 03c38e8c451..b62c3d944cf 100644
--- a/src/backend/utils/adt/hbafuncs.c
+++ b/src/backend/utils/adt/hbafuncs.c
@@ -152,6 +152,25 @@ get_hba_options(HbaLine *hba)
 				CStringGetTextDatum(psprintf("radiusports=%s", hba->radiusports_s));
 	}
 
+	if (hba->auth_method == uaOAuth)
+	{
+		if (hba->oauth_issuer)
+			options[noptions++] =
+				CStringGetTextDatum(psprintf("issuer=%s", hba->oauth_issuer));
+
+		if (hba->oauth_scope)
+			options[noptions++] =
+				CStringGetTextDatum(psprintf("scope=%s", hba->oauth_scope));
+
+		if (hba->oauth_validator)
+			options[noptions++] =
+				CStringGetTextDatum(psprintf("validator=%s", hba->oauth_validator));
+
+		if (hba->oauth_skip_usermap)
+			options[noptions++] =
+				CStringGetTextDatum(psprintf("delegate_ident_mapping=true"));
+	}
+
 	/* If you add more options, consider increasing MAX_HBA_OPTIONS. */
 	Assert(noptions <= MAX_HBA_OPTIONS);
 
diff --git a/src/backend/utils/misc/guc_tables.c b/src/backend/utils/misc/guc_tables.c
index ce7534d4d23..7747a09c2a9 100644
--- a/src/backend/utils/misc/guc_tables.c
+++ b/src/backend/utils/misc/guc_tables.c
@@ -49,6 +49,7 @@
 #include "jit/jit.h"
 #include "libpq/auth.h"
 #include "libpq/libpq.h"
+#include "libpq/oauth.h"
 #include "libpq/scram.h"
 #include "nodes/queryjumble.h"
 #include "optimizer/cost.h"
@@ -4832,6 +4833,17 @@ struct config_string ConfigureNamesString[] =
 		check_restrict_nonsystem_relation_kind, assign_restrict_nonsystem_relation_kind, NULL
 	},
 
+	{
+		{"oauth_validator_libraries", PGC_SIGHUP, CONN_AUTH_AUTH,
+			gettext_noop("Lists libraries that may be called to validate OAuth v2 bearer tokens."),
+			NULL,
+			GUC_LIST_INPUT | GUC_LIST_QUOTE | GUC_SUPERUSER_ONLY
+		},
+		&oauth_validator_libraries_string,
+		"",
+		NULL, NULL, NULL
+	},
+
 	/* End-of-list marker */
 	{
 		{NULL, 0, 0, NULL, NULL}, NULL, NULL, NULL, NULL, NULL
diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample
index c40b7a3121e..9184ea0f1d4 100644
--- a/src/backend/utils/misc/postgresql.conf.sample
+++ b/src/backend/utils/misc/postgresql.conf.sample
@@ -121,6 +121,9 @@
 #ssl_passphrase_command = ''
 #ssl_passphrase_command_supports_reload = off
 
+# OAuth
+#oauth_validator_libraries = ''	# comma-separated list of trusted validator modules
+
 
 #------------------------------------------------------------------------------
 # RESOURCE USAGE (except WAL)
diff --git a/src/include/common/oauth-common.h b/src/include/common/oauth-common.h
new file mode 100644
index 00000000000..8fe56267780
--- /dev/null
+++ b/src/include/common/oauth-common.h
@@ -0,0 +1,19 @@
+/*-------------------------------------------------------------------------
+ *
+ * oauth-common.h
+ *		Declarations for helper functions used for OAuth/OIDC authentication
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/include/common/oauth-common.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef OAUTH_COMMON_H
+#define OAUTH_COMMON_H
+
+/* Name of SASL mechanism per IANA */
+#define OAUTHBEARER_NAME "OAUTHBEARER"
+
+#endif							/* OAUTH_COMMON_H */
diff --git a/src/include/libpq/auth.h b/src/include/libpq/auth.h
index 902c5f6de32..25b5742068f 100644
--- a/src/include/libpq/auth.h
+++ b/src/include/libpq/auth.h
@@ -39,6 +39,7 @@ extern PGDLLIMPORT bool pg_gss_accept_delegation;
 extern void ClientAuthentication(Port *port);
 extern void sendAuthRequest(Port *port, AuthRequest areq, const char *extradata,
 							int extralen);
+extern void set_authn_id(Port *port, const char *id);
 
 /* Hook for plugins to get control in ClientAuthentication() */
 typedef void (*ClientAuthentication_hook_type) (Port *, int);
diff --git a/src/include/libpq/hba.h b/src/include/libpq/hba.h
index b20d0051f7d..3657f182db3 100644
--- a/src/include/libpq/hba.h
+++ b/src/include/libpq/hba.h
@@ -39,7 +39,8 @@ typedef enum UserAuth
 	uaCert,
 	uaRADIUS,
 	uaPeer,
-#define USER_AUTH_LAST uaPeer	/* Must be last value of this enum */
+	uaOAuth,
+#define USER_AUTH_LAST uaOAuth	/* Must be last value of this enum */
 } UserAuth;
 
 /*
@@ -135,6 +136,10 @@ typedef struct HbaLine
 	char	   *radiusidentifiers_s;
 	List	   *radiusports;
 	char	   *radiusports_s;
+	char	   *oauth_issuer;
+	char	   *oauth_scope;
+	char	   *oauth_validator;
+	bool		oauth_skip_usermap;
 } HbaLine;
 
 typedef struct IdentLine
diff --git a/src/include/libpq/oauth.h b/src/include/libpq/oauth.h
new file mode 100644
index 00000000000..4fcdda74305
--- /dev/null
+++ b/src/include/libpq/oauth.h
@@ -0,0 +1,54 @@
+/*-------------------------------------------------------------------------
+ *
+ * oauth.h
+ *	  Interface to libpq/auth-oauth.c
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/include/libpq/oauth.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PG_OAUTH_H
+#define PG_OAUTH_H
+
+#include "libpq/libpq-be.h"
+#include "libpq/sasl.h"
+
+extern PGDLLIMPORT char *oauth_validator_libraries_string;
+
+typedef struct ValidatorModuleState
+{
+	void	   *private_data;
+} ValidatorModuleState;
+
+typedef struct ValidatorModuleResult
+{
+	bool		authorized;
+	char	   *authn_id;
+} ValidatorModuleResult;
+
+typedef void (*ValidatorStartupCB) (ValidatorModuleState *state);
+typedef void (*ValidatorShutdownCB) (ValidatorModuleState *state);
+typedef ValidatorModuleResult *(*ValidatorValidateCB) (ValidatorModuleState *state, const char *token, const char *role);
+
+typedef struct OAuthValidatorCallbacks
+{
+	ValidatorStartupCB startup_cb;
+	ValidatorShutdownCB shutdown_cb;
+	ValidatorValidateCB validate_cb;
+} OAuthValidatorCallbacks;
+
+typedef const OAuthValidatorCallbacks *(*OAuthValidatorModuleInit) (void);
+extern PGDLLEXPORT const OAuthValidatorCallbacks *_PG_oauth_validator_module_init(void);
+
+/* Implementation */
+extern const pg_be_sasl_mech pg_be_oauth_mech;
+
+/*
+ * Ensure a validator named in the HBA is permitted by the configuration.
+ */
+extern bool check_oauth_validator(HbaLine *hba, int elevel, char **err_msg);
+
+#endif							/* PG_OAUTH_H */
diff --git a/src/include/pg_config.h.in b/src/include/pg_config.h.in
index 07b2f798abd..c04ee38d086 100644
--- a/src/include/pg_config.h.in
+++ b/src/include/pg_config.h.in
@@ -229,6 +229,9 @@
 /* Define to 1 if you have the `crypto' library (-lcrypto). */
 #undef HAVE_LIBCRYPTO
 
+/* Define to 1 if you have the `curl' library (-lcurl). */
+#undef HAVE_LIBCURL
+
 /* Define to 1 if you have the `ldap' library (-lldap). */
 #undef HAVE_LIBLDAP
 
@@ -442,6 +445,9 @@
 /* Define to 1 if you have the <termios.h> header file. */
 #undef HAVE_TERMIOS_H
 
+/* Define to 1 if curl_global_init() is guaranteed to be threadsafe. */
+#undef HAVE_THREADSAFE_CURL_GLOBAL_INIT
+
 /* Define to 1 if your compiler understands `typeof' or something similar. */
 #undef HAVE_TYPEOF
 
@@ -663,6 +669,9 @@
 /* Define to 1 to build with LDAP support. (--with-ldap) */
 #undef USE_LDAP
 
+/* Define to 1 to build with libcurl support. (--with-libcurl) */
+#undef USE_LIBCURL
+
 /* Define to 1 to build with XML support. (--with-libxml) */
 #undef USE_LIBXML
 
diff --git a/src/interfaces/libpq/Makefile b/src/interfaces/libpq/Makefile
index 701810a272a..90b0b65db6f 100644
--- a/src/interfaces/libpq/Makefile
+++ b/src/interfaces/libpq/Makefile
@@ -31,6 +31,7 @@ endif
 
 OBJS = \
 	$(WIN32RES) \
+	fe-auth-oauth.o \
 	fe-auth-scram.o \
 	fe-cancel.o \
 	fe-connect.o \
@@ -63,6 +64,10 @@ OBJS += \
 	fe-secure-gssapi.o
 endif
 
+ifeq ($(with_libcurl),yes)
+OBJS += fe-auth-oauth-curl.o
+endif
+
 ifeq ($(PORTNAME), cygwin)
 override shlib = cyg$(NAME)$(DLSUFFIX)
 endif
@@ -81,7 +86,7 @@ endif
 # that are built correctly for use in a shlib.
 SHLIB_LINK_INTERNAL = -lpgcommon_shlib -lpgport_shlib
 ifneq ($(PORTNAME), win32)
-SHLIB_LINK += $(filter -lcrypt -ldes -lcom_err -lcrypto -lk5crypto -lkrb5 -lgssapi_krb5 -lgss -lgssapi -lssl -lsocket -lnsl -lresolv -lintl -lm, $(LIBS)) $(LDAP_LIBS_FE) $(PTHREAD_LIBS)
+SHLIB_LINK += $(filter -lcrypt -ldes -lcom_err -lcrypto -lk5crypto -lkrb5 -lgssapi_krb5 -lgss -lgssapi -lssl -lcurl -lsocket -lnsl -lresolv -lintl -lm, $(LIBS)) $(LDAP_LIBS_FE) $(PTHREAD_LIBS)
 else
 SHLIB_LINK += $(filter -lcrypt -ldes -lcom_err -lcrypto -lk5crypto -lkrb5 -lgssapi32 -lssl -lsocket -lnsl -lresolv -lintl -lm $(PTHREAD_LIBS), $(LIBS)) $(LDAP_LIBS_FE)
 endif
@@ -110,6 +115,8 @@ backend_src = $(top_srcdir)/src/backend
 # which seems to insert references to that even in pure C code. Excluding
 # __tsan_func_exit is necessary when using ThreadSanitizer data race detector
 # which use this function for instrumentation of function exit.
+# libcurl registers an exit handler in the memory debugging code when running
+# with LeakSanitizer.
 # Skip the test when profiling, as gcc may insert exit() calls for that.
 # Also skip the test on platforms where libpq infrastructure may be provided
 # by statically-linked libraries, as we can't expect them to honor this
@@ -117,7 +124,7 @@ backend_src = $(top_srcdir)/src/backend
 libpq-refs-stamp: $(shlib)
 ifneq ($(enable_coverage), yes)
 ifeq (,$(filter solaris,$(PORTNAME)))
-	@if nm -A -u $< 2>/dev/null | grep -v -e __cxa_atexit -e __tsan_func_exit | grep exit; then \
+	@if nm -A -u $< 2>/dev/null | grep -v -e __cxa_atexit -e __tsan_func_exit -e _atexit | grep exit; then \
 		echo 'libpq must not be calling any function which invokes exit'; exit 1; \
 	fi
 endif
diff --git a/src/interfaces/libpq/exports.txt b/src/interfaces/libpq/exports.txt
index 2ad2cbf5ca3..9b789cbec0b 100644
--- a/src/interfaces/libpq/exports.txt
+++ b/src/interfaces/libpq/exports.txt
@@ -206,3 +206,6 @@ PQsocketPoll              203
 PQsetChunkedRowsMode      204
 PQgetCurrentTimeUSec      205
 PQservice                 206
+PQsetAuthDataHook         207
+PQgetAuthDataHook         208
+PQdefaultAuthDataHook     209
diff --git a/src/interfaces/libpq/fe-auth-oauth-curl.c b/src/interfaces/libpq/fe-auth-oauth-curl.c
new file mode 100644
index 00000000000..2407200ea97
--- /dev/null
+++ b/src/interfaces/libpq/fe-auth-oauth-curl.c
@@ -0,0 +1,2635 @@
+/*-------------------------------------------------------------------------
+ *
+ * fe-auth-oauth-curl.c
+ *	   The libcurl implementation of OAuth/OIDC authentication, using the
+ *	   OAuth Device Authorization Grant (RFC 8628).
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/interfaces/libpq/fe-auth-oauth-curl.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres_fe.h"
+
+#include <curl/curl.h>
+#include <math.h>
+#ifdef HAVE_SYS_EPOLL_H
+#include <sys/epoll.h>
+#include <sys/timerfd.h>
+#endif
+#ifdef HAVE_SYS_EVENT_H
+#include <sys/event.h>
+#endif
+#include <unistd.h>
+
+#include "common/jsonapi.h"
+#include "fe-auth.h"
+#include "fe-auth-oauth.h"
+#include "libpq-int.h"
+#include "mb/pg_wchar.h"
+
+#define MAX_OAUTH_RESPONSE_SIZE (1024 * 1024)
+
+/*
+ * Parsed JSON Representations
+ *
+ * As a general rule, we parse and cache only the fields we're currently using.
+ * When adding new fields, ensure the corresponding free_*() function is updated
+ * too.
+ */
+
+/*
+ * The OpenID Provider configuration (alternatively named "authorization server
+ * metadata") jointly described by OpenID Connect Discovery 1.0 and RFC 8414:
+ *
+ *     https://openid.net/specs/openid-connect-discovery-1_0.html
+ *     https://www.rfc-editor.org/rfc/rfc8414#section-3.2
+ */
+struct provider
+{
+	char	   *issuer;
+	char	   *token_endpoint;
+	char	   *device_authorization_endpoint;
+	struct curl_slist *grant_types_supported;
+};
+
+static void
+free_provider(struct provider *provider)
+{
+	free(provider->issuer);
+	free(provider->token_endpoint);
+	free(provider->device_authorization_endpoint);
+	curl_slist_free_all(provider->grant_types_supported);
+}
+
+/*
+ * The Device Authorization response, described by RFC 8628:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc8628#section-3.2
+ */
+struct device_authz
+{
+	char	   *device_code;
+	char	   *user_code;
+	char	   *verification_uri;
+	char	   *interval_str;
+
+	/* Fields below are parsed from the corresponding string above. */
+	int			interval;
+};
+
+static void
+free_device_authz(struct device_authz *authz)
+{
+	free(authz->device_code);
+	free(authz->user_code);
+	free(authz->verification_uri);
+	free(authz->interval_str);
+}
+
+/*
+ * The Token Endpoint error response, as described by RFC 6749:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc6749#section-5.2
+ *
+ * Note that this response type can also be returned from the Device
+ * Authorization Endpoint.
+ */
+struct token_error
+{
+	char	   *error;
+	char	   *error_description;
+};
+
+static void
+free_token_error(struct token_error *err)
+{
+	free(err->error);
+	free(err->error_description);
+}
+
+/*
+ * The Access Token response, as described by RFC 6749:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc6749#section-4.1.4
+ *
+ * During the Device Authorization flow, several temporary errors are expected
+ * as part of normal operation. To make it easy to handle these in the happy
+ * path, this contains an embedded token_error that is filled in if needed.
+ */
+struct token
+{
+	/* for successful responses */
+	char	   *access_token;
+	char	   *token_type;
+
+	/* for error responses */
+	struct token_error err;
+};
+
+static void
+free_token(struct token *tok)
+{
+	free(tok->access_token);
+	free(tok->token_type);
+	free_token_error(&tok->err);
+}
+
+/*
+ * Asynchronous State
+ */
+
+/* States for the overall async machine. */
+enum OAuthStep
+{
+	OAUTH_STEP_INIT = 0,
+	OAUTH_STEP_DISCOVERY,
+	OAUTH_STEP_DEVICE_AUTHORIZATION,
+	OAUTH_STEP_TOKEN_REQUEST,
+	OAUTH_STEP_WAIT_INTERVAL,
+};
+
+/*
+ * The async_ctx holds onto state that needs to persist across multiple calls
+ * to pg_fe_run_oauth_flow(). Almost everything interacts with this in some
+ * way.
+ */
+struct async_ctx
+{
+	enum OAuthStep step;		/* where are we in the flow? */
+
+#ifdef HAVE_SYS_EPOLL_H
+	int			timerfd;		/* a timerfd for signaling async timeouts */
+#endif
+	pgsocket	mux;			/* the multiplexer socket containing all
+								 * descriptors tracked by libcurl, plus the
+								 * timerfd */
+	CURLM	   *curlm;			/* top-level multi handle for libcurl
+								 * operations */
+	CURL	   *curl;			/* the (single) easy handle for serial
+								 * requests */
+
+	struct curl_slist *headers; /* common headers for all requests */
+	PQExpBufferData work_data;	/* scratch buffer for general use (remember to
+								 * clear out prior contents first!) */
+
+	/*------
+	 * Since a single logical operation may stretch across multiple calls to
+	 * our entry point, errors have three parts:
+	 *
+	 * - errctx:	an optional static string, describing the global operation
+	 *				currently in progress. It'll be translated for you.
+	 *
+	 * - errbuf:	contains the actual error message. Generally speaking, use
+	 *				actx_error[_str] to manipulate this. This must be filled
+	 *				with something useful on an error.
+	 *
+	 * - curl_err:	an optional static error buffer used by libcurl to put
+	 *				detailed information about failures. Unfortunately
+	 *				untranslatable.
+	 *
+	 * These pieces will be combined into a single error message looking
+	 * something like the following, with errctx and/or curl_err omitted when
+	 * absent:
+	 *
+	 *     connection to server ... failed: errctx: errbuf (curl_err)
+	 */
+	const char *errctx;			/* not freed; must point to static allocation */
+	PQExpBufferData errbuf;
+	char		curl_err[CURL_ERROR_SIZE];
+
+	/*
+	 * These documents need to survive over multiple calls, and are therefore
+	 * cached directly in the async_ctx.
+	 */
+	struct provider provider;
+	struct device_authz authz;
+
+	int			running;		/* is asynchronous work in progress? */
+	bool		user_prompted;	/* have we already sent the authz prompt? */
+	bool		used_basic_auth;	/* did we send a client secret? */
+	bool		debugging;		/* can we give unsafe developer assistance? */
+};
+
+/*
+ * Tears down the Curl handles and frees the async_ctx.
+ */
+static void
+free_async_ctx(PGconn *conn, struct async_ctx *actx)
+{
+	/*
+	 * TODO: in general, none of the error cases below should ever happen if
+	 * we have no bugs above. But if we do hit them, surfacing those errors
+	 * somehow might be the only way to have a chance to debug them. What's
+	 * the best way to do that? Assertions? Spraying messages on stderr?
+	 * Bubbling an error code to the top? Appending to the connection's error
+	 * message only helps if the bug caused a connection failure; otherwise
+	 * it'll be buried...
+	 */
+
+	if (actx->curlm && actx->curl)
+	{
+		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));
+	}
+
+	if (actx->curl)
+	{
+		/*
+		 * curl_multi_cleanup() doesn't free any associated easy handles; we
+		 * need to do that separately. We only ever have one easy handle per
+		 * multi handle.
+		 */
+		curl_easy_cleanup(actx->curl);
+	}
+
+	if (actx->curlm)
+	{
+		CURLMcode	err = curl_multi_cleanup(actx->curlm);
+
+		if (err)
+			libpq_append_conn_error(conn,
+									"libcurl multi handle cleanup failed: %s",
+									curl_multi_strerror(err));
+	}
+
+	free_provider(&actx->provider);
+	free_device_authz(&actx->authz);
+
+	curl_slist_free_all(actx->headers);
+	termPQExpBuffer(&actx->work_data);
+	termPQExpBuffer(&actx->errbuf);
+
+	if (actx->mux != PGINVALID_SOCKET)
+		close(actx->mux);
+#ifdef HAVE_SYS_EPOLL_H
+	if (actx->timerfd >= 0)
+		close(actx->timerfd);
+#endif
+
+	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.
+ */
+void
+pg_fe_cleanup_oauth_flow(PGconn *conn)
+{
+	fe_oauth_state *state = conn->sasl_state;
+
+	if (state->async_ctx)
+	{
+		free_async_ctx(conn, state->async_ctx);
+		state->async_ctx = NULL;
+	}
+
+	conn->altsock = PGINVALID_SOCKET;
+}
+
+/*
+ * Macros for manipulating actx->errbuf. actx_error() translates and formats a
+ * string for you; actx_error_str() appends a string directly without
+ * translation.
+ */
+
+#define actx_error(ACTX, FMT, ...) \
+	appendPQExpBuffer(&(ACTX)->errbuf, libpq_gettext(FMT), ##__VA_ARGS__)
+
+#define actx_error_str(ACTX, S) \
+	appendPQExpBufferStr(&(ACTX)->errbuf, S)
+
+/*
+ * Macros for getting and setting state for the connection's two libcurl
+ * handles, so you don't have to write out the error handling every time.
+ */
+
+#define CHECK_MSETOPT(ACTX, OPT, VAL, FAILACTION) \
+	do { \
+		struct async_ctx *_actx = (ACTX); \
+		CURLMcode	_setopterr = curl_multi_setopt(_actx->curlm, OPT, VAL); \
+		if (_setopterr) { \
+			actx_error(_actx, "failed to set %s on OAuth connection: %s",\
+					   #OPT, curl_multi_strerror(_setopterr)); \
+			FAILACTION; \
+		} \
+	} while (0)
+
+#define CHECK_SETOPT(ACTX, OPT, VAL, FAILACTION) \
+	do { \
+		struct async_ctx *_actx = (ACTX); \
+		CURLcode	_setopterr = curl_easy_setopt(_actx->curl, OPT, VAL); \
+		if (_setopterr) { \
+			actx_error(_actx, "failed to set %s on OAuth connection: %s",\
+					   #OPT, curl_easy_strerror(_setopterr)); \
+			FAILACTION; \
+		} \
+	} while (0)
+
+#define CHECK_GETINFO(ACTX, INFO, OUT, FAILACTION) \
+	do { \
+		struct async_ctx *_actx = (ACTX); \
+		CURLcode	_getinfoerr = curl_easy_getinfo(_actx->curl, INFO, OUT); \
+		if (_getinfoerr) { \
+			actx_error(_actx, "failed to get %s from OAuth response: %s",\
+					   #INFO, curl_easy_strerror(_getinfoerr)); \
+			FAILACTION; \
+		} \
+	} while (0)
+
+/*
+ * General JSON Parsing for OAuth Responses
+ */
+
+/*
+ * Represents a single name/value pair in a JSON object. This is the primary
+ * interface to parse_oauth_json().
+ *
+ * All fields are stored internally as strings or lists of strings, so clients
+ * have to explicitly parse other scalar types (though they will have gone
+ * through basic lexical validation). Storing nested objects is not currently
+ * supported, nor is parsing arrays of anything other than strings.
+ */
+struct json_field
+{
+	const char *name;			/* name (key) of the member */
+
+	JsonTokenType type;			/* currently supports JSON_TOKEN_STRING,
+								 * JSON_TOKEN_NUMBER, and
+								 * JSON_TOKEN_ARRAY_START */
+	union
+	{
+		char	  **scalar;		/* for all scalar types */
+		struct curl_slist **array;	/* for type == JSON_TOKEN_ARRAY_START */
+	}			target;
+
+	bool		required;		/* REQUIRED field, or just OPTIONAL? */
+};
+
+/* Documentation macros for json_field.required. */
+#define REQUIRED true
+#define OPTIONAL false
+
+/* Parse state for parse_oauth_json(). */
+struct oauth_parse
+{
+	PQExpBuffer errbuf;			/* detail message for JSON_SEM_ACTION_FAILED */
+	int			nested;			/* nesting level (zero is the top) */
+
+	const struct json_field *fields;	/* field definition array */
+	const struct json_field *active;	/* points inside the fields array */
+};
+
+#define oauth_parse_set_error(ctx, fmt, ...) \
+	appendPQExpBuffer((ctx)->errbuf, libpq_gettext(fmt), ##__VA_ARGS__)
+
+static void
+report_type_mismatch(struct oauth_parse *ctx)
+{
+	char	   *msgfmt;
+
+	Assert(ctx->active);
+
+	/*
+	 * At the moment, the only fields we're interested in are strings,
+	 * numbers, and arrays of strings.
+	 */
+	switch (ctx->active->type)
+	{
+		case JSON_TOKEN_STRING:
+			msgfmt = "field \"%s\" must be a string";
+			break;
+
+		case JSON_TOKEN_NUMBER:
+			msgfmt = "field \"%s\" must be a number";
+			break;
+
+		case JSON_TOKEN_ARRAY_START:
+			msgfmt = "field \"%s\" must be an array of strings";
+			break;
+
+		default:
+			Assert(false);
+			msgfmt = "field \"%s\" has unexpected type";
+	}
+
+	oauth_parse_set_error(ctx, msgfmt, ctx->active->name);
+}
+
+static JsonParseErrorType
+oauth_json_object_start(void *state)
+{
+	struct oauth_parse *ctx = state;
+
+	if (ctx->active)
+	{
+		/*
+		 * Currently, none of the fields we're interested in can be or contain
+		 * objects, so we can reject this case outright.
+		 */
+		report_type_mismatch(ctx);
+		return JSON_SEM_ACTION_FAILED;
+	}
+
+	++ctx->nested;
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_object_field_start(void *state, char *name, bool isnull)
+{
+	struct oauth_parse *ctx = state;
+
+	/* We care only about the top-level fields. */
+	if (ctx->nested == 1)
+	{
+		const struct json_field *field = ctx->fields;
+
+		/*
+		 * We should never start parsing a new field while a previous one is
+		 * still active.
+		 *
+		 * TODO: this code relies on assertions too much. We need to exit
+		 * sanely on internal logic errors, to avoid turning bugs into
+		 * vulnerabilities.
+		 */
+		Assert(!ctx->active);
+
+		while (field->name)
+		{
+			if (strcmp(name, field->name) == 0)
+			{
+				ctx->active = field;
+				break;
+			}
+
+			++field;
+		}
+
+		/*
+		 * We don't allow duplicate field names; error out if the target has
+		 * already been set.
+		 */
+		if (ctx->active)
+		{
+			field = ctx->active;
+
+			if ((field->type == JSON_TOKEN_ARRAY_START && *field->target.array)
+				|| (field->type != JSON_TOKEN_ARRAY_START && *field->target.scalar))
+			{
+				oauth_parse_set_error(ctx, "field \"%s\" is duplicated",
+									  field->name);
+				return JSON_SEM_ACTION_FAILED;
+			}
+		}
+	}
+
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_object_end(void *state)
+{
+	struct oauth_parse *ctx = state;
+
+	--ctx->nested;
+	if (!ctx->nested)
+		Assert(!ctx->active);	/* all fields should be fully processed */
+
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_array_start(void *state)
+{
+	struct oauth_parse *ctx = state;
+
+	if (!ctx->nested)
+	{
+		oauth_parse_set_error(ctx, "top-level element must be an object");
+		return JSON_SEM_ACTION_FAILED;
+	}
+
+	if (ctx->active)
+	{
+		if (ctx->active->type != JSON_TOKEN_ARRAY_START
+		/* The arrays we care about must not have arrays as values. */
+			|| ctx->nested > 1)
+		{
+			report_type_mismatch(ctx);
+			return JSON_SEM_ACTION_FAILED;
+		}
+	}
+
+	++ctx->nested;
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_array_end(void *state)
+{
+	struct oauth_parse *ctx = state;
+
+	if (ctx->active)
+	{
+		/*
+		 * This assumes that no target arrays can contain other arrays, which
+		 * we check in the array_start callback.
+		 */
+		Assert(ctx->nested == 2);
+		Assert(ctx->active->type == JSON_TOKEN_ARRAY_START);
+
+		ctx->active = NULL;
+	}
+
+	--ctx->nested;
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_scalar(void *state, char *token, JsonTokenType type)
+{
+	struct oauth_parse *ctx = state;
+
+	if (!ctx->nested)
+	{
+		oauth_parse_set_error(ctx, "top-level element must be an object");
+		return JSON_SEM_ACTION_FAILED;
+	}
+
+	if (ctx->active)
+	{
+		const struct json_field *field = ctx->active;
+		JsonTokenType expected = field->type;
+
+		/* Make sure this matches what the active field expects. */
+		if (expected == JSON_TOKEN_ARRAY_START)
+		{
+			/* Are we actually inside an array? */
+			if (ctx->nested < 2)
+			{
+				report_type_mismatch(ctx);
+				return JSON_SEM_ACTION_FAILED;
+			}
+
+			/* Currently, arrays can only contain strings. */
+			expected = JSON_TOKEN_STRING;
+		}
+
+		if (type != expected)
+		{
+			report_type_mismatch(ctx);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		if (field->type != JSON_TOKEN_ARRAY_START)
+		{
+			Assert(ctx->nested == 1);
+			Assert(!*field->target.scalar);
+
+			*field->target.scalar = strdup(token);
+			if (!*field->target.scalar)
+				return JSON_OUT_OF_MEMORY;
+
+			ctx->active = NULL;
+
+			return JSON_SUCCESS;
+		}
+		else
+		{
+			struct curl_slist *temp;
+
+			Assert(ctx->nested == 2);
+
+			/* Note that curl_slist_append() makes a copy of the token. */
+			temp = curl_slist_append(*field->target.array, token);
+			if (!temp)
+				return JSON_OUT_OF_MEMORY;
+
+			*field->target.array = temp;
+		}
+	}
+	else
+	{
+		/* otherwise we just ignore it */
+	}
+
+	return JSON_SUCCESS;
+}
+
+/*
+ * Checks the Content-Type header against the expected type. Parameters are
+ * allowed but ignored.
+ */
+static bool
+check_content_type(struct async_ctx *actx, const char *type)
+{
+	const size_t type_len = strlen(type);
+	char	   *content_type;
+
+	CHECK_GETINFO(actx, CURLINFO_CONTENT_TYPE, &content_type, return false);
+
+	if (!content_type)
+	{
+		actx_error(actx, "no content type was provided");
+		return false;
+	}
+
+	/*
+	 * We need to perform a length limited comparison and not compare the
+	 * whole string.
+	 */
+	if (pg_strncasecmp(content_type, type, type_len) != 0)
+		goto fail;
+
+	/* On an exact match, we're done. */
+	Assert(strlen(content_type) >= type_len);
+	if (content_type[type_len] == '\0')
+		return true;
+
+	/*
+	 * Only a semicolon (optionally preceded by HTTP optional whitespace) is
+	 * acceptable after the prefix we checked. This marks the start of media
+	 * type parameters, which we currently have no use for.
+	 */
+	for (size_t i = type_len; content_type[i]; ++i)
+	{
+		switch (content_type[i])
+		{
+			case ';':
+				return true;	/* success! */
+
+			case ' ':
+			case '\t':
+				/* HTTP optional whitespace allows only spaces and htabs. */
+				break;
+
+			default:
+				goto fail;
+		}
+	}
+
+fail:
+	actx_error(actx, "unexpected content type: \"%s\"", content_type);
+	return false;
+}
+
+/*
+ * A helper function for general JSON parsing. fields is the array of field
+ * definitions with their backing pointers. The response will be parsed from
+ * actx->curl and actx->work_data (as set up by start_request()), and any
+ * parsing errors will be placed into actx->errbuf.
+ */
+static bool
+parse_oauth_json(struct async_ctx *actx, const struct json_field *fields)
+{
+	PQExpBuffer resp = &actx->work_data;
+	JsonLexContext lex = {0};
+	JsonSemAction sem = {0};
+	JsonParseErrorType err;
+	struct oauth_parse ctx = {0};
+	bool		success = false;
+
+	if (!check_content_type(actx, "application/json"))
+		return false;
+
+	if (strlen(resp->data) != resp->len)
+	{
+		actx_error(actx, "response contains embedded NULLs");
+		return false;
+	}
+
+	/*
+	 * pg_parse_json doesn't validate the incoming UTF-8, so we have to check
+	 * that up front.
+	 */
+	if (pg_encoding_verifymbstr(PG_UTF8, resp->data, resp->len) != resp->len)
+	{
+		actx_error(actx, "response is not valid UTF-8");
+		return false;
+	}
+
+	makeJsonLexContextCstringLen(&lex, resp->data, resp->len, PG_UTF8, true);
+	setJsonLexContextOwnsTokens(&lex, true);	/* must not leak on error */
+
+	ctx.errbuf = &actx->errbuf;
+	ctx.fields = fields;
+	sem.semstate = &ctx;
+
+	sem.object_start = oauth_json_object_start;
+	sem.object_field_start = oauth_json_object_field_start;
+	sem.object_end = oauth_json_object_end;
+	sem.array_start = oauth_json_array_start;
+	sem.array_end = oauth_json_array_end;
+	sem.scalar = oauth_json_scalar;
+
+	err = pg_parse_json(&lex, &sem);
+
+	if (err != JSON_SUCCESS)
+	{
+		/*
+		 * For JSON_SEM_ACTION_FAILED, we've already written the error
+		 * message. Other errors come directly from pg_parse_json(), already
+		 * translated.
+		 */
+		if (err != JSON_SEM_ACTION_FAILED)
+			actx_error_str(actx, json_errdetail(err, &lex));
+
+		goto cleanup;
+	}
+
+	/* Check all required fields. */
+	while (fields->name)
+	{
+		if (fields->required
+			&& !*fields->target.scalar
+			&& !*fields->target.array)
+		{
+			actx_error(actx, "field \"%s\" is missing", fields->name);
+			goto cleanup;
+		}
+
+		fields++;
+	}
+
+	success = true;
+
+cleanup:
+	freeJsonLexContext(&lex);
+	return success;
+}
+
+/*
+ * JSON Parser Definitions
+ */
+
+/*
+ * Parses authorization server metadata. Fields are defined by OIDC Discovery
+ * 1.0 and RFC 8414.
+ */
+static bool
+parse_provider(struct async_ctx *actx, struct provider *provider)
+{
+	struct json_field fields[] = {
+		{"issuer", JSON_TOKEN_STRING, {&provider->issuer}, REQUIRED},
+		{"token_endpoint", JSON_TOKEN_STRING, {&provider->token_endpoint}, REQUIRED},
+
+		/*----
+		 * The following fields are technically REQUIRED, but we don't use
+		 * them anywhere yet:
+		 *
+		 * - jwks_uri
+		 * - response_types_supported
+		 * - subject_types_supported
+		 * - id_token_signing_alg_values_supported
+		 */
+
+		{"device_authorization_endpoint", JSON_TOKEN_STRING, {&provider->device_authorization_endpoint}, OPTIONAL},
+		{"grant_types_supported", JSON_TOKEN_ARRAY_START, {.array = &provider->grant_types_supported}, OPTIONAL},
+
+		{0},
+	};
+
+	return parse_oauth_json(actx, fields);
+}
+
+/*
+ * Parses the "interval" JSON number, corresponding to the number of seconds to
+ * wait between token endpoint requests.
+ *
+ * RFC 8628 is pretty silent on sanity checks for the interval. As a matter of
+ * practicality, round any fractional intervals up to the next second, and clamp
+ * the result at a minimum of one. (Zero-second intervals would result in an
+ * expensive network polling loop.) Tests may remove the lower bound with
+ * PGOAUTHDEBUG, for improved performance.
+ *
+ * TODO: maybe clamp the upper bound too, based on the libpq timeout and/or the
+ * code expiration time?
+ */
+static int
+parse_interval(struct async_ctx *actx, const char *interval_str)
+{
+	double		parsed;
+	int			cnt;
+
+	/*
+	 * The JSON lexer has already validated the number, which is stricter than
+	 * the %f format, so we should be good to use sscanf().
+	 */
+	cnt = sscanf(interval_str, "%lf", &parsed);
+
+	if (cnt != 1)
+	{
+		/*
+		 * Either the lexer screwed up or our assumption above isn't true, and
+		 * either way a developer needs to take a look.
+		 */
+		Assert(cnt == 1);
+		return 1;				/* don't fall through in release builds */
+	}
+
+	parsed = ceil(parsed);
+
+	if (parsed < 1)
+		return actx->debugging ? 0 : 1;
+
+	else if (INT_MAX <= parsed)
+		return INT_MAX;
+
+	return parsed;
+}
+
+/*
+ * Parses the Device Authorization Response (RFC 8628, Sec. 3.2).
+ */
+static bool
+parse_device_authz(struct async_ctx *actx, struct device_authz *authz)
+{
+	struct json_field fields[] = {
+		{"device_code", JSON_TOKEN_STRING, {&authz->device_code}, REQUIRED},
+		{"user_code", JSON_TOKEN_STRING, {&authz->user_code}, REQUIRED},
+		{"verification_uri", JSON_TOKEN_STRING, {&authz->verification_uri}, REQUIRED},
+
+		/*
+		 * Some services (Google, Azure) spell verification_uri differently.
+		 * We accept either.
+		 */
+		{"verification_url", JSON_TOKEN_STRING, {&authz->verification_uri}, REQUIRED},
+
+		/*
+		 * The following fields are technically REQUIRED, but we don't use
+		 * them anywhere yet:
+		 *
+		 * - expires_in
+		 */
+
+		{"interval", JSON_TOKEN_NUMBER, {&authz->interval_str}, OPTIONAL},
+
+		{0},
+	};
+
+	if (!parse_oauth_json(actx, fields))
+		return false;
+
+	/*
+	 * Parse our numeric fields. Lexing has already completed by this time, so
+	 * we at least know they're valid JSON numbers.
+	 */
+	if (authz->interval_str)
+		authz->interval = parse_interval(actx, authz->interval_str);
+	else
+	{
+		/*
+		 * RFC 8628 specifies 5 seconds as the default value if the server
+		 * doesn't provide an interval.
+		 */
+		authz->interval = 5;
+	}
+
+	return true;
+}
+
+/*
+ * Parses the device access token error response (RFC 8628, Sec. 3.5, which
+ * uses the error response defined in RFC 6749, Sec. 5.2).
+ */
+static bool
+parse_token_error(struct async_ctx *actx, struct token_error *err)
+{
+	bool		result;
+	struct json_field fields[] = {
+		{"error", JSON_TOKEN_STRING, {&err->error}, REQUIRED},
+
+		{"error_description", JSON_TOKEN_STRING, {&err->error_description}, OPTIONAL},
+
+		{0},
+	};
+
+	result = parse_oauth_json(actx, fields);
+
+	/*
+	 * Since token errors are parsed during other active error paths, only
+	 * override the errctx if parsing explicitly fails.
+	 */
+	if (!result)
+		actx->errctx = "failed to parse token error response";
+
+	return result;
+}
+
+/*
+ * Constructs a message from the token error response and puts it into
+ * actx->errbuf.
+ */
+static void
+record_token_error(struct async_ctx *actx, const struct token_error *err)
+{
+	if (err->error_description)
+		appendPQExpBuffer(&actx->errbuf, "%s ", err->error_description);
+	else
+	{
+		/*
+		 * Try to get some more helpful detail into the error string. A 401
+		 * status in particular implies that the oauth_client_secret is
+		 * missing or wrong.
+		 */
+		long		response_code;
+
+		CHECK_GETINFO(actx, CURLINFO_RESPONSE_CODE, &response_code, response_code = 0);
+
+		if (response_code == 401)
+		{
+			actx_error(actx, actx->used_basic_auth
+					   ? "provider rejected the oauth_client_secret"
+					   : "provider requires client authentication, and no oauth_client_secret is set");
+			actx_error_str(actx, " ");
+		}
+	}
+
+	appendPQExpBuffer(&actx->errbuf, "(%s)", err->error);
+}
+
+/*
+ * Parses the device access token response (RFC 8628, Sec. 3.5, which uses the
+ * success response defined in RFC 6749, Sec. 5.1).
+ */
+static bool
+parse_access_token(struct async_ctx *actx, struct token *tok)
+{
+	struct json_field fields[] = {
+		{"access_token", JSON_TOKEN_STRING, {&tok->access_token}, REQUIRED},
+		{"token_type", JSON_TOKEN_STRING, {&tok->token_type}, REQUIRED},
+
+		/*
+		 * The following fields are technically REQUIRED, but we don't use
+		 * them anywhere yet:
+		 *
+		 * - scope (only required if different than requested -- TODO check)
+		 */
+
+		{0},
+	};
+
+	return parse_oauth_json(actx, fields);
+}
+
+/*
+ * libcurl Multi Setup/Callbacks
+ */
+
+/*
+ * Sets up the actx->mux, which is the altsock that PQconnectPoll clients will
+ * select() on instead of the Postgres socket during OAuth negotiation.
+ *
+ * This is just an epoll set or kqueue abstracting multiple other descriptors.
+ * A timerfd is always part of the set when using epoll; it's just disabled
+ * when we're not using it.
+ */
+static bool
+setup_multiplexer(struct async_ctx *actx)
+{
+#ifdef HAVE_SYS_EPOLL_H
+	struct epoll_event ev = {.events = EPOLLIN};
+
+	actx->mux = epoll_create1(EPOLL_CLOEXEC);
+	if (actx->mux < 0)
+	{
+		actx_error(actx, "failed to create epoll set: %m");
+		return false;
+	}
+
+	actx->timerfd = timerfd_create(CLOCK_MONOTONIC, TFD_CLOEXEC);
+	if (actx->timerfd < 0)
+	{
+		actx_error(actx, "failed to create timerfd: %m");
+		return false;
+	}
+
+	if (epoll_ctl(actx->mux, EPOLL_CTL_ADD, actx->timerfd, &ev) < 0)
+	{
+		actx_error(actx, "failed to add timerfd to epoll set: %m");
+		return false;
+	}
+
+	return true;
+#endif
+#ifdef HAVE_SYS_EVENT_H
+	actx->mux = kqueue();
+	if (actx->mux < 0)
+	{
+		actx_error(actx, "failed to create kqueue: %m");
+		return false;
+	}
+
+	return true;
+#endif
+
+	actx_error(actx, "libpq does not support the Device Authorization flow on this platform");
+	return false;
+}
+
+/*
+ * Adds and removes sockets from the multiplexer set, as directed by the
+ * libcurl multi handle.
+ */
+static int
+register_socket(CURL *curl, curl_socket_t socket, int what, void *ctx,
+				void *socketp)
+{
+#ifdef HAVE_SYS_EPOLL_H
+	struct async_ctx *actx = ctx;
+	struct epoll_event ev = {0};
+	int			res;
+	int			op = EPOLL_CTL_ADD;
+
+	switch (what)
+	{
+		case CURL_POLL_IN:
+			ev.events = EPOLLIN;
+			break;
+
+		case CURL_POLL_OUT:
+			ev.events = EPOLLOUT;
+			break;
+
+		case CURL_POLL_INOUT:
+			ev.events = EPOLLIN | EPOLLOUT;
+			break;
+
+		case CURL_POLL_REMOVE:
+			op = EPOLL_CTL_DEL;
+			break;
+
+		default:
+			actx_error(actx, "unknown libcurl socket operation: %d", what);
+			return -1;
+	}
+
+	res = epoll_ctl(actx->mux, op, socket, &ev);
+	if (res < 0 && errno == EEXIST)
+	{
+		/* We already had this socket in the pollset. */
+		op = EPOLL_CTL_MOD;
+		res = epoll_ctl(actx->mux, op, socket, &ev);
+	}
+
+	if (res < 0)
+	{
+		switch (op)
+		{
+			case EPOLL_CTL_ADD:
+				actx_error(actx, "could not add to epoll set: %m");
+				break;
+
+			case EPOLL_CTL_DEL:
+				actx_error(actx, "could not delete from epoll set: %m");
+				break;
+
+			default:
+				actx_error(actx, "could not update epoll set: %m");
+		}
+
+		return -1;
+	}
+#endif
+#ifdef HAVE_SYS_EVENT_H
+	struct async_ctx *actx = ctx;
+	struct kevent ev[2] = {{0}};
+	struct kevent ev_out[2];
+	struct timespec timeout = {0};
+	int			nev = 0;
+	int			res;
+
+	switch (what)
+	{
+		case CURL_POLL_IN:
+			EV_SET(&ev[nev], socket, EVFILT_READ, EV_ADD | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			break;
+
+		case CURL_POLL_OUT:
+			EV_SET(&ev[nev], socket, EVFILT_WRITE, EV_ADD | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			break;
+
+		case CURL_POLL_INOUT:
+			EV_SET(&ev[nev], socket, EVFILT_READ, EV_ADD | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			EV_SET(&ev[nev], socket, EVFILT_WRITE, EV_ADD | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			break;
+
+		case CURL_POLL_REMOVE:
+
+			/*
+			 * We don't know which of these is currently registered, perhaps
+			 * both, so we try to remove both.  This means we need to tolerate
+			 * ENOENT below.
+			 */
+			EV_SET(&ev[nev], socket, EVFILT_READ, EV_DELETE | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			EV_SET(&ev[nev], socket, EVFILT_WRITE, EV_DELETE | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			break;
+
+		default:
+			actx_error(actx, "unknown libcurl socket operation: %d", what);
+			return -1;
+	}
+
+	res = kevent(actx->mux, ev, nev, ev_out, lengthof(ev_out), &timeout);
+	if (res < 0)
+	{
+		actx_error(actx, "could not modify kqueue: %m");
+		return -1;
+	}
+
+	/*
+	 * We can't use the simple errno version of kevent, because we need to
+	 * skip over ENOENT while still allowing a second change to be processed.
+	 * So we need a longer-form error checking loop.
+	 */
+	for (int i = 0; i < res; ++i)
+	{
+		/*
+		 * EV_RECEIPT should guarantee one EV_ERROR result for every change,
+		 * whether successful or not. Failed entries contain a non-zero errno
+		 * in the data field.
+		 */
+		Assert(ev_out[i].flags & EV_ERROR);
+
+		errno = ev_out[i].data;
+		if (errno && errno != ENOENT)
+		{
+			switch (what)
+			{
+				case CURL_POLL_REMOVE:
+					actx_error(actx, "could not delete from kqueue: %m");
+					break;
+				default:
+					actx_error(actx, "could not add to kqueue: %m");
+			}
+			return -1;
+		}
+	}
+#endif
+
+	return 0;
+}
+
+/*
+ * Enables or disables the timer in the multiplexer set. The timeout value is
+ * in milliseconds (negative values disable the timer). Rather than continually
+ * adding and removing the timer, we keep it in the set at all times and just
+ * disarm it when it's not needed.
+ */
+static bool
+set_timer(struct async_ctx *actx, long timeout)
+{
+#if HAVE_SYS_EPOLL_H
+	struct itimerspec spec = {0};
+
+	if (timeout < 0)
+	{
+		/* the zero itimerspec will disarm the timer below */
+	}
+	else if (timeout == 0)
+	{
+		/*
+		 * A zero timeout means libcurl wants us to call back immediately.
+		 * That's not technically an option for timerfd, but we can make the
+		 * timeout ridiculously short.
+		 */
+		spec.it_value.tv_nsec = 1;
+	}
+	else
+	{
+		spec.it_value.tv_sec = timeout / 1000;
+		spec.it_value.tv_nsec = (timeout % 1000) * 1000000;
+	}
+
+	if (timerfd_settime(actx->timerfd, 0 /* no flags */ , &spec, NULL) < 0)
+	{
+		actx_error(actx, "setting timerfd to %ld: %m", timeout);
+		return false;
+	}
+#endif
+#ifdef HAVE_SYS_EVENT_H
+	struct kevent ev;
+
+	EV_SET(&ev, 1, EVFILT_TIMER, timeout < 0 ? EV_DELETE : EV_ADD,
+		   0, timeout, 0);
+	if (kevent(actx->mux, &ev, 1, NULL, 0, NULL) < 0 && errno != ENOENT)
+	{
+		actx_error(actx, "setting kqueue timer to %ld: %m", timeout);
+		return false;
+	}
+#endif
+
+	return true;
+}
+
+/*
+ * Adds or removes timeouts from the multiplexer set, as directed by the
+ * libcurl multi handle.
+ */
+static int
+register_timer(CURLM *curlm, long timeout, void *ctx)
+{
+	struct async_ctx *actx = ctx;
+
+	/*
+	 * TODO: maybe just signal drive_request() to immediately call back in the
+	 * (timeout == 0) case?
+	 */
+	if (!set_timer(actx, timeout))
+		return -1;				/* actx_error already called */
+
+	return 0;
+}
+
+/*
+ * Prints Curl request debugging information to stderr.
+ *
+ * Note that this will expose a number of critical secrets, so users have to opt
+ * into this (see PGOAUTHDEBUG).
+ */
+static int
+debug_callback(CURL *handle, curl_infotype type, char *data, size_t size,
+			   void *clientp)
+{
+	const char *const end = data + size;
+	const char *prefix;
+
+	/* Prefixes are modeled off of the default libcurl debug output. */
+	switch (type)
+	{
+		case CURLINFO_TEXT:
+			prefix = "*";
+			break;
+
+		case CURLINFO_HEADER_IN:	/* fall through */
+		case CURLINFO_DATA_IN:
+			prefix = "<";
+			break;
+
+		case CURLINFO_HEADER_OUT:	/* fall through */
+		case CURLINFO_DATA_OUT:
+			prefix = ">";
+			break;
+
+		default:
+			return 0;
+	}
+
+	/*
+	 * Split the output into lines for readability; sometimes multiple headers
+	 * are included in a single call.
+	 */
+	while (data < end)
+	{
+		size_t		len = end - data;
+		char	   *eol = memchr(data, '\n', len);
+
+		if (eol)
+			len = eol - data + 1;
+
+		/* TODO: handle unprintables */
+		fprintf(stderr, "%s %.*s%s", prefix, (int) len, data,
+				eol ? "" : "\n");
+
+		data += len;
+	}
+
+	return 0;
+}
+
+/*
+ * Initializes the two libcurl handles in the async_ctx. The multi handle,
+ * actx->curlm, is what drives the asynchronous engine and tells us what to do
+ * next. The easy handle, actx->curl, encapsulates the state for a single
+ * request/response. It's added to the multi handle as needed, during
+ * start_request().
+ */
+static bool
+setup_curl_handles(struct async_ctx *actx)
+{
+	curl_version_info_data *curl_info;
+
+	/*
+	 * Create our multi handle. This encapsulates the entire conversation with
+	 * libcurl for this connection.
+	 */
+	actx->curlm = curl_multi_init();
+	if (!actx->curlm)
+	{
+		/* We don't get a lot of feedback on the failure reason. */
+		actx_error(actx, "failed to create libcurl multi handle");
+		return false;
+	}
+
+	/*
+	 * Extract information about the libcurl we are linked against.
+	 */
+	curl_info = curl_version_info(CURLVERSION_NOW);
+
+	/*
+	 * The multi handle tells us what to wait on using two callbacks. These
+	 * will manipulate actx->mux as needed.
+	 */
+	CHECK_MSETOPT(actx, CURLMOPT_SOCKETFUNCTION, register_socket, return false);
+	CHECK_MSETOPT(actx, CURLMOPT_SOCKETDATA, actx, return false);
+	CHECK_MSETOPT(actx, CURLMOPT_TIMERFUNCTION, register_timer, return false);
+	CHECK_MSETOPT(actx, CURLMOPT_TIMERDATA, actx, return false);
+
+	/*
+	 * Set up an easy handle. All of our requests are made serially, so we
+	 * only ever need to keep track of one.
+	 */
+	actx->curl = curl_easy_init();
+	if (!actx->curl)
+	{
+		actx_error(actx, "failed to create libcurl handle");
+		return false;
+	}
+
+	/*
+	 * Multi-threaded applications must set CURLOPT_NOSIGNAL. This requires us
+	 * to handle the possibility of SIGPIPE ourselves using pq_block_sigpipe;
+	 * see pg_fe_run_oauth_flow().
+	 */
+	CHECK_SETOPT(actx, CURLOPT_NOSIGNAL, 1L, return false);
+	if (!curl_info->ares_num)
+	{
+		/* No alternative resolver, TODO: warn about timeouts */
+	}
+
+	if (actx->debugging)
+	{
+		/*
+		 * Set a callback for retrieving error information from libcurl, the
+		 * function only takes effect when CURLOPT_VERBOSE has been set so
+		 * make sure the order is kept.
+		 */
+		CHECK_SETOPT(actx, CURLOPT_DEBUGFUNCTION, debug_callback, return false);
+		CHECK_SETOPT(actx, CURLOPT_VERBOSE, 1L, return false);
+	}
+
+	CHECK_SETOPT(actx, CURLOPT_ERRORBUFFER, actx->curl_err, return false);
+
+	/*
+	 * Only HTTPS is allowed. (Debug mode additionally allows HTTP; this is
+	 * intended for testing only.)
+	 *
+	 * There's a bit of unfortunate complexity around the choice of
+	 * CURLoption. CURLOPT_PROTOCOLS is deprecated in modern Curls, but its
+	 * replacement didn't show up until relatively recently.
+	 */
+	{
+#if CURL_AT_LEAST_VERSION(7, 85, 0)
+		const CURLoption popt = CURLOPT_PROTOCOLS_STR;
+		const char *protos = "https";
+		const char *const unsafe = "https,http";
+#else
+		const CURLoption popt = CURLOPT_PROTOCOLS;
+		long		protos = CURLPROTO_HTTPS;
+		const long	unsafe = CURLPROTO_HTTPS | CURLPROTO_HTTP;
+#endif
+
+		if (actx->debugging)
+			protos = unsafe;
+
+		CHECK_SETOPT(actx, popt, protos, return false);
+	}
+
+	/* TODO: would anyone use this in "real" situations, or just testing? */
+	if (actx->debugging)
+	{
+		const char *env;
+
+		if ((env = getenv("PGOAUTHCAFILE")) != NULL)
+			CHECK_SETOPT(actx, CURLOPT_CAINFO, env, return false);
+	}
+
+	/*
+	 * Suppress the Accept header to make our request as minimal as possible.
+	 * (Ideally we would set it to "application/json" instead, but OpenID is
+	 * pretty strict when it comes to provider behavior, so we have to check
+	 * what comes back anyway.)
+	 */
+	actx->headers = curl_slist_append(actx->headers, "Accept:");
+	if (actx->headers == NULL)
+	{
+		actx_error(actx, "out of memory");
+		return false;
+	}
+	CHECK_SETOPT(actx, CURLOPT_HTTPHEADER, actx->headers, return false);
+
+	return true;
+}
+
+/*
+ * Generic HTTP Request Handlers
+ */
+
+/*
+ * Response callback from libcurl which appends the response body into
+ * actx->work_data (see start_request()). The maximum size of the data is
+ * defined by CURL_MAX_WRITE_SIZE which by default is 16kb (and can only be
+ * changed by recompiling libcurl).
+ */
+static size_t
+append_data(char *buf, size_t size, size_t nmemb, void *userdata)
+{
+	struct async_ctx *actx = userdata;
+	PQExpBuffer resp = &actx->work_data;
+	size_t		len = size * nmemb;
+
+	/* In case we receive data over the threshold, abort the transfer */
+	if ((resp->len + len) > MAX_OAUTH_RESPONSE_SIZE)
+	{
+		actx_error(actx, "response is too large");
+		return 0;
+	}
+
+	/* The data passed from libcurl is not null-terminated */
+	appendBinaryPQExpBuffer(resp, buf, len);
+
+	/*
+	 * Signal an error in order to abort the transfer in case we ran out of
+	 * memory in accepting the data.
+	 */
+	if (PQExpBufferBroken(resp))
+	{
+		actx_error(actx, "out of memory");
+		return 0;
+	}
+
+	return len;
+}
+
+/*
+ * Begins an HTTP request on the multi handle. The caller should have set up all
+ * request-specific options on actx->curl first. The server's response body will
+ * be accumulated in actx->work_data (which will be reset, so don't store
+ * anything important there across this call).
+ *
+ * Once a request is queued, it can be driven to completion via drive_request().
+ * If actx->running is zero upon return, the request has already finished and
+ * drive_request() can be called without returning control to the client.
+ */
+static bool
+start_request(struct async_ctx *actx)
+{
+	CURLMcode	err;
+
+	resetPQExpBuffer(&actx->work_data);
+	CHECK_SETOPT(actx, CURLOPT_WRITEFUNCTION, append_data, return false);
+	CHECK_SETOPT(actx, CURLOPT_WRITEDATA, actx, return false);
+
+	err = curl_multi_add_handle(actx->curlm, actx->curl);
+	if (err)
+	{
+		actx_error(actx, "failed to queue HTTP request: %s",
+				   curl_multi_strerror(err));
+		return false;
+	}
+
+	/*
+	 * actx->running tracks the number of running handles, so we can
+	 * immediately call back if no waiting is needed.
+	 *
+	 * Even though this is nominally an asynchronous process, there are some
+	 * operations that can synchronously fail by this point (e.g. connections
+	 * to closed local ports) or even synchronously succeed if the stars align
+	 * (all the libcurl connection caches hit and the server is fast).
+	 */
+	err = curl_multi_socket_action(actx->curlm, CURL_SOCKET_TIMEOUT, 0, &actx->running);
+	if (err)
+	{
+		actx_error(actx, "asynchronous HTTP request failed: %s",
+				   curl_multi_strerror(err));
+		return false;
+	}
+
+	return true;
+}
+
+/*
+ * CURL_IGNORE_DEPRECATION was added in 7.87.0. If it's not defined, we can make
+ * it a no-op.
+ */
+#ifndef CURL_IGNORE_DEPRECATION
+#define CURL_IGNORE_DEPRECATION(x) x
+#endif
+
+/*
+ * Drives the multi handle towards completion. The caller should have already
+ * set up an asynchronous request via start_request().
+ */
+static PostgresPollingStatusType
+drive_request(struct async_ctx *actx)
+{
+	CURLMcode	err;
+	CURLMsg    *msg;
+	int			msgs_left;
+	bool		done;
+
+	if (actx->running)
+	{
+		/*---
+		 * There's an async request in progress. Pump the multi handle.
+		 *
+		 * curl_multi_socket_all() is officially deprecated, because it's
+		 * inefficient and pointless if your event loop has already handed you
+		 * the exact sockets that are ready. But that's not our use case --
+		 * our client has no way to tell us which sockets are ready. (They
+		 * don't even know there are sockets to begin with.)
+		 *
+		 * We can grab the list of triggered events from the multiplexer
+		 * ourselves, but that's effectively what curl_multi_socket_all() is
+		 * going to do. And there are currently no plans for the Curl project
+		 * to remove or break this API, so ignore the deprecation. See
+		 *
+		 *    https://curl.se/mail/lib-2024-11/0028.html
+		 *
+		 */
+		CURL_IGNORE_DEPRECATION(
+			err = curl_multi_socket_all(actx->curlm, &actx->running);
+		)
+
+		if (err)
+		{
+			actx_error(actx, "asynchronous HTTP request failed: %s",
+					   curl_multi_strerror(err));
+			return PGRES_POLLING_FAILED;
+		}
+
+		if (actx->running)
+		{
+			/* We'll come back again. */
+			return PGRES_POLLING_READING;
+		}
+	}
+
+	done = false;
+	while ((msg = curl_multi_info_read(actx->curlm, &msgs_left)) != NULL)
+	{
+		if (msg->msg != CURLMSG_DONE)
+		{
+			/*
+			 * Future libcurl versions may define new message types; we don't
+			 * know how to handle them, so we'll ignore them.
+			 */
+			continue;
+		}
+
+		/* First check the status of the request itself. */
+		if (msg->data.result != CURLE_OK)
+		{
+			/*
+			 * If a more specific error hasn't already been reported, use
+			 * libcurl's description.
+			 */
+			if (actx->errbuf.len == 0)
+				actx_error_str(actx, curl_easy_strerror(msg->data.result));
+
+			return PGRES_POLLING_FAILED;
+		}
+
+		/* Now remove the finished handle; we'll add it back later if needed. */
+		err = curl_multi_remove_handle(actx->curlm, msg->easy_handle);
+		if (err)
+		{
+			actx_error(actx, "libcurl easy handle removal failed: %s",
+					   curl_multi_strerror(err));
+			return PGRES_POLLING_FAILED;
+		}
+
+		done = true;
+	}
+
+	/* Sanity check. */
+	if (!done)
+	{
+		actx_error(actx, "no result was retrieved for the finished handle");
+		return PGRES_POLLING_FAILED;
+	}
+
+	return PGRES_POLLING_OK;
+}
+
+/*
+ * URL-Encoding Helpers
+ */
+
+/*
+ * Encodes a string using the application/x-www-form-urlencoded format, and
+ * appends it to the given buffer.
+ */
+static void
+append_urlencoded(PQExpBuffer buf, const char *s)
+{
+	char	   *escaped;
+	char	   *haystack;
+	char	   *match;
+
+	escaped = curl_easy_escape(NULL, s, 0);
+	if (!escaped)
+	{
+		termPQExpBuffer(buf);	/* mark the buffer broken */
+		return;
+	}
+
+	/*
+	 * curl_easy_escape() almost does what we want, but we need the
+	 * query-specific flavor which uses '+' instead of '%20' for spaces. The
+	 * Curl command-line tool does this with a simple search-and-replace, so
+	 * follow its lead.
+	 */
+	haystack = escaped;
+
+	while ((match = strstr(haystack, "%20")) != NULL)
+	{
+		/* Append the unmatched portion, followed by the plus sign. */
+		appendBinaryPQExpBuffer(buf, haystack, match - haystack);
+		appendPQExpBufferChar(buf, '+');
+
+		/* Keep searching after the match. */
+		haystack = match + 3 /* strlen("%20") */ ;
+	}
+
+	/* Push the remainder of the string onto the buffer. */
+	appendPQExpBufferStr(buf, haystack);
+
+	curl_free(escaped);
+}
+
+/*
+ * Convenience wrapper for encoding a single string. Returns NULL on allocation
+ * failure.
+ */
+static char *
+urlencode(const char *s)
+{
+	PQExpBufferData buf;
+
+	initPQExpBuffer(&buf);
+	append_urlencoded(&buf, s);
+
+	return PQExpBufferDataBroken(buf) ? NULL : buf.data;
+}
+
+/*
+ * Appends a key/value pair to the end of an application/x-www-form-urlencoded
+ * list.
+ */
+static void
+build_urlencoded(PQExpBuffer buf, const char *key, const char *value)
+{
+	if (buf->len)
+		appendPQExpBufferChar(buf, '&');
+
+	append_urlencoded(buf, key);
+	appendPQExpBufferChar(buf, '=');
+	append_urlencoded(buf, value);
+}
+
+/*
+ * Specific HTTP Request Handlers
+ *
+ * This is finally the beginning of the actual application logic. Generally
+ * speaking, a single request consists of a start_* and a finish_* step, with
+ * drive_request() pumping the machine in between.
+ */
+
+/*
+ * Queue an OpenID Provider Configuration Request:
+ *
+ *     https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationRequest
+ *     https://www.rfc-editor.org/rfc/rfc8414#section-3.1
+ *
+ * This is done first to get the endpoint URIs we need to contact and to make
+ * sure the provider provides a device authorization flow. finish_discovery()
+ * will fill in actx->provider.
+ */
+static bool
+start_discovery(struct async_ctx *actx, const char *discovery_uri)
+{
+	CHECK_SETOPT(actx, CURLOPT_HTTPGET, 1L, return false);
+	CHECK_SETOPT(actx, CURLOPT_URL, discovery_uri, return false);
+
+	return start_request(actx);
+}
+
+static bool
+finish_discovery(struct async_ctx *actx)
+{
+	long		response_code;
+
+	/*----
+	 * Now check the response. OIDC Discovery 1.0 is pretty strict:
+	 *
+	 *     A successful response MUST use the 200 OK HTTP status code and
+	 *     return a JSON object using the application/json content type that
+	 *     contains a set of Claims as its members that are a subset of the
+	 *     Metadata values defined in Section 3.
+	 *
+	 * Compared to standard HTTP semantics, this makes life easy -- we don't
+	 * need to worry about redirections (which would call the Issuer host
+	 * validation into question), or non-authoritative responses, or any other
+	 * complications.
+	 */
+	CHECK_GETINFO(actx, CURLINFO_RESPONSE_CODE, &response_code, return false);
+
+	if (response_code != 200)
+	{
+		actx_error(actx, "unexpected response code %ld", response_code);
+		return false;
+	}
+
+	/*
+	 * Pull the fields we care about from the document.
+	 */
+	actx->errctx = "failed to parse OpenID discovery document";
+	if (!parse_provider(actx, &actx->provider))
+		return false;			/* error message already set */
+
+	/*
+	 * Fill in any defaults for OPTIONAL/RECOMMENDED fields we care about.
+	 */
+	if (!actx->provider.grant_types_supported)
+	{
+		/*
+		 * Per Section 3, the default is ["authorization_code", "implicit"].
+		 */
+		struct curl_slist *temp = actx->provider.grant_types_supported;
+
+		temp = curl_slist_append(temp, "authorization_code");
+		if (temp)
+		{
+			temp = curl_slist_append(temp, "implicit");
+		}
+
+		if (!temp)
+		{
+			actx_error(actx, "out of memory");
+			return false;
+		}
+
+		actx->provider.grant_types_supported = temp;
+	}
+
+	return true;
+}
+
+/*
+ * Ensure that the discovery document is provided by the expected issuer.
+ * Currently, issuers are statically configured in the connection string.
+ */
+static bool
+check_issuer(struct async_ctx *actx, PGconn *conn)
+{
+	const struct provider *provider = &actx->provider;
+
+	Assert(conn->oauth_issuer_id);	/* ensured by setup_oauth_parameters() */
+	Assert(provider->issuer);	/* ensured by parse_provider() */
+
+	/*---
+	 * We require strict equality for issuer identifiers -- no path or case
+	 * normalization, no substitution of default ports and schemes, etc. This
+	 * is done to match the rules in OIDC Discovery Sec. 4.3 for config
+	 * validation:
+	 *
+	 *    The issuer value returned MUST be identical to the Issuer URL that
+	 *    was used as the prefix to /.well-known/openid-configuration to
+	 *    retrieve the configuration information.
+	 *
+	 * as well as the rules set out in RFC 9207 for avoiding mix-up attacks:
+	 *
+	 *    Clients MUST then [...] compare the result to the issuer identifier
+	 *    of the authorization server where the authorization request was
+	 *    sent to. This comparison MUST use simple string comparison as defined
+	 *    in Section 6.2.1 of [RFC3986].
+	 *
+	 * TODO: Encoding support?
+	 */
+	if (strcmp(conn->oauth_issuer_id, provider->issuer) != 0)
+	{
+		actx_error(actx,
+				   "the issuer identifier (%s) does not match oauth_issuer (%s)",
+				   provider->issuer, conn->oauth_issuer_id);
+		return false;
+	}
+
+	return true;
+}
+
+#define HTTPS_SCHEME "https://"
+#define OAUTH_GRANT_TYPE_DEVICE_CODE "urn:ietf:params:oauth:grant-type:device_code"
+
+/*
+ * Ensure that the provider supports the Device Authorization flow (i.e. it
+ * provides an authorization endpoint, and both the token and authorization
+ * endpoint URLs seem reasonable).
+ */
+static bool
+check_for_device_flow(struct async_ctx *actx)
+{
+	const struct provider *provider = &actx->provider;
+
+	Assert(provider->issuer);	/* ensured by parse_provider() */
+	Assert(provider->token_endpoint);	/* ensured by parse_provider() */
+
+	if (!provider->device_authorization_endpoint)
+	{
+		actx_error(actx,
+				   "issuer \"%s\" does not provide a device authorization endpoint",
+				   provider->issuer);
+		return false;
+	}
+
+	/*
+	 * The original implementation checked that OAUTH_GRANT_TYPE_DEVICE_CODE
+	 * was present in the discovery document's grant_types_supported list. MS
+	 * Entra does not advertise this grant type, though, and since it doesn't
+	 * make sense to stand up a device_authorization_endpoint without also
+	 * accepting device codes at the token_endpoint, that's the only thing we
+	 * currently require.
+	 */
+
+	/*
+	 * Although libcurl will fail later if the URL contains an unsupported
+	 * scheme, that error message is going to be a bit opaque. This is a
+	 * decent time to bail out if we're not using HTTPS for the endpoints
+	 * we'll use for the flow.
+	 */
+	if (!actx->debugging)
+	{
+		if (pg_strncasecmp(provider->device_authorization_endpoint,
+						   HTTPS_SCHEME, strlen(HTTPS_SCHEME)) != 0)
+		{
+			actx_error(actx,
+					   "device authorization endpoint \"%s\" must use HTTPS",
+					   provider->device_authorization_endpoint);
+			return false;
+		}
+
+		if (pg_strncasecmp(provider->token_endpoint,
+						   HTTPS_SCHEME, strlen(HTTPS_SCHEME)) != 0)
+		{
+			actx_error(actx,
+					   "token endpoint \"%s\" must use HTTPS",
+					   provider->token_endpoint);
+			return false;
+		}
+	}
+
+	return true;
+}
+
+/*
+ * Adds the client ID (and secret, if provided) to the current request, using
+ * either HTTP headers or the request body.
+ */
+static bool
+add_client_identification(struct async_ctx *actx, PQExpBuffer reqbody, PGconn *conn)
+{
+	bool		success = false;
+	char	   *username = NULL;
+	char	   *password = NULL;
+
+	if (conn->oauth_client_secret)	/* Zero-length secrets are permitted! */
+	{
+		/*----
+		 * Use HTTP Basic auth to send the client_id and secret. Per RFC 6749,
+		 * Sec. 2.3.1,
+		 *
+		 *   Including the client credentials in the request-body using the
+		 *   two parameters is NOT RECOMMENDED and SHOULD be limited to
+		 *   clients unable to directly utilize the HTTP Basic authentication
+		 *   scheme (or other password-based HTTP authentication schemes).
+		 *
+		 * Additionally:
+		 *
+		 *   The client identifier is encoded using the
+		 *   "application/x-www-form-urlencoded" encoding algorithm per Appendix
+		 *   B, and the encoded value is used as the username; the client
+		 *   password is encoded using the same algorithm and used as the
+		 *   password.
+		 *
+		 * (Appendix B modifies application/x-www-form-urlencoded by requiring
+		 * an initial UTF-8 encoding step. Since the client ID and secret must
+		 * both be 7-bit ASCII -- RFC 6749 Appendix A -- we don't worry about
+		 * that in this function.)
+		 *
+		 * client_id is not added to the request body in this case. Not only
+		 * would it be redundant, but some providers in the wild (e.g. Okta)
+		 * refuse to accept it.
+		 */
+		username = urlencode(conn->oauth_client_id);
+		password = urlencode(conn->oauth_client_secret);
+
+		if (!username || !password)
+		{
+			actx_error(actx, "out of memory");
+			goto cleanup;
+		}
+
+		CHECK_SETOPT(actx, CURLOPT_HTTPAUTH, CURLAUTH_BASIC, goto cleanup);
+		CHECK_SETOPT(actx, CURLOPT_USERNAME, username, goto cleanup);
+		CHECK_SETOPT(actx, CURLOPT_PASSWORD, password, goto cleanup);
+
+		actx->used_basic_auth = true;
+	}
+	else
+	{
+		/*
+		 * If we're not otherwise authenticating, client_id is REQUIRED in the
+		 * request body.
+		 */
+		build_urlencoded(reqbody, "client_id", conn->oauth_client_id);
+
+		CHECK_SETOPT(actx, CURLOPT_HTTPAUTH, CURLAUTH_NONE, goto cleanup);
+		actx->used_basic_auth = false;
+	}
+
+	success = true;
+
+cleanup:
+	free(username);
+	free(password);
+
+	return success;
+}
+
+/*
+ * Queue a Device Authorization Request:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc8628#section-3.1
+ *
+ * This is the second step. We ask the provider to verify the end user out of
+ * band and authorize us to act on their behalf; it will give us the required
+ * nonces for us to later poll the request status, which we'll grab in
+ * finish_device_authz().
+ */
+static bool
+start_device_authz(struct async_ctx *actx, PGconn *conn)
+{
+	const char *device_authz_uri = actx->provider.device_authorization_endpoint;
+	PQExpBuffer work_buffer = &actx->work_data;
+
+	Assert(conn->oauth_client_id);	/* ensured by setup_oauth_parameters() */
+	Assert(device_authz_uri);	/* ensured by check_for_device_flow() */
+
+	/* Construct our request body. */
+	resetPQExpBuffer(work_buffer);
+	if (conn->oauth_scope && conn->oauth_scope[0])
+		build_urlencoded(work_buffer, "scope", conn->oauth_scope);
+
+	if (!add_client_identification(actx, work_buffer, conn))
+		return false;
+
+	if (PQExpBufferBroken(work_buffer))
+	{
+		actx_error(actx, "out of memory");
+		return false;
+	}
+
+	/* Make our request. */
+	CHECK_SETOPT(actx, CURLOPT_URL, device_authz_uri, return false);
+	CHECK_SETOPT(actx, CURLOPT_COPYPOSTFIELDS, work_buffer->data, return false);
+
+	return start_request(actx);
+}
+
+static bool
+finish_device_authz(struct async_ctx *actx)
+{
+	long		response_code;
+
+	CHECK_GETINFO(actx, CURLINFO_RESPONSE_CODE, &response_code, return false);
+
+	/*
+	 * Per RFC 8628, Section 3, a successful device authorization response
+	 * uses 200 OK.
+	 */
+	if (response_code == 200)
+	{
+		actx->errctx = "failed to parse device authorization";
+		if (!parse_device_authz(actx, &actx->authz))
+			return false;		/* error message already set */
+
+		return true;
+	}
+
+	/*
+	 * The device authorization endpoint uses the same error response as the
+	 * token endpoint, so the error handling roughly follows
+	 * finish_token_request(). The key difference is that an error here is
+	 * immediately fatal.
+	 */
+	if (response_code == 400 || response_code == 401)
+	{
+		struct token_error err = {0};
+
+		if (!parse_token_error(actx, &err))
+		{
+			free_token_error(&err);
+			return false;
+		}
+
+		record_token_error(actx, &err);
+
+		free_token_error(&err);
+		return false;
+	}
+
+	/* Any other response codes are considered invalid */
+	actx_error(actx, "unexpected response code %ld", response_code);
+	return false;
+}
+
+/*
+ * Queue an Access Token Request:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc6749#section-4.1.3
+ *
+ * This is the final step. We continually poll the token endpoint to see if the
+ * user has authorized us yet. finish_token_request() will pull either the token
+ * or a (ideally temporary) error status from the provider.
+ */
+static bool
+start_token_request(struct async_ctx *actx, PGconn *conn)
+{
+	const char *token_uri = actx->provider.token_endpoint;
+	const char *device_code = actx->authz.device_code;
+	PQExpBuffer work_buffer = &actx->work_data;
+
+	Assert(conn->oauth_client_id);	/* ensured by setup_oauth_parameters() */
+	Assert(token_uri);			/* ensured by parse_provider() */
+	Assert(device_code);		/* ensured by parse_device_authz() */
+
+	/* Construct our request body. */
+	resetPQExpBuffer(work_buffer);
+	build_urlencoded(work_buffer, "device_code", device_code);
+	build_urlencoded(work_buffer, "grant_type", OAUTH_GRANT_TYPE_DEVICE_CODE);
+
+	if (!add_client_identification(actx, work_buffer, conn))
+		return false;
+
+	if (PQExpBufferBroken(work_buffer))
+	{
+		actx_error(actx, "out of memory");
+		return false;
+	}
+
+	/* Make our request. */
+	CHECK_SETOPT(actx, CURLOPT_URL, token_uri, return false);
+	CHECK_SETOPT(actx, CURLOPT_COPYPOSTFIELDS, work_buffer->data, return false);
+
+	return start_request(actx);
+}
+
+static bool
+finish_token_request(struct async_ctx *actx, struct token *tok)
+{
+	long		response_code;
+
+	CHECK_GETINFO(actx, CURLINFO_RESPONSE_CODE, &response_code, return false);
+
+	/*
+	 * Per RFC 6749, Section 5, a successful response uses 200 OK.
+	 */
+	if (response_code == 200)
+	{
+		actx->errctx = "failed to parse access token response";
+		if (!parse_access_token(actx, tok))
+			return false;		/* error message already set */
+
+		return true;
+	}
+
+	/*
+	 * An error response uses either 400 Bad Request or 401 Unauthorized.
+	 * There are references online to implementations using 403 for error
+	 * return which would violate the specification. For now we stick to the
+	 * specification but we might have to revisit this.
+	 */
+	if (response_code == 400 || response_code == 401)
+	{
+		if (!parse_token_error(actx, &tok->err))
+			return false;
+
+		return true;
+	}
+
+	/* Any other response codes are considered invalid */
+	actx_error(actx, "unexpected response code %ld", response_code);
+	return false;
+}
+
+/*
+ * Finishes the token request and examines the response. If the flow has
+ * completed, a valid token will be returned via the parameter list. Otherwise,
+ * the token parameter remains unchanged, and the caller needs to wait for
+ * another interval (which will have been increased in response to a slow_down
+ * message from the server) before starting a new token request.
+ *
+ * False is returned only for permanent error conditions.
+ */
+static bool
+handle_token_response(struct async_ctx *actx, char **token)
+{
+	bool		success = false;
+	struct token tok = {0};
+	const struct token_error *err;
+
+	if (!finish_token_request(actx, &tok))
+		goto token_cleanup;
+
+	/* A successful token request gives either a token or an in-band error. */
+	Assert(tok.access_token || tok.err.error);
+
+	if (tok.access_token)
+	{
+		*token = tok.access_token;
+		tok.access_token = NULL;
+
+		success = true;
+		goto token_cleanup;
+	}
+
+	/*
+	 * authorization_pending and slow_down are the only acceptable errors;
+	 * anything else and we bail. These are defined in RFC 8628, Sec. 3.5.
+	 */
+	err = &tok.err;
+	if (strcmp(err->error, "authorization_pending") != 0 &&
+		strcmp(err->error, "slow_down") != 0)
+	{
+		record_token_error(actx, err);
+		goto token_cleanup;
+	}
+
+	/*
+	 * A slow_down error requires us to permanently increase our retry
+	 * interval by five seconds.
+	 */
+	if (strcmp(err->error, "slow_down") == 0)
+	{
+		int			prev_interval = actx->authz.interval;
+
+		actx->authz.interval += 5;
+		if (actx->authz.interval < prev_interval)
+		{
+			actx_error(actx, "slow_down interval overflow");
+			goto token_cleanup;
+		}
+	}
+
+	success = true;
+
+token_cleanup:
+	free_token(&tok);
+	return success;
+}
+
+/*
+ * Displays a device authorization prompt for action by the end user, either via
+ * the PQauthDataHook, or by a message on standard error if no hook is set.
+ */
+static bool
+prompt_user(struct async_ctx *actx, PGconn *conn)
+{
+	int			res;
+	PGpromptOAuthDevice prompt = {
+		.verification_uri = actx->authz.verification_uri,
+		.user_code = actx->authz.user_code,
+		/* TODO: optional fields */
+	};
+
+	res = PQauthDataHook(PQAUTHDATA_PROMPT_OAUTH_DEVICE, conn, &prompt);
+
+	if (!res)
+	{
+		/*
+		 * translator: The first %s is a URL for the user to visit in a
+		 * browser, and the second %s is a code to be copy-pasted there.
+		 */
+		fprintf(stderr, libpq_gettext("Visit %s and enter the code: %s\n"),
+				prompt.verification_uri, prompt.user_code);
+	}
+	else if (res < 0)
+	{
+		actx_error(actx, "device prompt failed");
+		return false;
+	}
+
+	return true;
+}
+
+/*
+ * Calls curl_global_init() in a thread-safe way.
+ *
+ * libcurl has stringent requirements for the thread context in which you call
+ * curl_global_init(), because it's going to try initializing a bunch of other
+ * libraries (OpenSSL, Winsock, etc). Recent versions of libcurl have improved
+ * the thread-safety situation, but there's a chicken-and-egg problem at
+ * runtime: you can't check the thread safety until you've initialized libcurl,
+ * which you can't do from within a thread unless you know it's thread-safe...
+ *
+ * Returns true if initialization was successful. Successful or not, this
+ * function will not try to reinitialize Curl on successive calls.
+ */
+static bool
+initialize_curl(PGconn *conn)
+{
+	/*
+	 * Don't let the compiler play tricks with this variable. In the
+	 * HAVE_THREADSAFE_CURL_GLOBAL_INIT case, we don't care if two threads
+	 * enter simultaneously, but we do care if this gets set transiently to
+	 * PG_BOOL_YES/NO in cases where that's not the final answer.
+	 */
+	static volatile PGTernaryBool init_successful = PG_BOOL_UNKNOWN;
+#if HAVE_THREADSAFE_CURL_GLOBAL_INIT
+	curl_version_info_data *info;
+#endif
+
+#if !HAVE_THREADSAFE_CURL_GLOBAL_INIT
+
+	/*
+	 * Lock around the whole function. If a libpq client performs its own work
+	 * with libcurl, it must either ensure that Curl is initialized safely
+	 * before calling us (in which case our call will be a no-op), or else it
+	 * must guard its own calls to curl_global_init() with a registered
+	 * threadlock handler. See PQregisterThreadLock().
+	 */
+	pglock_thread();
+#endif
+
+	/*
+	 * Skip initialization if we've already done it. (Curl tracks the number
+	 * of calls; there's no point in incrementing the counter every time we
+	 * connect.)
+	 */
+	if (init_successful == PG_BOOL_YES)
+		goto done;
+	else if (init_successful == PG_BOOL_NO)
+	{
+		libpq_append_conn_error(conn,
+								"curl_global_init previously failed during OAuth setup");
+		goto done;
+	}
+
+	/*
+	 * We know we've already initialized Winsock by this point (see
+	 * pqMakeEmptyPGconn()), so we should be able to safely skip that bit. But
+	 * we have to tell libcurl to initialize everything else, because other
+	 * pieces of our client executable may already be using libcurl for their
+	 * own purposes. If we initialize libcurl with only a subset of its
+	 * features, we could break those other clients nondeterministically, and
+	 * that would probably be a nightmare to debug.
+	 *
+	 * If some other part of the program has already called this, it's a
+	 * no-op.
+	 */
+	if (curl_global_init(CURL_GLOBAL_ALL & ~CURL_GLOBAL_WIN32) != CURLE_OK)
+	{
+		libpq_append_conn_error(conn,
+								"curl_global_init failed during OAuth setup");
+		init_successful = PG_BOOL_NO;
+		goto done;
+	}
+
+#if HAVE_THREADSAFE_CURL_GLOBAL_INIT
+
+	/*
+	 * If we determined at configure time that the Curl installation is
+	 * threadsafe, our job here is much easier. We simply initialize above
+	 * without any locking (concurrent or duplicated calls are fine in that
+	 * situation), then double-check to make sure the runtime setting agrees,
+	 * to try to catch silent downgrades.
+	 */
+	info = curl_version_info(CURLVERSION_NOW);
+	if (!(info->features & CURL_VERSION_THREADSAFE))
+	{
+		/*
+		 * 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 threadsafe\n"
+								"\tCurl initialization was reported threadsafe 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;
+	}
+#endif
+
+	init_successful = PG_BOOL_YES;
+
+done:
+#if !HAVE_THREADSAFE_CURL_GLOBAL_INIT
+	pgunlock_thread();
+#endif
+	return (init_successful == PG_BOOL_YES);
+}
+
+/*
+ * The core nonblocking libcurl implementation. This will be called several
+ * times to pump the async engine.
+ *
+ * The architecture is based on PQconnectPoll(). The first half drives the
+ * connection state forward as necessary, returning if we're not ready to
+ * proceed to the next step yet. The second half performs the actual transition
+ * between states.
+ *
+ * You can trace the overall OAuth flow through the second half. It's linear
+ * until we get to the end, where we flip back and forth between
+ * OAUTH_STEP_TOKEN_REQUEST and OAUTH_STEP_WAIT_INTERVAL to regularly ping the
+ * provider.
+ */
+static PostgresPollingStatusType
+pg_fe_run_oauth_flow_impl(PGconn *conn)
+{
+	fe_oauth_state *state = conn->sasl_state;
+	struct async_ctx *actx;
+
+	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;
+#ifdef HAVE_SYS_EPOLL_H
+		actx->timerfd = -1;
+#endif
+
+		/* 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. */
+		conn->altsock = actx->mux;
+
+		switch (actx->step)
+		{
+			case OAUTH_STEP_INIT:
+				break;
+
+			case OAUTH_STEP_DISCOVERY:
+			case OAUTH_STEP_DEVICE_AUTHORIZATION:
+			case OAUTH_STEP_TOKEN_REQUEST:
+				{
+					PostgresPollingStatusType status;
+
+					status = drive_request(actx);
+
+					if (status == PGRES_POLLING_FAILED)
+						goto error_return;
+					else if (status != PGRES_POLLING_OK)
+					{
+						/* not done yet */
+						return status;
+					}
+				}
+
+			case OAUTH_STEP_WAIT_INTERVAL:
+				/* TODO check that the timer has expired */
+				break;
+		}
+
+		/*
+		 * Each case here must ensure that actx->running is set while we're
+		 * waiting on some asynchronous work. Most cases rely on
+		 * start_request() to do that for them.
+		 */
+		switch (actx->step)
+		{
+			case OAUTH_STEP_INIT:
+				actx->errctx = "failed to fetch OpenID discovery document";
+				if (!start_discovery(actx, conn->oauth_discovery_uri))
+					goto error_return;
+
+				actx->step = OAUTH_STEP_DISCOVERY;
+				break;
+
+			case OAUTH_STEP_DISCOVERY:
+				if (!finish_discovery(actx))
+					goto error_return;
+
+				if (!check_issuer(actx, conn))
+					goto error_return;
+
+				actx->errctx = "cannot run OAuth device authorization";
+				if (!check_for_device_flow(actx))
+					goto error_return;
+
+				actx->errctx = "failed to obtain device authorization";
+				if (!start_device_authz(actx, conn))
+					goto error_return;
+
+				actx->step = OAUTH_STEP_DEVICE_AUTHORIZATION;
+				break;
+
+			case OAUTH_STEP_DEVICE_AUTHORIZATION:
+				if (!finish_device_authz(actx))
+					goto error_return;
+
+				actx->errctx = "failed to obtain access token";
+				if (!start_token_request(actx, conn))
+					goto error_return;
+
+				actx->step = OAUTH_STEP_TOKEN_REQUEST;
+				break;
+
+			case OAUTH_STEP_TOKEN_REQUEST:
+				if (!handle_token_response(actx, &conn->oauth_token))
+					goto error_return;
+
+				if (!actx->user_prompted)
+				{
+					/*
+					 * Now that we know the token endpoint isn't broken, give
+					 * the user the login instructions.
+					 */
+					if (!prompt_user(actx, conn))
+						goto error_return;
+
+					actx->user_prompted = true;
+				}
+
+				if (conn->oauth_token)
+					break;		/* done! */
+
+				/*
+				 * Wait for the required interval before issuing the next
+				 * request.
+				 */
+				if (!set_timer(actx, actx->authz.interval * 1000))
+					goto error_return;
+
+#ifdef HAVE_SYS_EPOLL_H
+
+				/*
+				 * No Curl requests are running, so we can simplify by having
+				 * the client wait directly on the timerfd rather than the
+				 * multiplexer. (This isn't possible for kqueue.)
+				 */
+				conn->altsock = actx->timerfd;
+#endif
+
+				actx->step = OAUTH_STEP_WAIT_INTERVAL;
+				actx->running = 1;
+				break;
+
+			case OAUTH_STEP_WAIT_INTERVAL:
+				actx->errctx = "failed to obtain access token";
+				if (!start_token_request(actx, conn))
+					goto error_return;
+
+				actx->step = OAUTH_STEP_TOKEN_REQUEST;
+				break;
+		}
+
+		/*
+		 * The vast majority of the time, if we don't have a token at this
+		 * point, actx->running will be set. But there are some corner cases
+		 * where we can immediately loop back around; see start_request().
+		 */
+	} while (!conn->oauth_token && !actx->running);
+
+	/* If we've stored a token, we're done. Otherwise come back later. */
+	return conn->oauth_token ? PGRES_POLLING_OK : PGRES_POLLING_READING;
+
+error_return:
+
+	/*
+	 * Assemble the three parts of our error: context, body, and detail. See
+	 * also the documentation for struct async_ctx.
+	 */
+	if (actx->errctx)
+	{
+		appendPQExpBufferStr(&conn->errorMessage,
+							 libpq_gettext(actx->errctx));
+		appendPQExpBufferStr(&conn->errorMessage, ": ");
+	}
+
+	if (PQExpBufferDataBroken(actx->errbuf))
+		appendPQExpBufferStr(&conn->errorMessage,
+							 libpq_gettext("out of memory"));
+	else
+		appendPQExpBufferStr(&conn->errorMessage, actx->errbuf.data);
+
+	if (actx->curl_err[0])
+	{
+		size_t		len;
+
+		appendPQExpBuffer(&conn->errorMessage, " (%s)", actx->curl_err);
+
+		/* Sometimes libcurl adds a newline to the error buffer. :( */
+		len = conn->errorMessage.len;
+		if (len >= 2 && conn->errorMessage.data[len - 2] == '\n')
+		{
+			conn->errorMessage.data[len - 2] = ')';
+			conn->errorMessage.data[len - 1] = '\0';
+			conn->errorMessage.len--;
+		}
+	}
+
+	appendPQExpBufferStr(&conn->errorMessage, "\n");
+
+	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.
+ */
+PostgresPollingStatusType
+pg_fe_run_oauth_flow(PGconn *conn)
+{
+	PostgresPollingStatusType result;
+#ifndef WIN32
+	sigset_t	osigset;
+	bool		sigpipe_pending;
+	bool		masked;
+
+	/*---
+	 * Ignore SIGPIPE on this thread during all Curl processing.
+	 *
+	 * Because we support multiple threads, we have to set up libcurl with
+	 * CURLOPT_NOSIGNAL, which disables its default global handling of
+	 * SIGPIPE. From the Curl docs:
+	 *
+	 *     libcurl makes an effort to never cause such SIGPIPE signals to
+	 *     trigger, but some operating systems have no way to avoid them and
+	 *     even on those that have there are some corner cases when they may
+	 *     still happen, contrary to our desire.
+	 *
+	 * Note that libcurl is also at the mercy of its DNS resolution and SSL
+	 * libraries; if any of them forget a MSG_NOSIGNAL then we're in trouble.
+	 * Modern platforms and libraries seem to get it right, so this is a
+	 * difficult corner case to exercise in practice, and unfortunately it's
+	 * not really clear whether it's necessary in all cases.
+	 */
+	masked = (pq_block_sigpipe(&osigset, &sigpipe_pending) == 0);
+#endif
+
+	result = pg_fe_run_oauth_flow_impl(conn);
+
+#ifndef WIN32
+	if (masked)
+	{
+		/*
+		 * Undo the SIGPIPE mask. Assume we may have gotten EPIPE (we have no
+		 * way of knowing at this level).
+		 */
+		pq_reset_sigpipe(&osigset, sigpipe_pending, true /* EPIPE, maybe */ );
+	}
+#endif
+
+	return result;
+}
diff --git a/src/interfaces/libpq/fe-auth-oauth.c b/src/interfaces/libpq/fe-auth-oauth.c
new file mode 100644
index 00000000000..cc53e2bdd1a
--- /dev/null
+++ b/src/interfaces/libpq/fe-auth-oauth.c
@@ -0,0 +1,1141 @@
+/*-------------------------------------------------------------------------
+ *
+ * fe-auth-oauth.c
+ *	   The front-end (client) implementation of OAuth/OIDC authentication
+ *	   using the SASL OAUTHBEARER mechanism.
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/interfaces/libpq/fe-auth-oauth.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres_fe.h"
+
+#include "common/base64.h"
+#include "common/hmac.h"
+#include "common/jsonapi.h"
+#include "common/oauth-common.h"
+#include "fe-auth.h"
+#include "fe-auth-oauth.h"
+#include "mb/pg_wchar.h"
+
+/* The exported OAuth callback mechanism. */
+static void *oauth_init(PGconn *conn, const char *password,
+						const char *sasl_mechanism);
+static SASLStatus oauth_exchange(void *opaq, bool final,
+								 char *input, int inputlen,
+								 char **output, int *outputlen);
+static bool oauth_channel_bound(void *opaq);
+static void oauth_free(void *opaq);
+
+const pg_fe_sasl_mech pg_oauth_mech = {
+	oauth_init,
+	oauth_exchange,
+	oauth_channel_bound,
+	oauth_free,
+};
+
+/*
+ * Initializes mechanism state for OAUTHBEARER.
+ *
+ * For a full description of the API, see libpq/fe-auth-sasl.h.
+ */
+static void *
+oauth_init(PGconn *conn, const char *password,
+		   const char *sasl_mechanism)
+{
+	fe_oauth_state *state;
+
+	/*
+	 * We only support one SASL mechanism here; anything else is programmer
+	 * error.
+	 */
+	Assert(sasl_mechanism != NULL);
+	Assert(strcmp(sasl_mechanism, OAUTHBEARER_NAME) == 0);
+
+	state = calloc(1, sizeof(*state));
+	if (!state)
+		return NULL;
+
+	state->step = FE_OAUTH_INIT;
+	state->conn = conn;
+
+	return state;
+}
+
+/*
+ * Frees the state allocated by oauth_init().
+ *
+ * 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().
+ */
+static void
+oauth_free(void *opaq)
+{
+	fe_oauth_state *state = opaq;
+
+	/* Any async authentication state should have been cleaned up already. */
+	Assert(!state->async_ctx);
+
+	free(state);
+}
+
+#define kvsep "\x01"
+
+/*
+ * Constructs an OAUTHBEARER client initial response (RFC 7628, Sec. 3.1).
+ *
+ * If discover is true, the initial response will contain a request for the
+ * server's required OAuth parameters (Sec. 4.3). Otherwise, conn->token must
+ * be set; it will be sent as the connection's bearer token.
+ *
+ * Returns the response as a null-terminated string, or NULL on error.
+ */
+static char *
+client_initial_response(PGconn *conn, bool discover)
+{
+	static const char *const resp_format = "n,," kvsep "auth=%s%s" kvsep kvsep;
+
+	PQExpBufferData buf;
+	const char *authn_scheme;
+	char	   *response = NULL;
+	const char *token = conn->oauth_token;
+
+	if (discover)
+	{
+		/* Parameter discovery uses a completely empty auth value. */
+		authn_scheme = token = "";
+	}
+	else
+	{
+		/*
+		 * Use a Bearer authentication scheme (RFC 6750, Sec. 2.1). A trailing
+		 * space is used as a separator.
+		 */
+		authn_scheme = "Bearer ";
+
+		/* conn->token must have been set in this case. */
+		if (!token)
+		{
+			Assert(false);
+			libpq_append_conn_error(conn,
+									"internal error: no OAuth token was set for the connection");
+			return NULL;
+		}
+	}
+
+	initPQExpBuffer(&buf);
+	appendPQExpBuffer(&buf, resp_format, authn_scheme, token);
+
+	if (!PQExpBufferDataBroken(buf))
+		response = strdup(buf.data);
+	termPQExpBuffer(&buf);
+
+	if (!response)
+		libpq_append_conn_error(conn, "out of memory");
+
+	return response;
+}
+
+/*
+ * JSON Parser (for the OAUTHBEARER error result)
+ */
+
+/* Relevant JSON fields in the error result object. */
+#define ERROR_STATUS_FIELD "status"
+#define ERROR_SCOPE_FIELD "scope"
+#define ERROR_OPENID_CONFIGURATION_FIELD "openid-configuration"
+
+struct json_ctx
+{
+	char	   *errmsg;			/* any non-NULL value stops all processing */
+	PQExpBufferData errbuf;		/* backing memory for errmsg */
+	int			nested;			/* nesting level (zero is the top) */
+
+	const char *target_field_name;	/* points to a static allocation */
+	char	  **target_field;	/* see below */
+
+	/* target_field, if set, points to one of the following: */
+	char	   *status;
+	char	   *scope;
+	char	   *discovery_uri;
+};
+
+#define oauth_json_has_error(ctx) \
+	(PQExpBufferDataBroken((ctx)->errbuf) || (ctx)->errmsg)
+
+#define oauth_json_set_error(ctx, ...) \
+	do { \
+		appendPQExpBuffer(&(ctx)->errbuf, __VA_ARGS__); \
+		(ctx)->errmsg = (ctx)->errbuf.data; \
+	} while (0)
+
+static JsonParseErrorType
+oauth_json_object_start(void *state)
+{
+	struct json_ctx *ctx = state;
+
+	if (ctx->target_field)
+	{
+		Assert(ctx->nested == 1);
+
+		oauth_json_set_error(ctx,
+							 libpq_gettext("field \"%s\" must be a string"),
+							 ctx->target_field_name);
+	}
+
+	++ctx->nested;
+	return oauth_json_has_error(ctx) ? JSON_SEM_ACTION_FAILED : JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_object_end(void *state)
+{
+	struct json_ctx *ctx = state;
+
+	--ctx->nested;
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_object_field_start(void *state, char *name, bool isnull)
+{
+	struct json_ctx *ctx = state;
+
+	if (ctx->nested == 1)
+	{
+		if (strcmp(name, ERROR_STATUS_FIELD) == 0)
+		{
+			ctx->target_field_name = ERROR_STATUS_FIELD;
+			ctx->target_field = &ctx->status;
+		}
+		else if (strcmp(name, ERROR_SCOPE_FIELD) == 0)
+		{
+			ctx->target_field_name = ERROR_SCOPE_FIELD;
+			ctx->target_field = &ctx->scope;
+		}
+		else if (strcmp(name, ERROR_OPENID_CONFIGURATION_FIELD) == 0)
+		{
+			ctx->target_field_name = ERROR_OPENID_CONFIGURATION_FIELD;
+			ctx->target_field = &ctx->discovery_uri;
+		}
+	}
+
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_array_start(void *state)
+{
+	struct json_ctx *ctx = state;
+
+	if (!ctx->nested)
+	{
+		ctx->errmsg = libpq_gettext("top-level element must be an object");
+	}
+	else if (ctx->target_field)
+	{
+		Assert(ctx->nested == 1);
+
+		oauth_json_set_error(ctx,
+							 libpq_gettext("field \"%s\" must be a string"),
+							 ctx->target_field_name);
+	}
+
+	return oauth_json_has_error(ctx) ? JSON_SEM_ACTION_FAILED : JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_scalar(void *state, char *token, JsonTokenType type)
+{
+	struct json_ctx *ctx = state;
+
+	if (!ctx->nested)
+	{
+		ctx->errmsg = libpq_gettext("top-level element must be an object");
+		return JSON_SEM_ACTION_FAILED;
+	}
+
+	if (ctx->target_field)
+	{
+		Assert(ctx->nested == 1);
+
+		/*
+		 * We don't allow duplicate field names; error out if the target has
+		 * already been set.
+		 */
+		if (*ctx->target_field)
+		{
+			oauth_json_set_error(ctx,
+								 libpq_gettext("field \"%s\" is duplicated"),
+								 ctx->target_field_name);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		/* The only fields we support are strings. */
+		if (type != JSON_TOKEN_STRING)
+		{
+			oauth_json_set_error(ctx,
+								 libpq_gettext("field \"%s\" must be a string"),
+								 ctx->target_field_name);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		*ctx->target_field = strdup(token);
+		if (!*ctx->target_field)
+			return JSON_OUT_OF_MEMORY;
+
+		ctx->target_field = NULL;
+		ctx->target_field_name = NULL;
+	}
+	else
+	{
+		/* otherwise we just ignore it */
+	}
+
+	return JSON_SUCCESS;
+}
+
+#define HTTPS_SCHEME "https://"
+#define HTTP_SCHEME "http://"
+
+/* We support both well-known suffixes defined by RFC 8414. */
+#define WK_PREFIX "/.well-known/"
+#define OPENID_WK_SUFFIX "openid-configuration"
+#define OAUTH_WK_SUFFIX "oauth-authorization-server"
+
+/*
+ * Derives an issuer identifier from one of our recognized .well-known URIs,
+ * using the rules in RFC 8414.
+ */
+static char *
+issuer_from_well_known_uri(PGconn *conn, const char *wkuri)
+{
+	const char *authority_start = NULL;
+	const char *wk_start;
+	const char *wk_end;
+	char	   *issuer;
+	ptrdiff_t	start_offset,
+				end_offset;
+	size_t		end_len;
+
+	/*
+	 * https:// is required for issuer identifiers (RFC 8414, Sec. 2; OIDC
+	 * Discovery 1.0, Sec. 3). This is a case-insensitive comparison at this
+	 * level (but issuer identifier comparison at the level above this is
+	 * case-sensitive, so in practice it's probably moot).
+	 */
+	if (pg_strncasecmp(wkuri, HTTPS_SCHEME, strlen(HTTPS_SCHEME)) == 0)
+		authority_start = wkuri + strlen(HTTPS_SCHEME);
+
+	if (!authority_start
+		&& oauth_unsafe_debugging_enabled()
+		&& pg_strncasecmp(wkuri, HTTP_SCHEME, strlen(HTTP_SCHEME)) == 0)
+	{
+		/* Allow http:// for testing only. */
+		authority_start = wkuri + strlen(HTTP_SCHEME);
+	}
+
+	if (!authority_start)
+	{
+		libpq_append_conn_error(conn,
+								"OAuth discovery URI \"%s\" must use HTTPS",
+								wkuri);
+		return NULL;
+	}
+
+	/*
+	 * Well-known URIs in general may support queries and fragments, but the
+	 * two types we support here do not. (They must be constructed from the
+	 * components of issuer identifiers, which themselves may not contain any
+	 * queries or fragments.)
+	 *
+	 * It's important to check this first, to avoid getting tricked later by a
+	 * prefix buried inside a query or fragment.
+	 */
+	if (strpbrk(authority_start, "?#") != NULL)
+	{
+		libpq_append_conn_error(conn,
+								"OAuth discovery URI \"%s\" must not contain query or fragment components",
+								wkuri);
+		return NULL;
+	}
+
+	/*
+	 * Find the start of the .well-known prefix. IETF rules (RFC 8615) state
+	 * this must be at the beginning of the path component, but OIDC defined
+	 * it at the end instead (OIDC Discovery 1.0, Sec. 4), so we have to
+	 * search for it anywhere.
+	 */
+	wk_start = strstr(authority_start, WK_PREFIX);
+	if (!wk_start)
+	{
+		libpq_append_conn_error(conn,
+								"OAuth discovery URI \"%s\" is not a .well-known URI",
+								wkuri);
+		return NULL;
+	}
+
+	/*
+	 * Now find the suffix type. We only support the two defined in OIDC
+	 * Discovery 1.0 and RFC 8414.
+	 */
+	wk_end = wk_start + strlen(WK_PREFIX);
+
+	if (strncmp(wk_end, OPENID_WK_SUFFIX, strlen(OPENID_WK_SUFFIX)) == 0)
+		wk_end += strlen(OPENID_WK_SUFFIX);
+	else if (strncmp(wk_end, OAUTH_WK_SUFFIX, strlen(OAUTH_WK_SUFFIX)) == 0)
+		wk_end += strlen(OAUTH_WK_SUFFIX);
+	else
+		wk_end = NULL;
+
+	/*
+	 * Even if there's a match, we still need to check to make sure the suffix
+	 * takes up the entire path segment, to weed out constructions like
+	 * "/.well-known/openid-configuration-bad".
+	 */
+	if (!wk_end || (*wk_end != '/' && *wk_end != '\0'))
+	{
+		libpq_append_conn_error(conn,
+								"OAuth discovery URI \"%s\" uses an unsupported .well-known suffix",
+								wkuri);
+		return NULL;
+	}
+
+	/*
+	 * Finally, make sure the .well-known components are provided either as a
+	 * prefix (IETF style) or as a postfix (OIDC style). In other words,
+	 * "https://localhost/a/.well-known/openid-configuration/b" is not allowed
+	 * to claim association with "https://localhost/a/b".
+	 */
+	if (*wk_end != '\0')
+	{
+		/*
+		 * It's not at the end, so it's required to be at the beginning at the
+		 * path. Find the starting slash.
+		 */
+		const char *path_start;
+
+		path_start = strchr(authority_start, '/');
+		Assert(path_start);		/* otherwise we wouldn't have found WK_PREFIX */
+
+		if (wk_start != path_start)
+		{
+			libpq_append_conn_error(conn,
+									"OAuth discovery URI \"%s\" uses an invalid format",
+									wkuri);
+			return NULL;
+		}
+	}
+
+	/* Checks passed! Now build the issuer. */
+	issuer = strdup(wkuri);
+	if (!issuer)
+	{
+		libpq_append_conn_error(conn, "out of memory");
+		return NULL;
+	}
+
+	/*
+	 * The .well-known components are from [wk_start, wk_end). Remove those to
+	 * form the issuer ID, by shifting the path suffix (which may be empty)
+	 * leftwards.
+	 */
+	start_offset = wk_start - wkuri;
+	end_offset = wk_end - wkuri;
+	end_len = strlen(wk_end) + 1;	/* move the NULL terminator too */
+
+	memmove(issuer + start_offset, issuer + end_offset, end_len);
+
+	return issuer;
+}
+
+/*
+ * Parses the server error result (RFC 7628, Sec. 3.2.2) contained in msg and
+ * stores any discovered openid_configuration and scope settings for the
+ * connection.
+ */
+static bool
+handle_oauth_sasl_error(PGconn *conn, const char *msg, int msglen)
+{
+	JsonLexContext lex = {0};
+	JsonSemAction sem = {0};
+	JsonParseErrorType err;
+	struct json_ctx ctx = {0};
+	char	   *errmsg = NULL;
+	bool		success = false;
+
+	Assert(conn->oauth_issuer_id);	/* ensured by setup_oauth_parameters() */
+
+	/* Sanity check. */
+	if (strlen(msg) != msglen)
+	{
+		libpq_append_conn_error(conn,
+								"server's error message contained an embedded NULL, and was discarded");
+		return false;
+	}
+
+	/*
+	 * pg_parse_json doesn't validate the incoming UTF-8, so we have to check
+	 * that up front.
+	 */
+	if (pg_encoding_verifymbstr(PG_UTF8, msg, msglen) != msglen)
+	{
+		libpq_append_conn_error(conn,
+								"server's error response is not valid UTF-8");
+		return false;
+	}
+
+	makeJsonLexContextCstringLen(&lex, msg, msglen, PG_UTF8, true);
+	setJsonLexContextOwnsTokens(&lex, true);	/* must not leak on error */
+
+	initPQExpBuffer(&ctx.errbuf);
+	sem.semstate = &ctx;
+
+	sem.object_start = oauth_json_object_start;
+	sem.object_end = oauth_json_object_end;
+	sem.object_field_start = oauth_json_object_field_start;
+	sem.array_start = oauth_json_array_start;
+	sem.scalar = oauth_json_scalar;
+
+	err = pg_parse_json(&lex, &sem);
+
+	if (err == JSON_SEM_ACTION_FAILED)
+	{
+		if (PQExpBufferDataBroken(ctx.errbuf))
+			errmsg = libpq_gettext("out of memory");
+		else if (ctx.errmsg)
+			errmsg = ctx.errmsg;
+		else
+		{
+			/*
+			 * Developer error: one of the action callbacks didn't call
+			 * oauth_json_set_error() before erroring out.
+			 */
+			Assert(oauth_json_has_error(&ctx));
+			errmsg = "<unexpected empty error>";
+		}
+	}
+	else if (err != JSON_SUCCESS)
+		errmsg = json_errdetail(err, &lex);
+
+	if (errmsg)
+		libpq_append_conn_error(conn,
+								"failed to parse server's error response: %s",
+								errmsg);
+
+	/* Don't need the error buffer or the JSON lexer anymore. */
+	termPQExpBuffer(&ctx.errbuf);
+	freeJsonLexContext(&lex);
+
+	if (errmsg)
+		goto cleanup;
+
+	if (ctx.discovery_uri)
+	{
+		char	   *discovery_issuer;
+
+		/*
+		 * The URI MUST correspond to our existing issuer, to avoid mix-ups.
+		 *
+		 * Issuer comparison is done byte-wise, rather than performing any URL
+		 * normalization; this follows the suggestions for issuer comparison
+		 * in RFC 9207 Sec. 2.4 (which requires simple string comparison) and
+		 * vastly simplifies things. Since this is the key protection against
+		 * a rogue server sending the client to an untrustworthy location,
+		 * simpler is better.
+		 */
+		discovery_issuer = issuer_from_well_known_uri(conn, ctx.discovery_uri);
+		if (!discovery_issuer)
+			goto cleanup;		/* error message already set */
+
+		if (strcmp(conn->oauth_issuer_id, discovery_issuer) != 0)
+		{
+			libpq_append_conn_error(conn,
+									"server's discovery document at %s (issuer \"%s\") is incompatible with oauth_issuer (%s)",
+									ctx.discovery_uri, discovery_issuer,
+									conn->oauth_issuer_id);
+
+			free(discovery_issuer);
+			goto cleanup;
+		}
+
+		free(discovery_issuer);
+
+		if (!conn->oauth_discovery_uri)
+		{
+			conn->oauth_discovery_uri = ctx.discovery_uri;
+			ctx.discovery_uri = NULL;
+		}
+		else
+		{
+			/* This must match the URI we'd previously determined. */
+			if (strcmp(conn->oauth_discovery_uri, ctx.discovery_uri) != 0)
+			{
+				libpq_append_conn_error(conn,
+										"server's discovery document has moved to %s (previous location was %s)",
+										ctx.discovery_uri,
+										conn->oauth_discovery_uri);
+				goto cleanup;
+			}
+		}
+	}
+
+	if (ctx.scope)
+	{
+		/* Servers may not override a previously set oauth_scope. */
+		if (!conn->oauth_scope)
+		{
+			conn->oauth_scope = ctx.scope;
+			ctx.scope = NULL;
+		}
+	}
+
+	if (!ctx.status)
+	{
+		libpq_append_conn_error(conn,
+								"server sent error response without a status");
+		goto cleanup;
+	}
+
+	if (strcmp(ctx.status, "invalid_token") != 0)
+	{
+		/*
+		 * invalid_token is the only error code we'll automatically retry for;
+		 * otherwise, just bail out now.
+		 */
+		libpq_append_conn_error(conn,
+								"server rejected OAuth bearer token: %s",
+								ctx.status);
+		goto cleanup;
+	}
+
+	success = true;
+
+cleanup:
+	free(ctx.status);
+	free(ctx.scope);
+	free(ctx.discovery_uri);
+
+	return success;
+}
+
+/*
+ * Callback implementation of conn->async_auth() for a user-defined OAuth flow.
+ * Delegates the retrieval of the token to the application's 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_*
+ * statuses for use by PQconnectPoll().
+ */
+static PostgresPollingStatusType
+run_user_oauth_flow(PGconn *conn)
+{
+	fe_oauth_state *state = conn->sasl_state;
+	PGoauthBearerRequest *request = state->async_ctx;
+	PostgresPollingStatusType status;
+
+	if (!request->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);
+	if (status == PGRES_POLLING_FAILED)
+	{
+		libpq_append_conn_error(conn, "user-defined OAuth flow failed");
+		return status;
+	}
+	else if (status == PGRES_POLLING_OK)
+	{
+		/*
+		 * 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.)
+		 */
+		if (!request->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);
+		if (!conn->oauth_token)
+		{
+			libpq_append_conn_error(conn, "out of memory");
+			return PGRES_POLLING_FAILED;
+		}
+
+		return PGRES_POLLING_OK;
+	}
+
+	/* The hook wants the client to poll the altsock. Make sure it set one. */
+	if (conn->altsock == PGINVALID_SOCKET)
+	{
+		libpq_append_conn_error(conn,
+								"user-defined OAuth flow did not provide a socket for polling");
+		return PGRES_POLLING_FAILED;
+	}
+
+	return status;
+}
+
+/*
+ * Cleanup callback for the async user flow. Delegates most of its job to the
+ * user-provided cleanup implementation, then disconnects the altsock.
+ */
+static void
+cleanup_user_oauth_flow(PGconn *conn)
+{
+	fe_oauth_state *state = conn->sasl_state;
+	PGoauthBearerRequest *request = state->async_ctx;
+
+	Assert(request);
+
+	if (request->cleanup)
+		request->cleanup(conn, request);
+	conn->altsock = PGINVALID_SOCKET;
+
+	free(request);
+	state->async_ctx = NULL;
+}
+
+/*
+ * Chooses an OAuth client flow for the connection, which will retrieve a Bearer
+ * 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
+ * asynchronous callbacks which will be managed by run_user_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.)
+ *
+ * If neither a custom handler nor the builtin flow is available, the connection
+ * fails here.
+ */
+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,
+	};
+
+	Assert(request.openid_configuration);
+
+	/* The client may have overridden the OAuth flow. */
+	res = PQauthDataHook(PQAUTHDATA_OAUTH_BEARER_TOKEN, conn, &request);
+	if (res > 0)
+	{
+		PGoauthBearerRequest *request_copy;
+
+		if (request.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);
+			if (!conn->oauth_token)
+			{
+				libpq_append_conn_error(conn, "out of memory");
+				goto fail;
+			}
+
+			/* short-circuit */
+			if (request.cleanup)
+				request.cleanup(conn, &request);
+			return true;
+		}
+
+		request_copy = malloc(sizeof(*request_copy));
+		if (!request_copy)
+		{
+			libpq_append_conn_error(conn, "out of memory");
+			goto fail;
+		}
+
+		memcpy(request_copy, &request, sizeof(request));
+
+		conn->async_auth = run_user_oauth_flow;
+		conn->cleanup_async_auth = cleanup_user_oauth_flow;
+		state->async_ctx = request_copy;
+	}
+	else if (res < 0)
+	{
+		libpq_append_conn_error(conn, "user-defined OAuth flow failed");
+		goto fail;
+	}
+	else
+	{
+#if USE_LIBCURL
+		/* Hand off to our built-in OAuth flow. */
+		conn->async_auth = pg_fe_run_oauth_flow;
+		conn->cleanup_async_auth = pg_fe_cleanup_oauth_flow;
+
+#else
+		libpq_append_conn_error(conn, "no custom OAuth flows are available, and libpq was not built with libcurl support");
+		goto fail;
+
+#endif
+	}
+
+	return true;
+
+fail:
+	if (request.cleanup)
+		request.cleanup(conn, &request);
+	return false;
+}
+
+/*
+ * Fill in our issuer identifier and discovery URI, if possible, using the
+ * connection parameters. If conn->oauth_discovery_uri can't be populated in
+ * this function, it will be requested from the server.
+ */
+static bool
+setup_oauth_parameters(PGconn *conn)
+{
+	/*---
+	 * To talk to a server, we require the user to provide issuer and client
+	 * identifiers.
+	 *
+	 * While it's possible for an OAuth client to support multiple issuers, it
+	 * requires additional effort to make sure the flows in use are safe -- to
+	 * quote RFC 9207,
+	 *
+	 *     OAuth clients that interact with only one authorization server are
+	 *     not vulnerable to mix-up attacks. However, when such clients decide
+	 *     to add support for a second authorization server in the future, they
+	 *     become vulnerable and need to apply countermeasures to mix-up
+	 *     attacks.
+	 *
+	 * For now, we allow only one.
+	 */
+	if (!conn->oauth_issuer || !conn->oauth_client_id)
+	{
+		libpq_append_conn_error(conn,
+								"server requires OAuth authentication, but oauth_issuer and oauth_client_id are not both set");
+		return false;
+	}
+
+	/*
+	 * oauth_issuer is interpreted differently if it's a well-known discovery
+	 * URI rather than just an issuer identifier.
+	 */
+	if (strstr(conn->oauth_issuer, WK_PREFIX) != NULL)
+	{
+		/*
+		 * Convert the URI back to an issuer identifier. (This also performs
+		 * validation of the URI format.)
+		 */
+		conn->oauth_issuer_id = issuer_from_well_known_uri(conn,
+														   conn->oauth_issuer);
+		if (!conn->oauth_issuer_id)
+			return false;		/* error message already set */
+
+		conn->oauth_discovery_uri = strdup(conn->oauth_issuer);
+		if (!conn->oauth_discovery_uri)
+		{
+			libpq_append_conn_error(conn, "out of memory");
+			return false;
+		}
+	}
+	else
+	{
+		/*
+		 * Treat oauth_issuer as an issuer identifier. We'll ask the server
+		 * for the discovery URI.
+		 */
+		conn->oauth_issuer_id = strdup(conn->oauth_issuer);
+		if (!conn->oauth_issuer_id)
+		{
+			libpq_append_conn_error(conn, "out of memory");
+			return false;
+		}
+	}
+
+	return true;
+}
+
+/*
+ * Implements the OAUTHBEARER SASL exchange (RFC 7628, Sec. 3.2).
+ *
+ * If the necessary OAuth parameters are set up on the connection, this will run
+ * the client flow asynchronously and present the resulting token to the server.
+ * Otherwise, an empty discovery response will be sent and any parameters sent
+ * back by the server will be stored for a second attempt.
+ *
+ * For a full description of the API, see libpq/sasl.h.
+ */
+static SASLStatus
+oauth_exchange(void *opaq, bool final,
+			   char *input, int inputlen,
+			   char **output, int *outputlen)
+{
+	fe_oauth_state *state = opaq;
+	PGconn	   *conn = state->conn;
+	bool		discover = false;
+
+	*output = NULL;
+	*outputlen = 0;
+
+	switch (state->step)
+	{
+		case FE_OAUTH_INIT:
+			/* We begin in the initial response phase. */
+			Assert(inputlen == -1);
+
+			if (!setup_oauth_parameters(conn))
+				return SASL_FAILED;
+
+			if (conn->oauth_token)
+			{
+				/*
+				 * A previous connection already fetched the token; we'll use
+				 * it below.
+				 */
+			}
+			else if (conn->oauth_discovery_uri)
+			{
+				/*
+				 * We don't have a token, but we have a discovery URI already
+				 * stored. Decide whether we're using a user-provided OAuth
+				 * flow or the one we have built in.
+				 */
+				if (!setup_token_request(conn, state))
+					return SASL_FAILED;
+
+				if (conn->oauth_token)
+				{
+					/*
+					 * A really smart user implementation may have already
+					 * given us the token (e.g. if there was an unexpired copy
+					 * already cached), and we can use it immediately.
+					 */
+				}
+				else
+				{
+					/*
+					 * Otherwise, we'll have to hand the connection over to
+					 * our OAuth implementation.
+					 *
+					 * This could take a while, since it generally involves a
+					 * user in the loop. To avoid consuming the server's
+					 * authentication timeout, we'll continue this handshake
+					 * to the end, so that the server can close its side of
+					 * the connection. We'll open a second connection later
+					 * once we've retrieved a token.
+					 */
+					discover = true;
+				}
+			}
+			else
+			{
+				/*
+				 * If we don't have a token, and we don't have a discovery URI
+				 * to be able to request a token, we ask the server for one
+				 * explicitly.
+				 */
+				discover = true;
+			}
+
+			/*
+			 * Generate an initial response. This either contains a token, if
+			 * we have one, or an empty discovery response which is doomed to
+			 * fail.
+			 */
+			*output = client_initial_response(conn, discover);
+			if (!*output)
+				return SASL_FAILED;
+
+			*outputlen = strlen(*output);
+			state->step = FE_OAUTH_BEARER_SENT;
+
+			if (conn->oauth_token)
+			{
+				/*
+				 * For the purposes of require_auth, our side of
+				 * authentication is done at this point; the server will
+				 * either accept the connection or send an error. Unlike
+				 * SCRAM, there is no additional server data to check upon
+				 * success.
+				 */
+				conn->client_finished_auth = true;
+			}
+
+			return SASL_CONTINUE;
+
+		case FE_OAUTH_BEARER_SENT:
+			if (final)
+			{
+				/*
+				 * OAUTHBEARER does not make use of additional data with a
+				 * successful SASL exchange, so we shouldn't get an
+				 * AuthenticationSASLFinal message.
+				 */
+				libpq_append_conn_error(conn,
+										"server sent unexpected additional OAuth data");
+				return SASL_FAILED;
+			}
+
+			/*
+			 * An error message was sent by the server. Respond with the
+			 * required dummy message (RFC 7628, sec. 3.2.3).
+			 */
+			*output = strdup(kvsep);
+			if (unlikely(!*output))
+			{
+				libpq_append_conn_error(conn, "out of memory");
+				return SASL_FAILED;
+			}
+			*outputlen = strlen(*output);	/* == 1 */
+
+			/* Grab the settings from discovery. */
+			if (!handle_oauth_sasl_error(conn, input, inputlen))
+				return SASL_FAILED;
+
+			if (conn->oauth_token)
+			{
+				/*
+				 * The server rejected our token. Continue onwards towards the
+				 * expected FATAL message, but mark our state to catch any
+				 * unexpected "success" from the server.
+				 */
+				state->step = FE_OAUTH_SERVER_ERROR;
+				return SASL_CONTINUE;
+			}
+
+			if (!conn->async_auth)
+			{
+				/*
+				 * No OAuth flow is set up yet. Did we get enough information
+				 * from the server to create one?
+				 */
+				if (!conn->oauth_discovery_uri)
+				{
+					libpq_append_conn_error(conn,
+											"server requires OAuth authentication, but no discovery metadata was provided");
+					return SASL_FAILED;
+				}
+
+				/* Yes. Set up the flow now. */
+				if (!setup_token_request(conn, state))
+					return SASL_FAILED;
+
+				if (conn->oauth_token)
+				{
+					/*
+					 * A token was available in a custom flow's cache. Skip
+					 * the asynchronous processing.
+					 */
+					goto reconnect;
+				}
+			}
+
+			/*
+			 * Time to retrieve a token. This involves a number of HTTP
+			 * connections and timed waits, so we escape the synchronous auth
+			 * processing and tell PQconnectPoll to transfer control to our
+			 * async implementation.
+			 */
+			Assert(conn->async_auth);	/* should have been set already */
+			state->step = FE_OAUTH_REQUESTING_TOKEN;
+			return SASL_ASYNC;
+
+		case FE_OAUTH_REQUESTING_TOKEN:
+
+			/*
+			 * We've returned successfully from token retrieval. Double-check
+			 * that we have what we need for the next connection.
+			 */
+			if (!conn->oauth_token)
+			{
+				Assert(false);	/* should have failed before this point! */
+				libpq_append_conn_error(conn,
+										"internal error: OAuth flow did not set a token");
+				return SASL_FAILED;
+			}
+
+			goto reconnect;
+
+		case FE_OAUTH_SERVER_ERROR:
+
+			/*
+			 * After an error, the server should send an error response to
+			 * fail the SASL handshake, which is handled in higher layers.
+			 *
+			 * If we get here, the server either sent *another* challenge
+			 * which isn't defined in the RFC, or completed the handshake
+			 * successfully after telling us it was going to fail. Neither is
+			 * acceptable.
+			 */
+			libpq_append_conn_error(conn,
+									"server sent additional OAuth data after error");
+			return SASL_FAILED;
+
+		default:
+			libpq_append_conn_error(conn, "invalid OAuth exchange state");
+			break;
+	}
+
+	Assert(false);				/* should never get here */
+	return SASL_FAILED;
+
+reconnect:
+
+	/*
+	 * Despite being a failure from the point of view of SASL, we have enough
+	 * information to restart with a new connection.
+	 */
+	libpq_append_conn_error(conn, "retrying connection with new bearer token");
+	conn->oauth_want_retry = true;
+	return SASL_FAILED;
+}
+
+static bool
+oauth_channel_bound(void *opaq)
+{
+	/* This mechanism does not support channel binding. */
+	return false;
+}
+
+/*
+ * Fully clears out any stored OAuth token. This is done proactively upon
+ * successful connection as well as during pqClosePGconn().
+ */
+void
+pqClearOAuthToken(PGconn *conn)
+{
+	if (!conn->oauth_token)
+		return;
+
+	explicit_bzero(conn->oauth_token, strlen(conn->oauth_token));
+	free(conn->oauth_token);
+	conn->oauth_token = NULL;
+}
+
+/*
+ * Returns true if the PGOAUTHDEBUG=UNSAFE flag is set in the environment.
+ */
+bool
+oauth_unsafe_debugging_enabled(void)
+{
+	const char *env = getenv("PGOAUTHDEBUG");
+
+	return (env && strcmp(env, "UNSAFE") == 0);
+}
diff --git a/src/interfaces/libpq/fe-auth-oauth.h b/src/interfaces/libpq/fe-auth-oauth.h
new file mode 100644
index 00000000000..32598721686
--- /dev/null
+++ b/src/interfaces/libpq/fe-auth-oauth.h
@@ -0,0 +1,45 @@
+/*-------------------------------------------------------------------------
+ *
+ * fe-auth-oauth.h
+ *
+ *	  Definitions for OAuth authentication implementations
+ *
+ * Portions Copyright (c) 2024, PostgreSQL Global Development Group
+ *
+ * src/interfaces/libpq/fe-auth-oauth.h
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#ifndef FE_AUTH_OAUTH_H
+#define FE_AUTH_OAUTH_H
+
+#include "libpq-fe.h"
+#include "libpq-int.h"
+
+
+enum fe_oauth_step
+{
+	FE_OAUTH_INIT,
+	FE_OAUTH_BEARER_SENT,
+	FE_OAUTH_REQUESTING_TOKEN,
+	FE_OAUTH_SERVER_ERROR,
+};
+
+typedef struct
+{
+	enum fe_oauth_step step;
+
+	PGconn	   *conn;
+	void	   *async_ctx;
+} fe_oauth_state;
+
+extern PostgresPollingStatusType pg_fe_run_oauth_flow(PGconn *conn);
+extern void pg_fe_cleanup_oauth_flow(PGconn *conn);
+extern void pqClearOAuthToken(PGconn *conn);
+extern bool oauth_unsafe_debugging_enabled(void);
+
+/* Mechanisms in fe-auth-oauth.c */
+extern const pg_fe_sasl_mech pg_oauth_mech;
+
+#endif							/* FE_AUTH_OAUTH_H */
diff --git a/src/interfaces/libpq/fe-auth.c b/src/interfaces/libpq/fe-auth.c
index 761ee8f88f7..ec7a9236044 100644
--- a/src/interfaces/libpq/fe-auth.c
+++ b/src/interfaces/libpq/fe-auth.c
@@ -40,9 +40,11 @@
 #endif
 
 #include "common/md5.h"
+#include "common/oauth-common.h"
 #include "common/scram-common.h"
 #include "fe-auth.h"
 #include "fe-auth-sasl.h"
+#include "fe-auth-oauth.h"
 #include "libpq-fe.h"
 
 #ifdef ENABLE_GSS
@@ -535,6 +537,13 @@ pg_SASL_init(PGconn *conn, int payloadlen, bool *async)
 			conn->sasl = &pg_scram_mech;
 			conn->password_needed = true;
 		}
+		else if (strcmp(mechanism_buf.data, OAUTHBEARER_NAME) == 0 &&
+				 !selected_mechanism)
+		{
+			selected_mechanism = OAUTHBEARER_NAME;
+			conn->sasl = &pg_oauth_mech;
+			conn->password_needed = false;
+		}
 	}
 
 	if (!selected_mechanism)
@@ -559,13 +568,6 @@ pg_SASL_init(PGconn *conn, int payloadlen, bool *async)
 
 		if (!allowed)
 		{
-			/*
-			 * TODO: this is dead code until a second SASL mechanism is added;
-			 * the connection can't have proceeded past check_expected_areq()
-			 * if no SASL methods are allowed.
-			 */
-			Assert(false);
-
 			libpq_append_conn_error(conn, "authentication method requirement \"%s\" failed: server requested %s authentication",
 									conn->require_auth, selected_mechanism);
 			goto error;
@@ -1580,3 +1582,23 @@ PQchangePassword(PGconn *conn, const char *user, const char *passwd)
 		}
 	}
 }
+
+PQauthDataHook_type PQauthDataHook = PQdefaultAuthDataHook;
+
+PQauthDataHook_type
+PQgetAuthDataHook(void)
+{
+	return PQauthDataHook;
+}
+
+void
+PQsetAuthDataHook(PQauthDataHook_type hook)
+{
+	PQauthDataHook = hook ? hook : PQdefaultAuthDataHook;
+}
+
+int
+PQdefaultAuthDataHook(PGauthData type, PGconn *conn, void *data)
+{
+	return 0;					/* handle nothing */
+}
diff --git a/src/interfaces/libpq/fe-auth.h b/src/interfaces/libpq/fe-auth.h
index 1d4991f8996..de98e0d20c4 100644
--- a/src/interfaces/libpq/fe-auth.h
+++ b/src/interfaces/libpq/fe-auth.h
@@ -18,6 +18,9 @@
 #include "libpq-int.h"
 
 
+extern PQauthDataHook_type PQauthDataHook;
+
+
 /* Prototypes for functions in fe-auth.c */
 extern int	pg_fe_sendauth(AuthRequest areq, int payloadlen, PGconn *conn,
 						   bool *async);
diff --git a/src/interfaces/libpq/fe-connect.c b/src/interfaces/libpq/fe-connect.c
index 85d1ca2864f..d5051f5e820 100644
--- a/src/interfaces/libpq/fe-connect.c
+++ b/src/interfaces/libpq/fe-connect.c
@@ -28,6 +28,7 @@
 #include "common/scram-common.h"
 #include "common/string.h"
 #include "fe-auth.h"
+#include "fe-auth-oauth.h"
 #include "libpq-fe.h"
 #include "libpq-int.h"
 #include "mb/pg_wchar.h"
@@ -373,6 +374,23 @@ static const internalPQconninfoOption PQconninfoOptions[] = {
 	{"scram_server_key", NULL, NULL, NULL, "SCRAM-Server-Key", "D", SCRAM_MAX_KEY_LEN * 2,
 	offsetof(struct pg_conn, scram_server_key)},
 
+	/* OAuth v2 */
+	{"oauth_issuer", NULL, NULL, NULL,
+		"OAuth-Issuer", "", 40,
+	offsetof(struct pg_conn, oauth_issuer)},
+
+	{"oauth_client_id", NULL, NULL, NULL,
+		"OAuth-Client-ID", "", 40,
+	offsetof(struct pg_conn, oauth_client_id)},
+
+	{"oauth_client_secret", NULL, NULL, NULL,
+		"OAuth-Client-Secret", "", 40,
+	offsetof(struct pg_conn, oauth_client_secret)},
+
+	{"oauth_scope", NULL, NULL, NULL,
+		"OAuth-Scope", "", 15,
+	offsetof(struct pg_conn, oauth_scope)},
+
 	/* Terminating entry --- MUST BE LAST */
 	{NULL, NULL, NULL, NULL,
 	NULL, NULL, 0}
@@ -399,6 +417,7 @@ static const PQEnvironmentOption EnvironmentOptions[] =
 static const pg_fe_sasl_mech *supported_sasl_mechs[] =
 {
 	&pg_scram_mech,
+	&pg_oauth_mech,
 };
 #define SASL_MECHANISM_COUNT lengthof(supported_sasl_mechs)
 
@@ -655,6 +674,7 @@ pqDropServerData(PGconn *conn)
 	conn->write_failed = false;
 	free(conn->write_err_msg);
 	conn->write_err_msg = NULL;
+	conn->oauth_want_retry = false;
 
 	/*
 	 * Cancel connections need to retain their be_pid and be_key across
@@ -1144,7 +1164,7 @@ static inline void
 fill_allowed_sasl_mechs(PGconn *conn)
 {
 	/*---
-	 * We only support one mechanism at the moment, so rather than deal with a
+	 * We only support two mechanisms at the moment, so rather than deal with a
 	 * linked list, conn->allowed_sasl_mechs is an array of static length. We
 	 * rely on the compile-time assertion here to keep us honest.
 	 *
@@ -1519,6 +1539,10 @@ pqConnectOptions2(PGconn *conn)
 			{
 				mech = &pg_scram_mech;
 			}
+			else if (strcmp(method, "oauth") == 0)
+			{
+				mech = &pg_oauth_mech;
+			}
 
 			/*
 			 * Final group: meta-options.
@@ -4111,7 +4135,19 @@ keep_going:						/* We will come back to here until there is
 				conn->inStart = conn->inCursor;
 
 				if (res != STATUS_OK)
+				{
+					/*
+					 * OAuth connections may perform two-step discovery, where
+					 * the first connection is a dummy.
+					 */
+					if (conn->sasl == &pg_oauth_mech && conn->oauth_want_retry)
+					{
+						need_new_connection = true;
+						goto keep_going;
+					}
+
 					goto error_return;
+				}
 
 				/*
 				 * Just make sure that any data sent by pg_fe_sendauth is
@@ -4390,6 +4426,9 @@ keep_going:						/* We will come back to here until there is
 					}
 				}
 
+				/* Don't hold onto any OAuth tokens longer than necessary. */
+				pqClearOAuthToken(conn);
+
 				/*
 				 * For non cancel requests we can release the address list
 				 * now. For cancel requests we never actually resolve
@@ -5002,6 +5041,12 @@ freePGconn(PGconn *conn)
 	free(conn->load_balance_hosts);
 	free(conn->scram_client_key);
 	free(conn->scram_server_key);
+	free(conn->oauth_issuer);
+	free(conn->oauth_issuer_id);
+	free(conn->oauth_discovery_uri);
+	free(conn->oauth_client_id);
+	free(conn->oauth_client_secret);
+	free(conn->oauth_scope);
 	termPQExpBuffer(&conn->errorMessage);
 	termPQExpBuffer(&conn->workBuffer);
 
@@ -5155,6 +5200,7 @@ pqClosePGconn(PGconn *conn)
 	conn->asyncStatus = PGASYNC_IDLE;
 	conn->xactStatus = PQTRANS_IDLE;
 	conn->pipelineStatus = PQ_PIPELINE_OFF;
+	pqClearOAuthToken(conn);
 	pqClearAsyncResult(conn);	/* deallocate result */
 	pqClearConnErrorState(conn);
 
diff --git a/src/interfaces/libpq/libpq-fe.h b/src/interfaces/libpq/libpq-fe.h
index a3491faf0c3..5f8d608261e 100644
--- a/src/interfaces/libpq/libpq-fe.h
+++ b/src/interfaces/libpq/libpq-fe.h
@@ -59,6 +59,8 @@ extern "C"
 /* Features added in PostgreSQL v18: */
 /* Indicates presence of PQfullProtocolVersion */
 #define LIBPQ_HAS_FULL_PROTOCOL_VERSION 1
+/* Indicates presence of the PQAUTHDATA_PROMPT_OAUTH_DEVICE authdata hook */
+#define LIBPQ_HAS_PROMPT_OAUTH_DEVICE 1
 
 /*
  * Option flags for PQcopyResult
@@ -186,6 +188,13 @@ typedef enum
 	PQ_PIPELINE_ABORTED
 } PGpipelineStatus;
 
+typedef enum
+{
+	PQAUTHDATA_PROMPT_OAUTH_DEVICE, /* user must visit a device-authorization
+									 * URL */
+	PQAUTHDATA_OAUTH_BEARER_TOKEN,	/* server requests an OAuth Bearer token */
+} PGauthData;
+
 /* PGconn encapsulates a connection to the backend.
  * The contents of this struct are not supposed to be known to applications.
  */
@@ -720,10 +729,83 @@ extern int	PQenv2encoding(void);
 
 /* === in fe-auth.c === */
 
+typedef struct _PGpromptOAuthDevice
+{
+	const char *verification_uri;	/* verification URI to visit */
+	const char *user_code;		/* user code to enter */
+} PGpromptOAuthDevice;
+
+/* for PGoauthBearerRequest.async() */
+#ifdef _WIN32
+#define SOCKTYPE uintptr_t		/* avoids depending on winsock2.h for SOCKET */
+#else
+#define SOCKTYPE int
+#endif
+
+typedef struct _PGoauthBearerRequest
+{
+	/* Hook inputs (constant across all calls) */
+	const char *const openid_configuration; /* OIDC discovery URI */
+	const char *const scope;	/* required scope(s), or NULL */
+
+	/* Hook outputs */
+
+	/*---------
+	 * Callback implementing a custom asynchronous OAuth flow.
+	 *
+	 * The callback may return
+	 * - PGRES_POLLING_READING/WRITING, to indicate that a socket descriptor
+	 *   has been stored in *altsock and libpq should wait until it is
+	 *   readable or writable before calling back;
+	 * - PGRES_POLLING_OK, to indicate that the flow is complete and
+	 *   request->token has been set; or
+	 * - PGRES_POLLING_FAILED, to indicate that token retrieval has failed.
+	 *
+	 * This callback is optional. If the token can be obtained without
+	 * 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.
+	 */
+	PostgresPollingStatusType (*async) (PGconn *conn,
+										struct _PGoauthBearerRequest *request,
+										SOCKTYPE * altsock);
+
+	/*
+	 * Callback to clean up custom allocations. A hook implementation may use
+	 * this to free request->token and any resources in request->user.
+	 *
+	 * This is technically optional, but highly recommended, because there is
+	 * no other indication as to when it is safe to free the token.
+	 */
+	void		(*cleanup) (PGconn *conn, struct _PGoauthBearerRequest *request);
+
+	/*
+	 * The hook should set this to the Bearer token contents for the
+	 * connection, once the flow is completed.  The token contents must remain
+	 * available to libpq until the hook's cleanup callback is called.
+	 */
+	char	   *token;
+
+	/*
+	 * Hook-defined data. libpq will not modify this pointer across calls to
+	 * the async callback, so it can be used to keep track of
+	 * application-specific state. Resources allocated here should be freed by
+	 * the cleanup callback.
+	 */
+	void	   *user;
+} PGoauthBearerRequest;
+
+#undef SOCKTYPE
+
 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);
 
+typedef int (*PQauthDataHook_type) (PGauthData type, PGconn *conn, void *data);
+extern void PQsetAuthDataHook(PQauthDataHook_type hook);
+extern PQauthDataHook_type PQgetAuthDataHook(void);
+extern int	PQdefaultAuthDataHook(PGauthData type, PGconn *conn, void *data);
+
 /* === in encnames.c === */
 
 extern int	pg_char_to_encoding(const char *name);
diff --git a/src/interfaces/libpq/libpq-int.h b/src/interfaces/libpq/libpq-int.h
index 2546f9f8a50..f36f7f19d58 100644
--- a/src/interfaces/libpq/libpq-int.h
+++ b/src/interfaces/libpq/libpq-int.h
@@ -437,6 +437,17 @@ struct pg_conn
 								 * cancel request, instead of being a normal
 								 * connection that's used for queries */
 
+	/* OAuth v2 */
+	char	   *oauth_issuer;	/* token issuer/URL */
+	char	   *oauth_issuer_id;	/* token issuer identifier */
+	char	   *oauth_discovery_uri;	/* URI of the issuer's discovery
+										 * document */
+	char	   *oauth_client_id;	/* client identifier */
+	char	   *oauth_client_secret;	/* client secret */
+	char	   *oauth_scope;	/* access token scope */
+	char	   *oauth_token;	/* access token */
+	bool		oauth_want_retry;	/* should we retry on failure? */
+
 	/* Optional file to write trace info to */
 	FILE	   *Pfdebug;
 	int			traceFlags;
@@ -505,7 +516,7 @@ struct pg_conn
 								 * the server? */
 	uint32		allowed_auth_methods;	/* bitmask of acceptable AuthRequest
 										 * codes */
-	const pg_fe_sasl_mech *allowed_sasl_mechs[1];	/* and acceptable SASL
+	const pg_fe_sasl_mech *allowed_sasl_mechs[2];	/* and acceptable SASL
 													 * mechanisms */
 	bool		client_finished_auth;	/* have we finished our half of the
 										 * authentication exchange? */
diff --git a/src/interfaces/libpq/meson.build b/src/interfaces/libpq/meson.build
index dd64d291b3e..19f4a52a97a 100644
--- a/src/interfaces/libpq/meson.build
+++ b/src/interfaces/libpq/meson.build
@@ -1,6 +1,7 @@
 # Copyright (c) 2022-2025, PostgreSQL Global Development Group
 
 libpq_sources = files(
+  'fe-auth-oauth.c',
   'fe-auth-scram.c',
   'fe-auth.c',
   'fe-cancel.c',
@@ -37,6 +38,10 @@ if gssapi.found()
   )
 endif
 
+if libcurl.found()
+  libpq_sources += files('fe-auth-oauth-curl.c')
+endif
+
 export_file = custom_target('libpq.exports',
   kwargs: gen_export_kwargs,
 )
diff --git a/src/makefiles/meson.build b/src/makefiles/meson.build
index d49b2079a44..60e13d50235 100644
--- a/src/makefiles/meson.build
+++ b/src/makefiles/meson.build
@@ -229,6 +229,7 @@ pgxs_deps = {
   'gssapi': gssapi,
   'icu': icu,
   'ldap': ldap,
+  'libcurl': libcurl,
   'libxml': libxml,
   'libxslt': libxslt,
   'llvm': llvm,
diff --git a/src/test/authentication/t/001_password.pl b/src/test/authentication/t/001_password.pl
index 1357f806b6f..4ce22ccbdf2 100644
--- a/src/test/authentication/t/001_password.pl
+++ b/src/test/authentication/t/001_password.pl
@@ -404,11 +404,11 @@ $node->connect_fails(
 $node->connect_fails(
 	"user=scram_role require_auth=!scram-sha-256",
 	"SCRAM authentication forbidden, fails with SCRAM auth",
-	expected_stderr => qr/server requested SASL authentication/);
+	expected_stderr => qr/server requested SCRAM-SHA-256 authentication/);
 $node->connect_fails(
 	"user=scram_role require_auth=!password,!md5,!scram-sha-256",
 	"multiple authentication types forbidden, fails with SCRAM auth",
-	expected_stderr => qr/server requested SASL authentication/);
+	expected_stderr => qr/server requested SCRAM-SHA-256 authentication/);
 
 # Test that bad passwords are rejected.
 $ENV{"PGPASSWORD"} = 'badpass';
@@ -465,13 +465,13 @@ $node->connect_fails(
 	"user=scram_role require_auth=!scram-sha-256",
 	"password authentication forbidden, fails with SCRAM auth",
 	expected_stderr =>
-	  qr/authentication method requirement "!scram-sha-256" failed: server requested SASL authentication/
+	  qr/authentication method requirement "!scram-sha-256" failed: server requested SCRAM-SHA-256 authentication/
 );
 $node->connect_fails(
 	"user=scram_role require_auth=!password,!md5,!scram-sha-256",
 	"multiple authentication types forbidden, fails with SCRAM auth",
 	expected_stderr =>
-	  qr/authentication method requirement "!password,!md5,!scram-sha-256" failed: server requested SASL authentication/
+	  qr/authentication method requirement "!password,!md5,!scram-sha-256" failed: server requested SCRAM-SHA-256 authentication/
 );
 
 # Test SYSTEM_USER <> NULL with parallel workers.
diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile
index c0d3cf0e14b..bdfd5f1f8de 100644
--- a/src/test/modules/Makefile
+++ b/src/test/modules/Makefile
@@ -11,6 +11,7 @@ SUBDIRS = \
 		  dummy_index_am \
 		  dummy_seclabel \
 		  libpq_pipeline \
+		  oauth_validator \
 		  plsample \
 		  spgist_name_ops \
 		  test_bloomfilter \
diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build
index 4f544a042d4..0c2ccc75a63 100644
--- a/src/test/modules/meson.build
+++ b/src/test/modules/meson.build
@@ -9,6 +9,7 @@ subdir('gin')
 subdir('injection_points')
 subdir('ldap_password_func')
 subdir('libpq_pipeline')
+subdir('oauth_validator')
 subdir('plsample')
 subdir('spgist_name_ops')
 subdir('ssl_passphrase_callback')
diff --git a/src/test/modules/oauth_validator/.gitignore b/src/test/modules/oauth_validator/.gitignore
new file mode 100644
index 00000000000..5dcb3ff9723
--- /dev/null
+++ b/src/test/modules/oauth_validator/.gitignore
@@ -0,0 +1,4 @@
+# Generated subdirectories
+/log/
+/results/
+/tmp_check/
diff --git a/src/test/modules/oauth_validator/Makefile b/src/test/modules/oauth_validator/Makefile
new file mode 100644
index 00000000000..f297ed5c968
--- /dev/null
+++ b/src/test/modules/oauth_validator/Makefile
@@ -0,0 +1,40 @@
+#-------------------------------------------------------------------------
+#
+# Makefile for src/test/modules/oauth_validator
+#
+# Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/test/modules/oauth_validator/Makefile
+#
+#-------------------------------------------------------------------------
+
+MODULES = validator fail_validator
+PGFILEDESC = "validator - test OAuth validator module"
+
+PROGRAM = oauth_hook_client
+PGAPPICON = win32
+OBJS = $(WIN32RES) oauth_hook_client.o
+
+PG_CPPFLAGS = -I$(libpq_srcdir)
+PG_LIBS_INTERNAL += $(libpq_pgport)
+
+NO_INSTALLCHECK = 1
+
+TAP_TESTS = 1
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = src/test/modules/oauth_validator
+top_builddir = ../../../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+
+export PYTHON
+export with_libcurl
+export with_python
+
+endif
diff --git a/src/test/modules/oauth_validator/README b/src/test/modules/oauth_validator/README
new file mode 100644
index 00000000000..138a8104622
--- /dev/null
+++ b/src/test/modules/oauth_validator/README
@@ -0,0 +1,13 @@
+Test programs and libraries for OAuth
+-------------------------------------
+
+This folder contains tests for the client- and server-side OAuth
+implementations. Most tests are run end-to-end to test both simultaneously. The
+tests in t/001_server use a mock OAuth authorization server, implemented jointly
+by t/OAuth/Server.pm and t/oauth_server.py, to run the libpq Device
+Authorization flow. The tests in t/002_client exercise custom OAuth flows and
+don't need an authorization server.
+
+Tests in this folder generally require 'oauth' to be present in PG_TEST_EXTRA,
+since localhost HTTP servers will be started. A Python installation is required
+to run the mock authorization server.
diff --git a/src/test/modules/oauth_validator/fail_validator.c b/src/test/modules/oauth_validator/fail_validator.c
new file mode 100644
index 00000000000..f77a3e115c6
--- /dev/null
+++ b/src/test/modules/oauth_validator/fail_validator.c
@@ -0,0 +1,42 @@
+/*-------------------------------------------------------------------------
+ *
+ * fail_validator.c
+ *	  Test module for serverside OAuth token validation callbacks, which always
+ *	  fails
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/test/modules/oauth_validator/fail_validator.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "fmgr.h"
+#include "libpq/oauth.h"
+
+PG_MODULE_MAGIC;
+
+static ValidatorModuleResult *fail_token(ValidatorModuleState *state,
+										 const char *token,
+										 const char *role);
+
+/* Callback implementations (we only need the main one) */
+static const OAuthValidatorCallbacks validator_callbacks = {
+	.validate_cb = fail_token,
+};
+
+const OAuthValidatorCallbacks *
+_PG_oauth_validator_module_init(void)
+{
+	return &validator_callbacks;
+}
+
+static ValidatorModuleResult *
+fail_token(ValidatorModuleState *state, const char *token, const char *role)
+{
+	elog(FATAL, "fail_validator: sentinel error");
+	pg_unreachable();
+}
diff --git a/src/test/modules/oauth_validator/meson.build b/src/test/modules/oauth_validator/meson.build
new file mode 100644
index 00000000000..4b78c90557c
--- /dev/null
+++ b/src/test/modules/oauth_validator/meson.build
@@ -0,0 +1,69 @@
+# Copyright (c) 2024, PostgreSQL Global Development Group
+
+validator_sources = files(
+  'validator.c',
+)
+
+if host_system == 'windows'
+  validator_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'validator',
+    '--FILEDESC', 'validator - test OAuth validator module',])
+endif
+
+validator = shared_module('validator',
+  validator_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += validator
+
+fail_validator_sources = files(
+  'fail_validator.c',
+)
+
+if host_system == 'windows'
+  fail_validator_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'fail_validator',
+    '--FILEDESC', 'fail_validator - failing OAuth validator module',])
+endif
+
+fail_validator = shared_module('fail_validator',
+  fail_validator_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += fail_validator
+
+oauth_hook_client_sources = files(
+  'oauth_hook_client.c',
+)
+
+if host_system == 'windows'
+  oauth_hook_client_sources += rc_bin_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'oauth_hook_client',
+    '--FILEDESC', 'oauth_hook_client - test program for libpq OAuth hooks',])
+endif
+
+oauth_hook_client = executable('oauth_hook_client',
+  oauth_hook_client_sources,
+  dependencies: [frontend_code, libpq],
+  kwargs: default_bin_args + {
+    'install': false,
+  },
+)
+testprep_targets += oauth_hook_client
+
+tests += {
+  'name': 'oauth_validator',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'tap': {
+    'tests': [
+      't/001_server.pl',
+      't/002_client.pl',
+    ],
+    'env': {
+      'PYTHON': python.path(),
+      'with_libcurl': libcurl.found() ? 'yes' : 'no',
+      'with_python': 'yes',
+    },
+  },
+}
diff --git a/src/test/modules/oauth_validator/oauth_hook_client.c b/src/test/modules/oauth_validator/oauth_hook_client.c
new file mode 100644
index 00000000000..12fe70c990b
--- /dev/null
+++ b/src/test/modules/oauth_validator/oauth_hook_client.c
@@ -0,0 +1,264 @@
+/*-------------------------------------------------------------------------
+ *
+ * oauth_hook_client.c
+ *		Test driver for t/002_client.pl, which verifies OAuth hook
+ *		functionality in libpq.
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ *
+ * IDENTIFICATION
+ *		src/test/modules/oauth_validator/oauth_hook_client.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres_fe.h"
+
+#include <sys/socket.h>
+
+#include "getopt_long.h"
+#include "libpq-fe.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(" --expected-scope SCOPE	fail if received scopes do not match SCOPE\n");
+	printf(" --expected-uri URI		fail if received configuration link does not match URI\n");
+	printf(" --misbehave=MODE		have the hook fail required postconditions\n"
+		   "						(MODEs: no-hook, fail-async, no-token, no-socket)\n");
+	printf(" --no-hook				don't install OAuth hooks (connection will fail)\n");
+	printf(" --hang-forever			don't ever return a token (combine with connect_timeout)\n");
+	printf(" --token TOKEN			use the provided TOKEN value\n");
+}
+
+/* --options */
+static bool no_hook = false;
+static bool hang_forever = false;
+static const char *expected_uri = NULL;
+static const char *expected_scope = NULL;
+static const char *misbehave_mode = NULL;
+static char *token = NULL;
+
+int
+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},
+		{0}
+	};
+
+	const char *conninfo;
+	PGconn	   *conn;
+	int			c;
+
+	while ((c = getopt_long(argc, argv, "h", long_options, NULL)) != -1)
+	{
+		switch (c)
+		{
+			case 'h':
+				usage(argv);
+				return 0;
+
+			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;
+
+			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);
+
+	/* Connect. (All the actual work is in the hook.) */
+	conn = PQconnectdb(conninfo);
+	if (PQstatus(conn) != CONNECTION_OK)
+	{
+		fprintf(stderr, "Connection to database failed: %s\n",
+				PQerrorMessage(conn));
+		PQfinish(conn);
+		return 1;
+	}
+
+	printf("connection succeeded\n");
+	PQfinish(conn);
+	return 0;
+}
+
+/*
+ * PQauthDataHook implementation. Replaces the default client flow by handling
+ * PQAUTHDATA_OAUTH_BEARER_TOKEN.
+ */
+static int
+handle_auth_data(PGauthData type, PGconn *conn, void *data)
+{
+	PGoauthBearerRequest *req = data;
+
+	if (no_hook || (type != PQAUTHDATA_OAUTH_BEARER_TOKEN))
+		return 0;
+
+	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;
+		}
+	}
+
+	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". */
+		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/001_server.pl b/src/test/modules/oauth_validator/t/001_server.pl
new file mode 100644
index 00000000000..80f52585896
--- /dev/null
+++ b/src/test/modules/oauth_validator/t/001_server.pl
@@ -0,0 +1,551 @@
+
+#
+# Tests the libpq builtin OAuth flow, as well as server-side HBA and validator
+# setup.
+#
+# Copyright (c) 2021-2024, PostgreSQL Global Development Group
+#
+
+use strict;
+use warnings FATAL => 'all';
+
+use JSON::PP     qw(encode_json);
+use MIME::Base64 qw(encode_base64);
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+use FindBin;
+use lib $FindBin::RealBin;
+
+use OAuth::Server;
+
+if (!$ENV{PG_TEST_EXTRA} || $ENV{PG_TEST_EXTRA} !~ /\boauth\b/)
+{
+	plan skip_all =>
+	  'Potentially unsafe test oauth not enabled in PG_TEST_EXTRA';
+}
+
+if ($ENV{with_libcurl} ne 'yes')
+{
+	plan skip_all => 'client-side OAuth not supported by this build';
+}
+
+if ($ENV{with_python} ne 'yes')
+{
+	plan skip_all => 'OAuth tests require --with-python to run';
+}
+
+my $node = PostgreSQL::Test::Cluster->new('primary');
+$node->init;
+$node->append_conf('postgresql.conf', "log_connections = on\n");
+$node->append_conf('postgresql.conf',
+	"oauth_validator_libraries = 'validator'\n");
+$node->start;
+
+$node->safe_psql('postgres', 'CREATE USER test;');
+$node->safe_psql('postgres', 'CREATE USER testalt;');
+$node->safe_psql('postgres', 'CREATE USER testparam;');
+
+# Save a background connection for later configuration changes.
+my $bgconn = $node->background_psql('postgres');
+
+my $webserver = OAuth::Server->new();
+$webserver->run();
+
+END
+{
+	my $exit_code = $?;
+
+	$webserver->stop() if defined $webserver;    # might have been SKIP'd
+
+	$? = $exit_code;
+}
+
+my $port = $webserver->port();
+my $issuer = "http://localhost:$port";
+
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test      oauth issuer="$issuer"       scope="openid postgres"
+local all testalt   oauth issuer="$issuer/.well-known/oauth-authorization-server/alternate" scope="openid postgres alt"
+local all testparam oauth issuer="$issuer/param" scope="openid postgres"
+});
+$node->reload;
+
+my $log_start = $node->wait_for_log(qr/reloading configuration files/);
+
+# Check pg_hba_file_rules() support.
+my $contents = $bgconn->query_safe(
+	qq(SELECT rule_number, auth_method, options
+		 FROM pg_hba_file_rules
+		 ORDER BY rule_number;));
+is( $contents,
+	qq{1|oauth|\{issuer=$issuer,"scope=openid postgres",validator=validator\}
+2|oauth|\{issuer=$issuer/.well-known/oauth-authorization-server/alternate,"scope=openid postgres alt",validator=validator\}
+3|oauth|\{issuer=$issuer/param,"scope=openid postgres",validator=validator\}},
+	"pg_hba_file_rules recreates OAuth HBA settings");
+
+# To test against HTTP rather than HTTPS, we need to enable PGOAUTHDEBUG. But
+# first, check to make sure the client refuses such connections by default.
+$node->connect_fails(
+	"user=test dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635",
+	"HTTPS is required without debug mode",
+	expected_stderr =>
+	  qr@OAuth discovery URI "\Q$issuer\E/.well-known/openid-configuration" must use HTTPS@
+);
+
+$ENV{PGOAUTHDEBUG} = "UNSAFE";
+
+my $user = "test";
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635",
+	"connect as test",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@,
+	log_like => [
+		qr/oauth_validator: token="9243959234", role="$user"/,
+		qr/oauth_validator: issuer="\Q$issuer\E", scope="openid postgres"/,
+		qr/connection authenticated: identity="test" method=oauth/,
+		qr/connection authorized/,
+	]);
+
+# The /alternate issuer uses slightly different parameters, along with an
+# OAuth-style discovery document.
+$user = "testalt";
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer/alternate oauth_client_id=f02c6361-0636",
+	"connect as testalt",
+	expected_stderr =>
+	  qr@Visit https://example\.org/ and enter the code: postgresuser@,
+	log_like => [
+		qr/oauth_validator: token="9243959234-alt", role="$user"/,
+		qr|oauth_validator: issuer="\Q$issuer/.well-known/oauth-authorization-server/alternate\E", scope="openid postgres alt"|,
+		qr/connection authenticated: identity="testalt" method=oauth/,
+		qr/connection authorized/,
+	]);
+
+# The issuer linked by the server must match the client's oauth_issuer setting.
+$node->connect_fails(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0636",
+	"oauth_issuer must match discovery",
+	expected_stderr =>
+	  qr@server's discovery document at \Q$issuer/.well-known/oauth-authorization-server/alternate\E \(issuer "\Q$issuer/alternate\E"\) is incompatible with oauth_issuer \(\Q$issuer\E\)@
+);
+
+# Test require_auth settings against OAUTHBEARER.
+my @cases = (
+	{ require_auth => "oauth" },
+	{ require_auth => "oauth,scram-sha-256" },
+	{ require_auth => "password,oauth" },
+	{ require_auth => "none,oauth" },
+	{ require_auth => "!scram-sha-256" },
+	{ require_auth => "!none" },
+
+	{
+		require_auth => "!oauth",
+		failure => qr/server requested OAUTHBEARER authentication/
+	},
+	{
+		require_auth => "scram-sha-256",
+		failure => qr/server requested OAUTHBEARER authentication/
+	},
+	{
+		require_auth => "!password,!oauth",
+		failure => qr/server requested OAUTHBEARER authentication/
+	},
+	{
+		require_auth => "none",
+		failure => qr/server requested SASL authentication/
+	},
+	{
+		require_auth => "!oauth,!scram-sha-256",
+		failure => qr/server requested SASL authentication/
+	});
+
+$user = "test";
+foreach my $c (@cases)
+{
+	my $connstr =
+	  "user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635 require_auth=$c->{'require_auth'}";
+
+	if (defined $c->{'failure'})
+	{
+		$node->connect_fails(
+			$connstr,
+			"require_auth=$c->{'require_auth'} fails",
+			expected_stderr => $c->{'failure'});
+	}
+	else
+	{
+		$node->connect_ok(
+			$connstr,
+			"require_auth=$c->{'require_auth'} succeeds",
+			expected_stderr =>
+			  qr@Visit https://example\.com/ and enter the code: postgresuser@
+		);
+	}
+}
+
+# Make sure the client_id and secret are correctly encoded. $vschars contains
+# every allowed character for a client_id/_secret (the "VSCHAR" class).
+# $vschars_esc is additionally backslash-escaped for inclusion in a
+# single-quoted connection string.
+my $vschars =
+  " !\"#\$%&'()*+,-./0123456789:;<=>?\@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~";
+my $vschars_esc =
+  " !\"#\$%&\\'()*+,-./0123456789:;<=>?\@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\\\]^_`abcdefghijklmnopqrstuvwxyz{|}~";
+
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id='$vschars_esc'",
+	"escapable characters: client_id",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id='$vschars_esc' oauth_client_secret='$vschars_esc'",
+	"escapable characters: client_id and secret",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+#
+# Further tests rely on support for specific behaviors in oauth_server.py. To
+# trigger these behaviors, we ask for the special issuer .../param (which is set
+# up in HBA for the testparam user) and encode magic instructions into the
+# oauth_client_id.
+#
+
+my $common_connstr =
+  "user=testparam dbname=postgres oauth_issuer=$issuer/param ";
+my $base_connstr = $common_connstr;
+
+sub connstr
+{
+	my (%params) = @_;
+
+	my $json = encode_json(\%params);
+	my $encoded = encode_base64($json, "");
+
+	return "$base_connstr oauth_client_id=$encoded";
+}
+
+# Make sure the param system works end-to-end first.
+$node->connect_ok(
+	connstr(),
+	"connect to /param",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$node->connect_ok(
+	connstr(stage => 'token', retries => 1),
+	"token retry",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(stage => 'token', retries => 2),
+	"token retry (twice)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(stage => 'all', retries => 1, interval => 2),
+	"token retry (two second interval)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(stage => 'all', retries => 1, interval => JSON::PP::null),
+	"token retry (default interval)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$node->connect_ok(
+	connstr(stage => 'all', content_type => 'application/json;charset=utf-8'),
+	"content type with charset",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(
+		stage => 'all',
+		content_type => "application/json \t;\t charset=utf-8"),
+	"content type with charset (whitespace)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(stage => 'device', uri_spelling => "verification_url"),
+	"alternative spelling of verification_uri",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$node->connect_fails(
+	connstr(stage => 'device', huge_response => JSON::PP::true),
+	"bad device authz response: overlarge JSON",
+	expected_stderr =>
+	  qr/failed to obtain device authorization: response is too large/);
+$node->connect_fails(
+	connstr(stage => 'token', huge_response => JSON::PP::true),
+	"bad token response: overlarge JSON",
+	expected_stderr =>
+	  qr/failed to obtain access token: response is too large/);
+
+$node->connect_fails(
+	connstr(stage => 'device', content_type => 'text/plain'),
+	"bad device authz response: wrong content type",
+	expected_stderr =>
+	  qr/failed to parse device authorization: unexpected content type/);
+$node->connect_fails(
+	connstr(stage => 'token', content_type => 'text/plain'),
+	"bad token response: wrong content type",
+	expected_stderr =>
+	  qr/failed to parse access token response: unexpected content type/);
+$node->connect_fails(
+	connstr(stage => 'token', content_type => 'application/jsonx'),
+	"bad token response: wrong content type (correct prefix)",
+	expected_stderr =>
+	  qr/failed to parse access token response: unexpected content type/);
+
+$node->connect_fails(
+	connstr(
+		stage => 'all',
+		interval => ~0,
+		retries => 1,
+		retry_code => "slow_down"),
+	"bad token response: server overflows the device authz interval",
+	expected_stderr =>
+	  qr/failed to obtain access token: slow_down interval overflow/);
+
+$node->connect_fails(
+	connstr(stage => 'token', error_code => "invalid_grant"),
+	"bad token response: invalid_grant, no description",
+	expected_stderr => qr/failed to obtain access token: \(invalid_grant\)/);
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_grant",
+		error_desc => "grant expired"),
+	"bad token response: expired grant",
+	expected_stderr =>
+	  qr/failed to obtain access token: grant expired \(invalid_grant\)/);
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_client",
+		error_status => 401),
+	"bad token response: client authentication failure, default description",
+	expected_stderr =>
+	  qr/failed to obtain access token: provider requires client authentication, and no oauth_client_secret is set \(invalid_client\)/
+);
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_client",
+		error_status => 401,
+		error_desc => "authn failure"),
+	"bad token response: client authentication failure, provided description",
+	expected_stderr =>
+	  qr/failed to obtain access token: authn failure \(invalid_client\)/);
+
+$node->connect_fails(
+	connstr(stage => 'token', token => ""),
+	"server rejects access: empty token",
+	expected_stderr => qr/bearer authentication failed/);
+$node->connect_fails(
+	connstr(stage => 'token', token => "****"),
+	"server rejects access: invalid token contents",
+	expected_stderr => qr/bearer authentication failed/);
+
+# Test behavior of the oauth_client_secret.
+$base_connstr = "$common_connstr oauth_client_secret=''";
+
+$node->connect_ok(
+	connstr(stage => 'all', expected_secret => ''),
+	"empty oauth_client_secret",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$base_connstr = "$common_connstr oauth_client_secret='$vschars_esc'";
+
+$node->connect_ok(
+	connstr(stage => 'all', expected_secret => $vschars),
+	"nonempty oauth_client_secret",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_client",
+		error_status => 401),
+	"bad token response: client authentication failure, default description with oauth_client_secret",
+	expected_stderr =>
+	  qr/failed to obtain access token: provider rejected the oauth_client_secret \(invalid_client\)/
+);
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_client",
+		error_status => 401,
+		error_desc => "mutual TLS required for client"),
+	"bad token response: client authentication failure, provided description with oauth_client_secret",
+	expected_stderr =>
+	  qr/failed to obtain access token: mutual TLS required for client \(invalid_client\)/
+);
+
+#
+# This section of tests reconfigures the validator module itself, rather than
+# the OAuth server.
+#
+
+# Searching the logs is easier if OAuth parameter discovery isn't cluttering
+# things up; hardcode the discovery URI. (Scope is hardcoded to empty to cover
+# that case as well.)
+$common_connstr =
+  "dbname=postgres oauth_issuer=$issuer/.well-known/openid-configuration oauth_scope='' oauth_client_id=f02c6361-0635";
+
+# Misbehaving validators must fail shut.
+$bgconn->query_safe("ALTER SYSTEM SET oauth_validator.authn_id TO ''");
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+$node->connect_fails(
+	"$common_connstr user=test",
+	"validator must set authn_id",
+	expected_stderr => qr/OAuth bearer authentication failed/,
+	log_like => [
+		qr/connection authenticated: identity=""/,
+		qr/DETAIL:\s+Validator provided no identity/,
+		qr/FATAL:\s+OAuth bearer authentication failed/,
+	]);
+
+# Even if a validator authenticates the user, if the token isn't considered
+# valid, the connection fails.
+$bgconn->query_safe(
+	"ALTER SYSTEM SET oauth_validator.authn_id TO 'test\@example.org'");
+$bgconn->query_safe(
+	"ALTER SYSTEM SET oauth_validator.authorize_tokens TO false");
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+$node->connect_fails(
+	"$common_connstr user=test",
+	"validator must authorize token explicitly",
+	expected_stderr => qr/OAuth bearer authentication failed/,
+	log_like => [
+		qr/connection authenticated: identity="test\@example\.org"/,
+		qr/DETAIL:\s+Validator failed to authorize the provided token/,
+		qr/FATAL:\s+OAuth bearer authentication failed/,
+	]);
+
+#
+# Test user mapping.
+#
+
+# Allow "[email protected]" to log in under the test role.
+unlink($node->data_dir . '/pg_ident.conf');
+$node->append_conf(
+	'pg_ident.conf', qq{
+oauthmap	user\@example.com	test
+});
+
+# test and testalt use the map; testparam uses ident delegation.
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test      oauth issuer="$issuer" scope="" map=oauthmap
+local all testalt   oauth issuer="$issuer" scope="" map=oauthmap
+local all testparam oauth issuer="$issuer" scope="" delegate_ident_mapping=1
+});
+
+# To start, have the validator use the role names as authn IDs.
+$bgconn->query_safe("ALTER SYSTEM RESET oauth_validator.authn_id");
+$bgconn->query_safe("ALTER SYSTEM RESET oauth_validator.authorize_tokens");
+
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+# The test and testalt roles should no longer map correctly.
+$node->connect_fails(
+	"$common_connstr user=test",
+	"mismatched username map (test)",
+	expected_stderr => qr/OAuth bearer authentication failed/);
+$node->connect_fails(
+	"$common_connstr user=testalt",
+	"mismatched username map (testalt)",
+	expected_stderr => qr/OAuth bearer authentication failed/);
+
+# Have the validator identify the end user as [email protected].
+$bgconn->query_safe(
+	"ALTER SYSTEM SET oauth_validator.authn_id TO 'user\@example.com'");
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+# Now the test role can be logged into. (testalt still can't be mapped.)
+$node->connect_ok(
+	"$common_connstr user=test",
+	"matched username map (test)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_fails(
+	"$common_connstr user=testalt",
+	"mismatched username map (testalt)",
+	expected_stderr => qr/OAuth bearer authentication failed/);
+
+# testparam ignores the map entirely.
+$node->connect_ok(
+	"$common_connstr user=testparam",
+	"delegated ident (testparam)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$bgconn->query_safe("ALTER SYSTEM RESET oauth_validator.authn_id");
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+#
+# Test multiple validators.
+#
+
+$node->append_conf('postgresql.conf',
+	"oauth_validator_libraries = 'validator, fail_validator'\n");
+
+# With multiple validators, every HBA line must explicitly declare one.
+my $result = $node->restart(fail_ok => 1);
+is($result, 0,
+	'restart fails without explicit validators in oauth HBA entries');
+
+$log_start = $node->wait_for_log(
+	qr/authentication method "oauth" requires argument "validator" to be set/,
+	$log_start);
+
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test    oauth validator=validator      issuer="$issuer"           scope="openid postgres"
+local all testalt oauth validator=fail_validator issuer="$issuer/.well-known/oauth-authorization-server/alternate" scope="openid postgres alt"
+});
+$node->restart;
+
+$log_start = $node->wait_for_log(qr/ready to accept connections/, $log_start);
+
+# The test user should work as before.
+$user = "test";
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635",
+	"validator is used for $user",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@,
+	log_like => [qr/connection authorized/]);
+
+# testalt should be routed through the fail_validator.
+$user = "testalt";
+$node->connect_fails(
+	"user=$user dbname=postgres oauth_issuer=$issuer/.well-known/oauth-authorization-server/alternate oauth_client_id=f02c6361-0636",
+	"fail_validator is used for $user",
+	expected_stderr => qr/FATAL:\s+fail_validator: sentinel error/);
+
+$node->stop;
+
+done_testing();
diff --git a/src/test/modules/oauth_validator/t/002_client.pl b/src/test/modules/oauth_validator/t/002_client.pl
new file mode 100644
index 00000000000..95cccf90dd8
--- /dev/null
+++ b/src/test/modules/oauth_validator/t/002_client.pl
@@ -0,0 +1,154 @@
+#
+# Exercises the API for custom OAuth client flows, using the oauth_hook_client
+# test driver.
+#
+# Copyright (c) 2021-2024, PostgreSQL Global Development Group
+#
+
+use strict;
+use warnings FATAL => 'all';
+
+use JSON::PP     qw(encode_json);
+use MIME::Base64 qw(encode_base64);
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+if (!$ENV{PG_TEST_EXTRA} || $ENV{PG_TEST_EXTRA} !~ /\boauth\b/)
+{
+	plan skip_all =>
+	  'Potentially unsafe test oauth not enabled in PG_TEST_EXTRA';
+}
+
+#
+# Cluster Setup
+#
+
+my $node = PostgreSQL::Test::Cluster->new('primary');
+$node->init;
+$node->append_conf('postgresql.conf', "log_connections = on\n");
+$node->append_conf('postgresql.conf',
+	"oauth_validator_libraries = 'validator'\n");
+$node->start;
+
+$node->safe_psql('postgres', 'CREATE USER test;');
+
+# These tests don't use the builtin flow, and we don't have an authorization
+# server running, so the address used here shouldn't matter. Use an invalid IP
+# address, so if there's some cascade of errors that causes the client to
+# attempt a connection, we'll fail noisily.
+my $issuer = "https://256.256.256.256";
+my $scope = "openid postgres";
+
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test oauth issuer="$issuer" scope="$scope"
+});
+$node->reload;
+
+my ($log_start, $log_end);
+$log_start = $node->wait_for_log(qr/reloading configuration files/);
+
+$ENV{PGOAUTHDEBUG} = "UNSAFE";
+
+#
+# Tests
+#
+
+my $user = "test";
+my $base_connstr = $node->connstr() . " user=$user";
+my $common_connstr =
+  "$base_connstr oauth_issuer=$issuer oauth_client_id=myID";
+
+sub test
+{
+	my ($test_name, %params) = @_;
+
+	my $flags = [];
+	if (defined($params{flags}))
+	{
+		$flags = $params{flags};
+	}
+
+	my @cmd = ("oauth_hook_client", @{$flags}, $common_connstr);
+	note "running '" . join("' '", @cmd) . "'";
+
+	my ($stdout, $stderr) = run_command(\@cmd);
+
+	if (defined($params{expected_stdout}))
+	{
+		like($stdout, $params{expected_stdout}, "$test_name: stdout matches");
+	}
+
+	if (defined($params{expected_stderr}))
+	{
+		like($stderr, $params{expected_stderr}, "$test_name: stderr matches");
+	}
+	else
+	{
+		is($stderr, "", "$test_name: no stderr");
+	}
+}
+
+test(
+	"basic synchronous hook can provide a token",
+	flags => [
+		"--token", "my-token",
+		"--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"/, ]);
+
+if ($ENV{with_libcurl} ne 'yes')
+{
+	# libpq should help users out if no OAuth support is built in.
+	test(
+		"fails without custom hook installed",
+		flags => ["--no-hook"],
+		expected_stderr =>
+		  qr/no custom OAuth flows are available, and libpq was not built with libcurl support/
+	);
+}
+
+# connect_timeout should work if the flow doesn't respond.
+$common_connstr = "$common_connstr connect_timeout=1";
+test(
+	"connect_timeout interrupts hung client flow",
+	flags => ["--hang-forever"],
+	expected_stderr => qr/failed: timeout expired/);
+
+# Test various misbehaviors of the client hook.
+my @cases = (
+	{
+		flag => "--misbehave=no-hook",
+		expected_error =>
+		  qr/user-defined OAuth flow provided neither a token nor an async callback/,
+	},
+	{
+		flag => "--misbehave=fail-async",
+		expected_error => qr/user-defined OAuth flow failed/,
+	},
+	{
+		flag => "--misbehave=no-token",
+		expected_error => qr/user-defined OAuth flow did not provide a token/,
+	},
+	{
+		flag => "--misbehave=no-socket",
+		expected_error =>
+		  qr/user-defined OAuth flow did not provide a socket for polling/,
+	});
+
+foreach my $c (@cases)
+{
+	test(
+		"hook misbehavior: $c->{'flag'}",
+		flags => [ $c->{'flag'} ],
+		expected_stderr => $c->{'expected_error'});
+}
+
+done_testing();
diff --git a/src/test/modules/oauth_validator/t/OAuth/Server.pm b/src/test/modules/oauth_validator/t/OAuth/Server.pm
new file mode 100644
index 00000000000..f0f23d1d1a8
--- /dev/null
+++ b/src/test/modules/oauth_validator/t/OAuth/Server.pm
@@ -0,0 +1,140 @@
+
+# Copyright (c) 2024, PostgreSQL Global Development Group
+
+=pod
+
+=head1 NAME
+
+OAuth::Server - runs a mock OAuth authorization server for testing
+
+=head1 SYNOPSIS
+
+  use OAuth::Server;
+
+  my $server = OAuth::Server->new();
+  $server->run;
+
+  my $port = $server->port;
+  my $issuer = "http://localhost:$port";
+
+  # test against $issuer...
+
+  $server->stop;
+
+=head1 DESCRIPTION
+
+This is glue API between the Perl tests and the Python authorization server
+daemon implemented in t/oauth_server.py. (Python has a fairly usable HTTP server
+in its standard library, so the implementation was ported from Perl.)
+
+This authorization server does not use TLS (it implements a nonstandard, unsafe
+issuer at "http://localhost:<port>"), so libpq in particular will need to set
+PGOAUTHDEBUG=UNSAFE to be able to talk to it.
+
+=cut
+
+package OAuth::Server;
+
+use warnings;
+use strict;
+use Scalar::Util;
+use Test::More;
+
+=pod
+
+=head1 METHODS
+
+=over
+
+=item SSL::Server->new()
+
+Create a new OAuth Server object.
+
+=cut
+
+sub new
+{
+	my $class = shift;
+
+	my $self = {};
+	bless($self, $class);
+
+	return $self;
+}
+
+=pod
+
+=item $server->port()
+
+Returns the port in use by the server.
+
+=cut
+
+sub port
+{
+	my $self = shift;
+
+	return $self->{'port'};
+}
+
+=pod
+
+=item $server->run()
+
+Runs the authorization server daemon in t/oauth_server.py.
+
+=cut
+
+sub run
+{
+	my $self = shift;
+	my $port;
+
+	my $pid = open(my $read_fh, "-|", $ENV{PYTHON}, "t/oauth_server.py")
+	  or die "failed to start OAuth server: $!";
+
+	# Get the port number from the daemon. It closes stdout afterwards; that way
+	# we can slurp in the entire contents here rather than worrying about the
+	# number of bytes to read.
+	$port = do { local $/ = undef; <$read_fh> }
+	  // die "failed to read port number: $!";
+	chomp $port;
+	die "server did not advertise a valid port"
+	  unless Scalar::Util::looks_like_number($port);
+
+	$self->{'pid'} = $pid;
+	$self->{'port'} = $port;
+	$self->{'child'} = $read_fh;
+
+	note("OAuth provider (PID $pid) is listening on port $port\n");
+}
+
+=pod
+
+=item $server->stop()
+
+Sends SIGTERM to the authorization server and waits for it to exit.
+
+=cut
+
+sub stop
+{
+	my $self = shift;
+
+	note("Sending SIGTERM to OAuth provider PID: $self->{'pid'}\n");
+
+	kill(15, $self->{'pid'});
+	$self->{'pid'} = undef;
+
+	# Closing the popen() handle waits for the process to exit.
+	close($self->{'child'});
+	$self->{'child'} = undef;
+}
+
+=pod
+
+=back
+
+=cut
+
+1;
diff --git a/src/test/modules/oauth_validator/t/oauth_server.py b/src/test/modules/oauth_validator/t/oauth_server.py
new file mode 100755
index 00000000000..8ec09102027
--- /dev/null
+++ b/src/test/modules/oauth_validator/t/oauth_server.py
@@ -0,0 +1,391 @@
+#! /usr/bin/env python3
+#
+# A mock OAuth authorization server, designed to be invoked from
+# OAuth/Server.pm. This listens on an ephemeral port number (printed to stdout
+# so that the Perl tests can contact it) and runs as a daemon until it is
+# signaled.
+#
+
+import base64
+import http.server
+import json
+import os
+import sys
+import time
+import urllib.parse
+from collections import defaultdict
+
+
+class OAuthHandler(http.server.BaseHTTPRequestHandler):
+    """
+    Core implementation of the authorization server. The API is
+    inheritance-based, with entry points at do_GET() and do_POST(). See the
+    documentation for BaseHTTPRequestHandler.
+    """
+
+    JsonObject = dict[str, object]  # TypeAlias is not available until 3.10
+
+    def _check_issuer(self):
+        """
+        Switches the behavior of the provider depending on the issuer URI.
+        """
+        self._alt_issuer = (
+            self.path.startswith("/alternate/")
+            or self.path == "/.well-known/oauth-authorization-server/alternate"
+        )
+        self._parameterized = self.path.startswith("/param/")
+
+        if self._alt_issuer:
+            # The /alternate issuer uses IETF-style .well-known URIs.
+            if self.path.startswith("/.well-known/"):
+                self.path = self.path.removesuffix("/alternate")
+            else:
+                self.path = self.path.removeprefix("/alternate")
+        elif self._parameterized:
+            self.path = self.path.removeprefix("/param")
+
+    def _check_authn(self):
+        """
+        Checks the expected value of the Authorization header, if any.
+        """
+        secret = self._get_param("expected_secret", None)
+        if secret is None:
+            return
+
+        assert "Authorization" in self.headers
+        method, creds = self.headers["Authorization"].split()
+
+        if method != "Basic":
+            raise RuntimeError(f"client used {method} auth; expected Basic")
+
+        username = urllib.parse.quote_plus(self.client_id)
+        password = urllib.parse.quote_plus(secret)
+        expected_creds = f"{username}:{password}"
+
+        if creds.encode() != base64.b64encode(expected_creds.encode()):
+            raise RuntimeError(
+                f"client sent '{creds}'; expected b64encode('{expected_creds}')"
+            )
+
+    def do_GET(self):
+        self._response_code = 200
+        self._check_issuer()
+
+        config_path = "/.well-known/openid-configuration"
+        if self._alt_issuer:
+            config_path = "/.well-known/oauth-authorization-server"
+
+        if self.path == config_path:
+            resp = self.config()
+        else:
+            self.send_error(404, "Not Found")
+            return
+
+        self._send_json(resp)
+
+    def _parse_params(self) -> dict[str, str]:
+        """
+        Parses apart the form-urlencoded request body and returns the resulting
+        dict. For use by do_POST().
+        """
+        size = int(self.headers["Content-Length"])
+        form = self.rfile.read(size)
+
+        assert self.headers["Content-Type"] == "application/x-www-form-urlencoded"
+        return urllib.parse.parse_qs(
+            form.decode("utf-8"),
+            strict_parsing=True,
+            keep_blank_values=True,
+            encoding="utf-8",
+            errors="strict",
+        )
+
+    @property
+    def client_id(self) -> str:
+        """
+        Returns the client_id sent in the POST body or the Authorization header.
+        self._parse_params() must have been called first.
+        """
+        if "client_id" in self._params:
+            return self._params["client_id"][0]
+
+        if "Authorization" not in self.headers:
+            raise RuntimeError("client did not send any client_id")
+
+        _, creds = self.headers["Authorization"].split()
+
+        decoded = base64.b64decode(creds).decode("utf-8")
+        username, _ = decoded.split(":", 1)
+
+        return urllib.parse.unquote_plus(username)
+
+    def do_POST(self):
+        self._response_code = 200
+        self._check_issuer()
+
+        self._params = self._parse_params()
+        if self._parameterized:
+            # Pull encoded test parameters out of the peer's client_id field.
+            # This is expected to be Base64-encoded JSON.
+            js = base64.b64decode(self.client_id)
+            self._test_params = json.loads(js)
+
+        self._check_authn()
+
+        if self.path == "/authorize":
+            resp = self.authorization()
+        elif self.path == "/token":
+            resp = self.token()
+        else:
+            self.send_error(404)
+            return
+
+        self._send_json(resp)
+
+    def _should_modify(self) -> bool:
+        """
+        Returns True if the client has requested a modification to this stage of
+        the exchange.
+        """
+        if not hasattr(self, "_test_params"):
+            return False
+
+        stage = self._test_params.get("stage")
+
+        return (
+            stage == "all"
+            or (
+                stage == "discovery"
+                and self.path == "/.well-known/openid-configuration"
+            )
+            or (stage == "device" and self.path == "/authorize")
+            or (stage == "token" and self.path == "/token")
+        )
+
+    def _get_param(self, name, default):
+        """
+        If the client has requested a modification to this stage (see
+        _should_modify()), this method searches the provided test parameters for
+        a key of the given name, and returns it if found. Otherwise the provided
+        default is returned.
+        """
+        if self._should_modify() and name in self._test_params:
+            return self._test_params[name]
+
+        return default
+
+    @property
+    def _content_type(self) -> str:
+        """
+        Returns "application/json" unless the test has requested something
+        different.
+        """
+        return self._get_param("content_type", "application/json")
+
+    @property
+    def _interval(self) -> int:
+        """
+        Returns 0 unless the test has requested something different.
+        """
+        return self._get_param("interval", 0)
+
+    @property
+    def _retry_code(self) -> str:
+        """
+        Returns "authorization_pending" unless the test has requested something
+        different.
+        """
+        return self._get_param("retry_code", "authorization_pending")
+
+    @property
+    def _uri_spelling(self) -> str:
+        """
+        Returns "verification_uri" unless the test has requested something
+        different.
+        """
+        return self._get_param("uri_spelling", "verification_uri")
+
+    @property
+    def _response_padding(self):
+        """
+        If the huge_response test parameter is set to True, returns a dict
+        containing a gigantic string value, which can then be folded into a JSON
+        response.
+        """
+        if not self._get_param("huge_response", False):
+            return dict()
+
+        return {"_pad_": "x" * 1024 * 1024}
+
+    @property
+    def _access_token(self):
+        """
+        The actual Bearer token sent back to the client on success. Tests may
+        override this with the "token" test parameter.
+        """
+        token = self._get_param("token", None)
+        if token is not None:
+            return token
+
+        token = "9243959234"
+        if self._alt_issuer:
+            token += "-alt"
+
+        return token
+
+    def _send_json(self, js: JsonObject) -> None:
+        """
+        Sends the provided JSON dict as an application/json response.
+        self._response_code can be modified to send JSON error responses.
+        """
+        resp = json.dumps(js).encode("ascii")
+        self.log_message("sending JSON response: %s", resp)
+
+        self.send_response(self._response_code)
+        self.send_header("Content-Type", self._content_type)
+        self.send_header("Content-Length", str(len(resp)))
+        self.end_headers()
+
+        self.wfile.write(resp)
+
+    def config(self) -> JsonObject:
+        port = self.server.socket.getsockname()[1]
+
+        issuer = f"http://localhost:{port}"
+        if self._alt_issuer:
+            issuer += "/alternate"
+        elif self._parameterized:
+            issuer += "/param"
+
+        return {
+            "issuer": issuer,
+            "token_endpoint": issuer + "/token",
+            "device_authorization_endpoint": issuer + "/authorize",
+            "response_types_supported": ["token"],
+            "subject_types_supported": ["public"],
+            "id_token_signing_alg_values_supported": ["RS256"],
+            "grant_types_supported": [
+                "authorization_code",
+                "urn:ietf:params:oauth:grant-type:device_code",
+            ],
+        }
+
+    @property
+    def _token_state(self):
+        """
+        A cached _TokenState object for the connected client (as determined by
+        the request's client_id), or a new one if it doesn't already exist.
+
+        This relies on the existence of a defaultdict attached to the server;
+        see main() below.
+        """
+        return self.server.token_state[self.client_id]
+
+    def _remove_token_state(self):
+        """
+        Removes any cached _TokenState for the current client_id. Call this
+        after the token exchange ends to get rid of unnecessary state.
+        """
+        if self.client_id in self.server.token_state:
+            del self.server.token_state[self.client_id]
+
+    def authorization(self) -> JsonObject:
+        uri = "https://example.com/"
+        if self._alt_issuer:
+            uri = "https://example.org/"
+
+        resp = {
+            "device_code": "postgres",
+            "user_code": "postgresuser",
+            self._uri_spelling: uri,
+            "expires-in": 5,
+            **self._response_padding,
+        }
+
+        interval = self._interval
+        if interval is not None:
+            resp["interval"] = interval
+            self._token_state.min_delay = interval
+        else:
+            self._token_state.min_delay = 5  # default
+
+        # Check the scope.
+        if "scope" in self._params:
+            assert self._params["scope"][0], "empty scopes should be omitted"
+
+        return resp
+
+    def token(self) -> JsonObject:
+        if err := self._get_param("error_code", None):
+            self._response_code = self._get_param("error_status", 400)
+
+            resp = {"error": err}
+            if desc := self._get_param("error_desc", ""):
+                resp["error_description"] = desc
+
+            return resp
+
+        if self._should_modify() and "retries" in self._test_params:
+            retries = self._test_params["retries"]
+
+            # Check to make sure the token interval is being respected.
+            now = time.monotonic()
+            if self._token_state.last_try is not None:
+                delay = now - self._token_state.last_try
+                assert (
+                    delay > self._token_state.min_delay
+                ), f"client waited only {delay} seconds between token requests (expected {self._token_state.min_delay})"
+
+            self._token_state.last_try = now
+
+            # If we haven't reached the required number of retries yet, return a
+            # "pending" response.
+            if self._token_state.retries < retries:
+                self._token_state.retries += 1
+
+                self._response_code = 400
+                return {"error": self._retry_code}
+
+        # Clean up any retry tracking state now that the exchange is ending.
+        self._remove_token_state()
+
+        return {
+            "access_token": self._access_token,
+            "token_type": "bearer",
+            **self._response_padding,
+        }
+
+
+def main():
+    """
+    Starts the authorization server on localhost. The ephemeral port in use will
+    be printed to stdout.
+    """
+
+    s = http.server.HTTPServer(("127.0.0.1", 0), OAuthHandler)
+
+    # Attach a "cache" dictionary to the server to allow the OAuthHandlers to
+    # track state across token requests. The use of defaultdict ensures that new
+    # entries will be created automatically.
+    class _TokenState:
+        retries = 0
+        min_delay = None
+        last_try = None
+
+    s.token_state = defaultdict(_TokenState)
+
+    # Give the parent the port number to contact (this is also the signal that
+    # we're ready to receive requests).
+    port = s.socket.getsockname()[1]
+    print(port)
+
+    # stdout is closed to allow the parent to just "read to the end".
+    stdout = sys.stdout.fileno()
+    sys.stdout.close()
+    os.close(stdout)
+
+    s.serve_forever()  # we expect our parent to send a termination signal
+
+
+if __name__ == "__main__":
+    main()
diff --git a/src/test/modules/oauth_validator/validator.c b/src/test/modules/oauth_validator/validator.c
new file mode 100644
index 00000000000..bf94f091def
--- /dev/null
+++ b/src/test/modules/oauth_validator/validator.c
@@ -0,0 +1,135 @@
+/*-------------------------------------------------------------------------
+ *
+ * validator.c
+ *	  Test module for serverside OAuth token validation callbacks
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/test/modules/oauth_validator/validator.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "fmgr.h"
+#include "libpq/oauth.h"
+#include "miscadmin.h"
+#include "utils/guc.h"
+#include "utils/memutils.h"
+
+PG_MODULE_MAGIC;
+
+static void validator_startup(ValidatorModuleState *state);
+static void validator_shutdown(ValidatorModuleState *state);
+static ValidatorModuleResult *validate_token(ValidatorModuleState *state,
+											 const char *token,
+											 const char *role);
+
+/* Callback implementations (exercise all three) */
+static const OAuthValidatorCallbacks validator_callbacks = {
+	.startup_cb = validator_startup,
+	.shutdown_cb = validator_shutdown,
+	.validate_cb = validate_token
+};
+
+/* GUCs */
+static char *authn_id = NULL;
+static bool authorize_tokens = true;
+
+/*---
+ * Extension entry point. Sets up GUCs for use by tests:
+ *
+ * - oauth_validator.authn_id	Sets the user identifier to return during token
+ *								validation. Defaults to the username in the
+ *								startup packet.
+ *
+ * - oauth_validator.authorize_tokens
+ *								Sets whether to successfully validate incoming
+ *								tokens. Defaults to true.
+ */
+void
+_PG_init(void)
+{
+	DefineCustomStringVariable("oauth_validator.authn_id",
+							   "Authenticated identity to use for future connections",
+							   NULL,
+							   &authn_id,
+							   NULL,
+							   PGC_SIGHUP,
+							   0,
+							   NULL, NULL, NULL);
+	DefineCustomBoolVariable("oauth_validator.authorize_tokens",
+							 "Should tokens be marked valid?",
+							 NULL,
+							 &authorize_tokens,
+							 true,
+							 PGC_SIGHUP,
+							 0,
+							 NULL, NULL, NULL);
+
+	MarkGUCPrefixReserved("oauth_validator");
+}
+
+/*
+ * Validator module entry point.
+ */
+const OAuthValidatorCallbacks *
+_PG_oauth_validator_module_init(void)
+{
+	return &validator_callbacks;
+}
+
+#define PRIVATE_COOKIE ((void *) 13579)
+
+/*
+ * Startup callback, to set up private data for the validator.
+ */
+static void
+validator_startup(ValidatorModuleState *state)
+{
+	state->private_data = PRIVATE_COOKIE;
+}
+
+/*
+ * Shutdown callback, to tear down the validator.
+ */
+static void
+validator_shutdown(ValidatorModuleState *state)
+{
+	/* Check to make sure our private state still exists. */
+	if (state->private_data != PRIVATE_COOKIE)
+		elog(ERROR, "oauth_validator: private state cookie changed to %p in shutdown",
+			 state->private_data);
+}
+
+/*
+ * Validator implementation. Logs the incoming data and authorizes the token by
+ * default; the behavior can be modified via the module's GUC settings.
+ */
+static ValidatorModuleResult *
+validate_token(ValidatorModuleState *state, const char *token, const char *role)
+{
+	ValidatorModuleResult *res;
+
+	/* Check to make sure our private state still exists. */
+	if (state->private_data != PRIVATE_COOKIE)
+		elog(ERROR, "oauth_validator: private state cookie changed to %p in validate",
+			 state->private_data);
+
+	res = palloc(sizeof(ValidatorModuleResult));
+
+	elog(LOG, "oauth_validator: token=\"%s\", role=\"%s\"", token, role);
+	elog(LOG, "oauth_validator: issuer=\"%s\", scope=\"%s\"",
+		 MyProcPort->hba->oauth_issuer,
+		 MyProcPort->hba->oauth_scope);
+
+	res->authorized = authorize_tokens;
+	if (authn_id)
+		res->authn_id = pstrdup(authn_id);
+	else
+		res->authn_id = pstrdup(role);
+
+	return res;
+}
diff --git a/src/test/perl/PostgreSQL/Test/Cluster.pm b/src/test/perl/PostgreSQL/Test/Cluster.pm
index f521ad0b12f..ab7d7452ede 100644
--- a/src/test/perl/PostgreSQL/Test/Cluster.pm
+++ b/src/test/perl/PostgreSQL/Test/Cluster.pm
@@ -2515,6 +2515,11 @@ instead of the default.
 
 If this regular expression is set, matches it with the output generated.
 
+=item expected_stderr => B<value>
+
+If this regular expression is set, matches it against the standard error
+stream; otherwise the stderr must be empty.
+
 =item log_like => [ qr/required message/ ]
 
 =item log_unlike => [ qr/prohibited message/ ]
@@ -2558,7 +2563,20 @@ sub connect_ok
 		like($stdout, $params{expected_stdout}, "$test_name: stdout matches");
 	}
 
-	is($stderr, "", "$test_name: no stderr");
+	if (defined($params{expected_stderr}))
+	{
+		if (like($stderr, $params{expected_stderr}, "$test_name: stderr matches")
+			&& ($ret != 0))
+		{
+			# In this case (failing test but matching stderr) we'll have
+			# swallowed the output needed to debug. Put it back into the logs.
+			diag("$test_name: full stderr:\n" . $stderr);
+		}
+	}
+	else
+	{
+		is($stderr, "", "$test_name: no stderr");
+	}
 
 	$self->log_check($test_name, $log_location, %params);
 }
diff --git a/src/tools/pgindent/pgindent b/src/tools/pgindent/pgindent
index d8acce7e929..7dccf4614aa 100755
--- a/src/tools/pgindent/pgindent
+++ b/src/tools/pgindent/pgindent
@@ -242,6 +242,14 @@ sub pre_indent
 	# Protect wrapping in CATALOG()
 	$source =~ s!^(CATALOG\(.*)$!/*$1*/!gm;
 
+	# Treat a CURL_IGNORE_DEPRECATION() as braces for the purposes of
+	# indentation. (The recursive regex comes from the perlre documentation; it
+	# matches balanced parentheses as group $1 and the contents as group $2.)
+	my $curlopen = '{ /* CURL_IGNORE_DEPRECATION */';
+	my $curlclose = '} /* CURL_IGNORE_DEPRECATION */';
+	$source =~
+	  s!^[ \t]+CURL_IGNORE_DEPRECATION(\(((?:(?>[^()]+)|(?1))*)\))!$curlopen$2$curlclose!gms;
+
 	return $source;
 }
 
@@ -256,6 +264,12 @@ sub post_indent
 	$source =~ s!^/\* Open extern "C" \*/$!{!gm;
 	$source =~ s!^/\* Close extern "C" \*/$!}!gm;
 
+	# Restore the CURL_IGNORE_DEPRECATION() macro, keeping in mind that our
+	# markers may have been re-indented.
+	$source =~
+	  s!{[ \t]+/\* CURL_IGNORE_DEPRECATION \*/!CURL_IGNORE_DEPRECATION(!gm;
+	$source =~ s!}[ \t]+/\* CURL_IGNORE_DEPRECATION \*/!)!gm;
+
 	## Comments
 
 	# Undo change of dash-protected block comments
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 9a3bee93dec..f3e3592eb77 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -371,6 +371,9 @@ CState
 CTECycleClause
 CTEMaterialize
 CTESearchClause
+CURL
+CURLM
+CURLoption
 CV
 CachedExpression
 CachedPlan
@@ -1724,6 +1727,7 @@ NumericDigit
 NumericSortSupport
 NumericSumAccum
 NumericVar
+OAuthValidatorCallbacks
 OM_uint32
 OP
 OSAPerGroupState
@@ -1832,6 +1836,7 @@ PGVerbosity
 PG_Locale_Strategy
 PG_Lock_Status
 PG_init_t
+PGauthData
 PGcancel
 PGcancelConn
 PGcmdQueueEntry
@@ -1839,7 +1844,9 @@ PGconn
 PGdataValue
 PGlobjfuncs
 PGnotify
+PGoauthBearerRequest
 PGpipelineStatus
+PGpromptOAuthDevice
 PGresAttDesc
 PGresAttValue
 PGresParamDesc
@@ -1951,6 +1958,7 @@ PQArgBlock
 PQEnvironmentOption
 PQExpBuffer
 PQExpBufferData
+PQauthDataHook_type
 PQcommMethods
 PQconninfoOption
 PQnoticeProcessor
@@ -3089,6 +3097,8 @@ VacuumRelation
 VacuumStmt
 ValidIOData
 ValidateIndexState
+ValidatorModuleState
+ValidatorModuleResult
 ValuesScan
 ValuesScanState
 Var
@@ -3485,6 +3495,7 @@ explain_get_index_name_hook_type
 f_smgr
 fasthash_state
 fd_set
+fe_oauth_state
 fe_scram_state
 fe_scram_state_enum
 fetch_range_request
-- 
2.34.1



  [application/octet-stream] v47-0002-fixup-Add-OAUTHBEARER-SASL-mechanism.patch (3.3K, ../../CAOYmi+nHG7oy+ybHH72WjiXAQG3tE6v_at-K9ebRy2oqo92V+A@mail.gmail.com/4-v47-0002-fixup-Add-OAUTHBEARER-SASL-mechanism.patch)
  download | inline diff:
From 483129c1ca931f60b76ef93bf2f646cea2f00568 Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Thu, 6 Feb 2025 13:16:04 -0800
Subject: [PATCH v47 02/11] fixup! Add OAUTHBEARER SASL mechanism

---
 src/interfaces/libpq/fe-auth-oauth-curl.c | 35 +++++++++++++++++------
 1 file changed, 26 insertions(+), 9 deletions(-)

diff --git a/src/interfaces/libpq/fe-auth-oauth-curl.c b/src/interfaces/libpq/fe-auth-oauth-curl.c
index 2407200ea97..eeddace7060 100644
--- a/src/interfaces/libpq/fe-auth-oauth-curl.c
+++ b/src/interfaces/libpq/fe-auth-oauth-curl.c
@@ -973,11 +973,20 @@ parse_access_token(struct async_ctx *actx, struct token *tok)
 		{"access_token", JSON_TOKEN_STRING, {&tok->access_token}, REQUIRED},
 		{"token_type", JSON_TOKEN_STRING, {&tok->token_type}, REQUIRED},
 
-		/*
-		 * The following fields are technically REQUIRED, but we don't use
-		 * them anywhere yet:
+		/*---
+		 * We currently have no use for the following OPTIONAL fields:
+		 *
+		 * - expires_in: This will be important for maintaining a token cache,
+		 *               but we do not yet implement one.
 		 *
-		 * - scope (only required if different than requested -- TODO check)
+		 * - refresh_token: Ditto.
+		 *
+		 * - scope: This is only sent when the authorization server sees fit to
+		 *          change our scope request. It's not clear what we should do
+		 *          about this; either it's been done as a matter of policy, or
+		 *          the user has explicitly denied part of the authorization,
+		 *          and either way the server-side validator is in a better
+		 *          place to complain if the change isn't acceptable.
 		 */
 
 		{0},
@@ -1252,8 +1261,11 @@ register_timer(CURLM *curlm, long timeout, void *ctx)
 	struct async_ctx *actx = ctx;
 
 	/*
-	 * TODO: maybe just signal drive_request() to immediately call back in the
-	 * (timeout == 0) case?
+	 * There might be an optimization opportunity here: if timeout == 0, we
+	 * could signal drive_request to immediately call
+	 * curl_multi_socket_action, rather than returning all the way up the
+	 * stack only to come right back. But it's not clear that the additional
+	 * code complexity is worth it.
 	 */
 	if (!set_timer(actx, timeout))
 		return -1;				/* actx_error already called */
@@ -1415,7 +1427,14 @@ setup_curl_handles(struct async_ctx *actx)
 		CHECK_SETOPT(actx, popt, protos, return false);
 	}
 
-	/* TODO: would anyone use this in "real" situations, or just testing? */
+	/*
+	 * If we're in debug mode, allow the developer to change the trusted CA
+	 * list. For now, this is not something we expose outside of the UNSAFE
+	 * mode, because it's not clear that it's useful in production: both libpq
+	 * and the user's browser must trust the same authorization servers for
+	 * the flow to work at all, so any changes to the roots are likely to be
+	 * done system-wide.
+	 */
 	if (actx->debugging)
 	{
 		const char *env;
@@ -1824,8 +1843,6 @@ check_issuer(struct async_ctx *actx, PGconn *conn)
 	 *    of the authorization server where the authorization request was
 	 *    sent to. This comparison MUST use simple string comparison as defined
 	 *    in Section 6.2.1 of [RFC3986].
-	 *
-	 * TODO: Encoding support?
 	 */
 	if (strcmp(conn->oauth_issuer_id, provider->issuer) != 0)
 	{
-- 
2.34.1



  [application/octet-stream] v47-0003-fixup-Add-OAUTHBEARER-SASL-mechanism.patch (935B, ../../CAOYmi+nHG7oy+ybHH72WjiXAQG3tE6v_at-K9ebRy2oqo92V+A@mail.gmail.com/5-v47-0003-fixup-Add-OAUTHBEARER-SASL-mechanism.patch)
  download | inline diff:
From 75d98784dedc6d5e5468bd7d2c63322ff83c09d7 Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Tue, 4 Feb 2025 17:00:47 -0800
Subject: [PATCH v47 03/11] fixup! Add OAUTHBEARER SASL mechanism

---
 src/interfaces/libpq/fe-auth-oauth-curl.c | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/interfaces/libpq/fe-auth-oauth-curl.c b/src/interfaces/libpq/fe-auth-oauth-curl.c
index eeddace7060..dc47a7bdf11 100644
--- a/src/interfaces/libpq/fe-auth-oauth-curl.c
+++ b/src/interfaces/libpq/fe-auth-oauth-curl.c
@@ -1239,7 +1239,7 @@ set_timer(struct async_ctx *actx, long timeout)
 #ifdef HAVE_SYS_EVENT_H
 	struct kevent ev;
 
-	EV_SET(&ev, 1, EVFILT_TIMER, timeout < 0 ? EV_DELETE : EV_ADD,
+	EV_SET(&ev, 1, EVFILT_TIMER, timeout < 0 ? EV_DELETE : (EV_ADD | EV_ONESHOT),
 		   0, timeout, 0);
 	if (kevent(actx->mux, &ev, 1, NULL, 0, NULL) < 0 && errno != ENOENT)
 	{
-- 
2.34.1



  [application/octet-stream] v47-0004-fixup-Add-OAUTHBEARER-SASL-mechanism.patch (2.6K, ../../CAOYmi+nHG7oy+ybHH72WjiXAQG3tE6v_at-K9ebRy2oqo92V+A@mail.gmail.com/6-v47-0004-fixup-Add-OAUTHBEARER-SASL-mechanism.patch)
  download | inline diff:
From fd60ceb4c849c2a13b0201b57753506b60718e6e Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Thu, 6 Feb 2025 20:22:03 -0800
Subject: [PATCH v47 04/11] fixup! Add OAUTHBEARER SASL mechanism

---
 src/backend/libpq/auth-oauth.c               | 13 +++++++++----
 src/test/modules/oauth_validator/validator.c |  2 +-
 2 files changed, 10 insertions(+), 5 deletions(-)

diff --git a/src/backend/libpq/auth-oauth.c b/src/backend/libpq/auth-oauth.c
index 6155d63a116..d910cbcb161 100644
--- a/src/backend/libpq/auth-oauth.c
+++ b/src/backend/libpq/auth-oauth.c
@@ -39,7 +39,7 @@ static int	oauth_exchange(void *opaq, const char *input, int inputlen,
 						   char **output, int *outputlen, const char **logdetail);
 
 static void load_validator_library(const char *libname);
-static void shutdown_validator_library(int code, Datum arg);
+static void shutdown_validator_library(void *arg);
 
 static ValidatorModuleState *validator_module_state;
 static const OAuthValidatorCallbacks *ValidatorCallbacks;
@@ -737,6 +737,7 @@ static void
 load_validator_library(const char *libname)
 {
 	OAuthValidatorModuleInit validator_init;
+	MemoryContextCallback *mcb;
 
 	Assert(libname && *libname);
 
@@ -761,15 +762,19 @@ load_validator_library(const char *libname)
 	if (ValidatorCallbacks->startup_cb != NULL)
 		ValidatorCallbacks->startup_cb(validator_module_state);
 
-	before_shmem_exit(shutdown_validator_library, 0);
+	/* Shut down the library before cleaning up its state. */
+	mcb = palloc0(sizeof(*mcb));
+	mcb->func = shutdown_validator_library;
+
+	MemoryContextRegisterResetCallback(CurrentMemoryContext, mcb);
 }
 
 /*
  * Call the validator module's shutdown callback, if one is provided. This is
- * invoked via before_shmem_exit().
+ * invoked during memory context reset.
  */
 static void
-shutdown_validator_library(int code, Datum arg)
+shutdown_validator_library(void *arg)
 {
 	if (ValidatorCallbacks->shutdown_cb != NULL)
 		ValidatorCallbacks->shutdown_cb(validator_module_state);
diff --git a/src/test/modules/oauth_validator/validator.c b/src/test/modules/oauth_validator/validator.c
index bf94f091def..ef9bbb2866f 100644
--- a/src/test/modules/oauth_validator/validator.c
+++ b/src/test/modules/oauth_validator/validator.c
@@ -100,7 +100,7 @@ validator_shutdown(ValidatorModuleState *state)
 {
 	/* Check to make sure our private state still exists. */
 	if (state->private_data != PRIVATE_COOKIE)
-		elog(ERROR, "oauth_validator: private state cookie changed to %p in shutdown",
+		elog(PANIC, "oauth_validator: private state cookie changed to %p in shutdown",
 			 state->private_data);
 }
 
-- 
2.34.1



  [application/octet-stream] v47-0005-fixup-Add-OAUTHBEARER-SASL-mechanism.patch (6.3K, ../../CAOYmi+nHG7oy+ybHH72WjiXAQG3tE6v_at-K9ebRy2oqo92V+A@mail.gmail.com/7-v47-0005-fixup-Add-OAUTHBEARER-SASL-mechanism.patch)
  download | inline diff:
From 595362ef2c16bbca684a5d8e9fd6c416894444b0 Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Tue, 4 Feb 2025 11:37:44 -0800
Subject: [PATCH v47 05/11] fixup! Add OAUTHBEARER SASL mechanism

---
 config/programs.m4                        | 23 ++++++++++
 configure                                 | 53 +++++++++++++++++++++++
 meson.build                               | 34 +++++++++++++++
 src/interfaces/libpq/fe-auth-oauth-curl.c | 15 ++-----
 4 files changed, 114 insertions(+), 11 deletions(-)

diff --git a/config/programs.m4 b/config/programs.m4
index 86a3750f9e5..ead427046f5 100644
--- a/config/programs.m4
+++ b/config/programs.m4
@@ -315,4 +315,27 @@ AC_DEFUN([PGAC_CHECK_LIBCURL],
     AC_DEFINE(HAVE_THREADSAFE_CURL_GLOBAL_INIT, 1,
               [Define to 1 if curl_global_init() is guaranteed to be threadsafe.])
   fi
+
+  # Warn if a thread-friendly DNS resolver isn't built.
+  AC_CACHE_CHECK([for curl support for asynchronous DNS], [pgac_cv__libcurl_async_dns],
+  [AC_RUN_IFELSE([AC_LANG_PROGRAM([
+#include <curl/curl.h>
+],[
+    curl_version_info_data *info;
+
+    if (curl_global_init(CURL_GLOBAL_ALL))
+        return -1;
+
+    info = curl_version_info(CURLVERSION_NOW);
+    return (info->features & CURL_VERSION_ASYNCHDNS) ? 0 : 1;
+])],
+  [pgac_cv__libcurl_async_dns=yes],
+  [pgac_cv__libcurl_async_dns=no],
+  [pgac_cv__libcurl_async_dns=unknown])])
+  if test x"$pgac_cv__libcurl_async_dns" != xyes ; then
+    AC_MSG_WARN([
+*** The installed version of libcurl does not support asynchronous DNS
+*** lookups. Connection timeouts will not be honored during DNS resolution,
+*** which may lead to hangs in client programs.])
+  fi
 ])# PGAC_CHECK_LIBCURL
diff --git a/configure b/configure
index 33422d24112..93fddd69981 100755
--- a/configure
+++ b/configure
@@ -12493,6 +12493,59 @@ $as_echo "#define HAVE_THREADSAFE_CURL_GLOBAL_INIT 1" >>confdefs.h
 
   fi
 
+  # Warn if a thread-friendly DNS resolver isn't built.
+  { $as_echo "$as_me:${as_lineno-$LINENO}: checking for curl support for asynchronous DNS" >&5
+$as_echo_n "checking for curl support for asynchronous DNS... " >&6; }
+if ${pgac_cv__libcurl_async_dns+:} false; then :
+  $as_echo_n "(cached) " >&6
+else
+  if test "$cross_compiling" = yes; then :
+  pgac_cv__libcurl_async_dns=unknown
+else
+  cat confdefs.h - <<_ACEOF >conftest.$ac_ext
+/* end confdefs.h.  */
+
+#include <curl/curl.h>
+
+int
+main ()
+{
+
+    curl_version_info_data *info;
+
+    if (curl_global_init(CURL_GLOBAL_ALL))
+        return -1;
+
+    info = curl_version_info(CURLVERSION_NOW);
+    return (info->features & CURL_VERSION_ASYNCHDNS) ? 0 : 1;
+
+  ;
+  return 0;
+}
+_ACEOF
+if ac_fn_c_try_run "$LINENO"; then :
+  pgac_cv__libcurl_async_dns=yes
+else
+  pgac_cv__libcurl_async_dns=no
+fi
+rm -f core *.core core.conftest.* gmon.out bb.out conftest$ac_exeext \
+  conftest.$ac_objext conftest.beam conftest.$ac_ext
+fi
+
+fi
+{ $as_echo "$as_me:${as_lineno-$LINENO}: result: $pgac_cv__libcurl_async_dns" >&5
+$as_echo "$pgac_cv__libcurl_async_dns" >&6; }
+  if test x"$pgac_cv__libcurl_async_dns" != xyes ; then
+    { $as_echo "$as_me:${as_lineno-$LINENO}: WARNING:
+*** The installed version of libcurl does not support asynchronous DNS
+*** lookups. Connection timeouts will not be honored during DNS resolution,
+*** which may lead to hangs in client programs." >&5
+$as_echo "$as_me: WARNING:
+*** The installed version of libcurl does not support asynchronous DNS
+*** lookups. Connection timeouts will not be honored during DNS resolution,
+*** which may lead to hangs in client programs." >&2;}
+  fi
+
 fi
 
 if test "$with_gssapi" = yes ; then
diff --git a/meson.build b/meson.build
index 80a6b1d57d6..96e5f0f6434 100644
--- a/meson.build
+++ b/meson.build
@@ -907,6 +907,40 @@ if not libcurlopt.disabled()
     if libcurl_threadsafe_init
       cdata.set('HAVE_THREADSAFE_CURL_GLOBAL_INIT', 1)
     endif
+
+    # Warn if a thread-friendly DNS resolver isn't built.
+    libcurl_async_dns = false
+
+    if not meson.is_cross_build()
+      r = cc.run('''
+        #include <curl/curl.h>
+
+        int main(void)
+        {
+            curl_version_info_data *info;
+
+            if (curl_global_init(CURL_GLOBAL_ALL))
+                return -1;
+
+            info = curl_version_info(CURLVERSION_NOW);
+            return (info->features & CURL_VERSION_ASYNCHDNS) ? 0 : 1;
+        }''',
+        name: 'test for curl support for asynchronous DNS',
+        dependencies: libcurl,
+      )
+
+      assert(r.compiled())
+      if r.returncode() == 0
+        libcurl_async_dns = true
+      endif
+    endif
+
+    if not libcurl_async_dns
+      warning('''
+*** The installed version of libcurl does not support asynchronous DNS
+*** lookups. Connection timeouts will not be honored during DNS resolution,
+*** which may lead to hangs in client programs.''')
+    endif
   endif
 
 else
diff --git a/src/interfaces/libpq/fe-auth-oauth-curl.c b/src/interfaces/libpq/fe-auth-oauth-curl.c
index dc47a7bdf11..b6255834f00 100644
--- a/src/interfaces/libpq/fe-auth-oauth-curl.c
+++ b/src/interfaces/libpq/fe-auth-oauth-curl.c
@@ -1339,8 +1339,6 @@ debug_callback(CURL *handle, curl_infotype type, char *data, size_t size,
 static bool
 setup_curl_handles(struct async_ctx *actx)
 {
-	curl_version_info_data *curl_info;
-
 	/*
 	 * Create our multi handle. This encapsulates the entire conversation with
 	 * libcurl for this connection.
@@ -1353,11 +1351,6 @@ setup_curl_handles(struct async_ctx *actx)
 		return false;
 	}
 
-	/*
-	 * Extract information about the libcurl we are linked against.
-	 */
-	curl_info = curl_version_info(CURLVERSION_NOW);
-
 	/*
 	 * The multi handle tells us what to wait on using two callbacks. These
 	 * will manipulate actx->mux as needed.
@@ -1382,12 +1375,12 @@ setup_curl_handles(struct async_ctx *actx)
 	 * Multi-threaded applications must set CURLOPT_NOSIGNAL. This requires us
 	 * to handle the possibility of SIGPIPE ourselves using pq_block_sigpipe;
 	 * see pg_fe_run_oauth_flow().
+	 *
+	 * NB: If libcurl is not built against a friendly DNS resolver (c-ares or
+	 * threaded), setting this option prevents DNS lookups from timing out
+	 * correctly. We warn about this situation at configure time.
 	 */
 	CHECK_SETOPT(actx, CURLOPT_NOSIGNAL, 1L, return false);
-	if (!curl_info->ares_num)
-	{
-		/* No alternative resolver, TODO: warn about timeouts */
-	}
 
 	if (actx->debugging)
 	{
-- 
2.34.1



  [application/octet-stream] v47-0006-fixup-Add-OAUTHBEARER-SASL-mechanism.patch (5.1K, ../../CAOYmi+nHG7oy+ybHH72WjiXAQG3tE6v_at-K9ebRy2oqo92V+A@mail.gmail.com/8-v47-0006-fixup-Add-OAUTHBEARER-SASL-mechanism.patch)
  download | inline diff:
From f73c042adc97544c10542c4818afb703cc20ed7a Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Mon, 3 Feb 2025 16:19:25 +0100
Subject: [PATCH v47 06/11] fixup! Add OAUTHBEARER SASL mechanism

---
 src/interfaces/libpq/fe-auth-oauth-curl.c | 74 ++++++++++++++++++-----
 src/interfaces/libpq/fe-auth-oauth.c      | 14 ++++-
 2 files changed, 73 insertions(+), 15 deletions(-)

diff --git a/src/interfaces/libpq/fe-auth-oauth-curl.c b/src/interfaces/libpq/fe-auth-oauth-curl.c
index b6255834f00..6c8333789f1 100644
--- a/src/interfaces/libpq/fe-auth-oauth-curl.c
+++ b/src/interfaces/libpq/fe-auth-oauth-curl.c
@@ -461,12 +461,15 @@ oauth_json_object_field_start(void *state, char *name, bool isnull)
 		/*
 		 * We should never start parsing a new field while a previous one is
 		 * still active.
-		 *
-		 * TODO: this code relies on assertions too much. We need to exit
-		 * sanely on internal logic errors, to avoid turning bugs into
-		 * vulnerabilities.
 		 */
-		Assert(!ctx->active);
+		if (ctx->active)
+		{
+			Assert(false);
+			oauth_parse_set_error(ctx,
+								  "internal error: started field '%s' before field '%s' was finished",
+								  name, ctx->active->name);
+			return JSON_SEM_ACTION_FAILED;
+		}
 
 		while (field->name)
 		{
@@ -506,8 +509,19 @@ oauth_json_object_end(void *state)
 	struct oauth_parse *ctx = state;
 
 	--ctx->nested;
-	if (!ctx->nested)
-		Assert(!ctx->active);	/* all fields should be fully processed */
+
+	/*
+	 * All fields should be fully processed by the end of the top-level
+	 * object.
+	 */
+	if (!ctx->nested && ctx->active)
+	{
+		Assert(false);
+		oauth_parse_set_error(ctx,
+							  "internal error: field '%s' still active at end of object",
+							  ctx->active->name);
+		return JSON_SEM_ACTION_FAILED;
+	}
 
 	return JSON_SUCCESS;
 }
@@ -546,11 +560,18 @@ oauth_json_array_end(void *state)
 	if (ctx->active)
 	{
 		/*
-		 * This assumes that no target arrays can contain other arrays, which
-		 * we check in the array_start callback.
+		 * Clear the target (which should be an array inside the top-level
+		 * object). For this to be safe, no target arrays can contain other
+		 * arrays; we check for that in the array_start callback.
 		 */
-		Assert(ctx->nested == 2);
-		Assert(ctx->active->type == JSON_TOKEN_ARRAY_START);
+		if (ctx->nested != 2 || ctx->active->type != JSON_TOKEN_ARRAY_START)
+		{
+			Assert(false);
+			oauth_parse_set_error(ctx,
+								  "internal error: found unexpected array end while parsing field '%s'",
+								  ctx->active->name);
+			return JSON_SEM_ACTION_FAILED;
+		}
 
 		ctx->active = NULL;
 	}
@@ -597,8 +618,25 @@ oauth_json_scalar(void *state, char *token, JsonTokenType type)
 
 		if (field->type != JSON_TOKEN_ARRAY_START)
 		{
-			Assert(ctx->nested == 1);
-			Assert(!*field->target.scalar);
+			/* Ensure that we're parsing the top-level keys... */
+			if (ctx->nested != 1)
+			{
+				Assert(false);
+				oauth_parse_set_error(ctx,
+									  "internal error: scalar target found at nesting level %d",
+									  ctx->nested);
+				return JSON_SEM_ACTION_FAILED;
+			}
+
+			/* ...and that a result has not already been set. */
+			if (*field->target.scalar)
+			{
+				Assert(false);
+				oauth_parse_set_error(ctx,
+									  "internal error: scalar field '%s' would be assigned twice",
+									  ctx->active->name);
+				return JSON_SEM_ACTION_FAILED;
+			}
 
 			*field->target.scalar = strdup(token);
 			if (!*field->target.scalar)
@@ -612,7 +650,15 @@ oauth_json_scalar(void *state, char *token, JsonTokenType type)
 		{
 			struct curl_slist *temp;
 
-			Assert(ctx->nested == 2);
+			/* The target array should be inside the top-level object. */
+			if (ctx->nested != 2)
+			{
+				Assert(false);
+				oauth_parse_set_error(ctx,
+									  "internal error: array member found at nesting level %d",
+									  ctx->nested);
+				return JSON_SEM_ACTION_FAILED;
+			}
 
 			/* Note that curl_slist_append() makes a copy of the token. */
 			temp = curl_slist_append(*field->target.array, token);
diff --git a/src/interfaces/libpq/fe-auth-oauth.c b/src/interfaces/libpq/fe-auth-oauth.c
index cc53e2bdd1a..8beae9604c7 100644
--- a/src/interfaces/libpq/fe-auth-oauth.c
+++ b/src/interfaces/libpq/fe-auth-oauth.c
@@ -208,6 +208,7 @@ oauth_json_object_field_start(void *state, char *name, bool isnull)
 {
 	struct json_ctx *ctx = state;
 
+	/* Only top-level keys are considered. */
 	if (ctx->nested == 1)
 	{
 		if (strcmp(name, ERROR_STATUS_FIELD) == 0)
@@ -264,7 +265,18 @@ oauth_json_scalar(void *state, char *token, JsonTokenType type)
 
 	if (ctx->target_field)
 	{
-		Assert(ctx->nested == 1);
+		if (ctx->nested != 1)
+		{
+			/*
+			 * ctx->target_field should not have been set for nested keys.
+			 * Assert and don't continue any further for production builds.
+			 */
+			Assert(false);
+			oauth_json_set_error(ctx,	/* don't bother translating */
+								 "internal error: target scalar found at nesting level %d during OAUTHBEARER parsing",
+								 ctx->nested);
+			return JSON_SEM_ACTION_FAILED;
+		}
 
 		/*
 		 * We don't allow duplicate field names; error out if the target has
-- 
2.34.1



  [application/octet-stream] v47-0007-fixup-Add-OAUTHBEARER-SASL-mechanism.patch (2.3K, ../../CAOYmi+nHG7oy+ybHH72WjiXAQG3tE6v_at-K9ebRy2oqo92V+A@mail.gmail.com/9-v47-0007-fixup-Add-OAUTHBEARER-SASL-mechanism.patch)
  download | inline diff:
From 298839b69f04d01ec5ba2e77c3b9f7d351384809 Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Thu, 6 Feb 2025 14:56:04 -0800
Subject: [PATCH v47 07/11] fixup! Add OAUTHBEARER SASL mechanism

---
 src/interfaces/libpq/fe-auth-oauth-curl.c | 48 +++++++++++++++++------
 1 file changed, 37 insertions(+), 11 deletions(-)

diff --git a/src/interfaces/libpq/fe-auth-oauth-curl.c b/src/interfaces/libpq/fe-auth-oauth-curl.c
index 6c8333789f1..993ca3bdab9 100644
--- a/src/interfaces/libpq/fe-auth-oauth-curl.c
+++ b/src/interfaces/libpq/fe-auth-oauth-curl.c
@@ -1329,8 +1329,9 @@ static int
 debug_callback(CURL *handle, curl_infotype type, char *data, size_t size,
 			   void *clientp)
 {
-	const char *const end = data + size;
 	const char *prefix;
+	bool		printed_prefix = false;
+	PQExpBufferData buf;
 
 	/* Prefixes are modeled off of the default libcurl debug output. */
 	switch (type)
@@ -1353,25 +1354,50 @@ debug_callback(CURL *handle, curl_infotype type, char *data, size_t size,
 			return 0;
 	}
 
+	initPQExpBuffer(&buf);
+
 	/*
 	 * Split the output into lines for readability; sometimes multiple headers
-	 * are included in a single call.
+	 * are included in a single call. We also don't allow unprintable ASCII
+	 * through without a basic <XX> escape.
 	 */
-	while (data < end)
+	for (int i = 0; i < size; i++)
 	{
-		size_t		len = end - data;
-		char	   *eol = memchr(data, '\n', len);
+		char		c = data[i];
 
-		if (eol)
-			len = eol - data + 1;
+		if (!printed_prefix)
+		{
+			appendPQExpBuffer(&buf, "%s ", prefix);
+			printed_prefix = true;
+		}
 
-		/* TODO: handle unprintables */
-		fprintf(stderr, "%s %.*s%s", prefix, (int) len, data,
-				eol ? "" : "\n");
+		if (c >= 0x20 && c <= 0x7E)
+			appendPQExpBufferChar(&buf, c);
+		else if ((type == CURLINFO_HEADER_IN
+				  || type == CURLINFO_HEADER_OUT
+				  || type == CURLINFO_TEXT)
+				 && (c == '\r' || c == '\n'))
+		{
+			/*
+			 * Don't bother emitting <0D><0A> for headers and text; it's not
+			 * helpful noise.
+			 */
+		}
+		else
+			appendPQExpBuffer(&buf, "<%02X>", c);
 
-		data += len;
+		if (c == '\n')
+		{
+			appendPQExpBufferChar(&buf, c);
+			printed_prefix = false;
+		}
 	}
 
+	if (printed_prefix)
+		appendPQExpBufferChar(&buf, '\n');	/* finish the line */
+
+	fprintf(stderr, "%s", buf.data);
+	termPQExpBuffer(&buf);
 	return 0;
 }
 
-- 
2.34.1



  [application/octet-stream] v47-0008-fixup-Add-OAUTHBEARER-SASL-mechanism.patch (8.8K, ../../CAOYmi+nHG7oy+ybHH72WjiXAQG3tE6v_at-K9ebRy2oqo92V+A@mail.gmail.com/10-v47-0008-fixup-Add-OAUTHBEARER-SASL-mechanism.patch)
  download | inline diff:
From 1cf48a8f83505a0cce1f94f8b0b563d4dcdd547a Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Wed, 5 Feb 2025 15:49:01 -0800
Subject: [PATCH v47 08/11] fixup! Add OAUTHBEARER SASL mechanism

---
 doc/src/sgml/libpq.sgml                       | 13 +++
 src/interfaces/libpq/fe-auth-oauth-curl.c     | 86 ++++++++++++++-----
 src/interfaces/libpq/libpq-fe.h               |  3 +
 .../modules/oauth_validator/t/oauth_server.py |  2 +-
 4 files changed, 80 insertions(+), 24 deletions(-)

diff --git a/doc/src/sgml/libpq.sgml b/doc/src/sgml/libpq.sgml
index 9a69ffbc5b3..ddfc2a27c50 100644
--- a/doc/src/sgml/libpq.sgml
+++ b/doc/src/sgml/libpq.sgml
@@ -10228,6 +10228,9 @@ typedef struct _PGpromptOAuthDevice
 {
     const char *verification_uri;   /* verification URI to visit */
     const char *user_code;          /* user code to enter */
+    const char *verification_uri_complete;  /* optional combination of URI and
+                                             * code, or NULL */
+    int         expires_in;         /* seconds until user code expires */
 } PGpromptOAuthDevice;
 </synopsis>
         </para>
@@ -10246,6 +10249,16 @@ typedef struct _PGpromptOAuthDevice
          <link linkend="libpq-oauth-authdata-oauth-bearer-token">custom OAuth
          flow</link>, this authdata type will not be used.
         </para>
+        <para>
+         If a non-NULL <structfield>verification_uri_complete</structfield> is
+         provided, it may optionally be used for non-textual verification (for
+         example, by displaying a QR code). The URL and user code should still
+         be displayed to the end user in this case, because the code will be
+         manually confirmed by the provider, and the URL lets users continue
+         even if they can't use the non-textual method. Review the RFC's
+         <ulink url="https://datatracker.ietf.org/doc/html/rfc8628#section-3.3.1">notes
+         on non-textual verification</ulink>.
+        </para>
        </listitem>
       </varlistentry>
 
diff --git a/src/interfaces/libpq/fe-auth-oauth-curl.c b/src/interfaces/libpq/fe-auth-oauth-curl.c
index 993ca3bdab9..02c5b50afcd 100644
--- a/src/interfaces/libpq/fe-auth-oauth-curl.c
+++ b/src/interfaces/libpq/fe-auth-oauth-curl.c
@@ -76,9 +76,12 @@ struct device_authz
 	char	   *device_code;
 	char	   *user_code;
 	char	   *verification_uri;
+	char	   *verification_uri_complete;
+	char	   *expires_in_str;
 	char	   *interval_str;
 
 	/* Fields below are parsed from the corresponding string above. */
+	int			expires_in;
 	int			interval;
 };
 
@@ -88,6 +91,8 @@ free_device_authz(struct device_authz *authz)
 	free(authz->device_code);
 	free(authz->user_code);
 	free(authz->verification_uri);
+	free(authz->verification_uri_complete);
+	free(authz->expires_in_str);
 	free(authz->interval_str);
 }
 
@@ -853,20 +858,12 @@ parse_provider(struct async_ctx *actx, struct provider *provider)
 }
 
 /*
- * Parses the "interval" JSON number, corresponding to the number of seconds to
- * wait between token endpoint requests.
- *
- * RFC 8628 is pretty silent on sanity checks for the interval. As a matter of
- * practicality, round any fractional intervals up to the next second, and clamp
- * the result at a minimum of one. (Zero-second intervals would result in an
- * expensive network polling loop.) Tests may remove the lower bound with
- * PGOAUTHDEBUG, for improved performance.
- *
- * TODO: maybe clamp the upper bound too, based on the libpq timeout and/or the
- * code expiration time?
+ * Parses a valid JSON number into a double. The input must have come from
+ * pg_parse_json(), so that we know the lexer has validated it; there's no
+ * in-band signal for invalid formats.
  */
-static int
-parse_interval(struct async_ctx *actx, const char *interval_str)
+static double
+parse_json_number(const char *s)
 {
 	double		parsed;
 	int			cnt;
@@ -875,7 +872,7 @@ parse_interval(struct async_ctx *actx, const char *interval_str)
 	 * The JSON lexer has already validated the number, which is stricter than
 	 * the %f format, so we should be good to use sscanf().
 	 */
-	cnt = sscanf(interval_str, "%lf", &parsed);
+	cnt = sscanf(s, "%lf", &parsed);
 
 	if (cnt != 1)
 	{
@@ -884,9 +881,28 @@ parse_interval(struct async_ctx *actx, const char *interval_str)
 		 * either way a developer needs to take a look.
 		 */
 		Assert(cnt == 1);
-		return 1;				/* don't fall through in release builds */
+		return 0;
 	}
 
+	return parsed;
+}
+
+/*
+ * Parses the "interval" JSON number, corresponding to the number of seconds to
+ * wait between token endpoint requests.
+ *
+ * RFC 8628 is pretty silent on sanity checks for the interval. As a matter of
+ * practicality, round any fractional intervals up to the next second, and clamp
+ * the result at a minimum of one. (Zero-second intervals would result in an
+ * expensive network polling loop.) Tests may remove the lower bound with
+ * PGOAUTHDEBUG, for improved performance.
+ */
+static int
+parse_interval(struct async_ctx *actx, const char *interval_str)
+{
+	double		parsed;
+
+	parsed = parse_json_number(interval_str);
 	parsed = ceil(parsed);
 
 	if (parsed < 1)
@@ -898,6 +914,31 @@ parse_interval(struct async_ctx *actx, const char *interval_str)
 	return parsed;
 }
 
+/*
+ * Parses the "expires_in" JSON number, corresponding to the number of seconds
+ * remaining in the lifetime of the device code request.
+ *
+ * Similar to parse_interval, but we have even fewer requirements for reasonable
+ * values since we don't use the expiration time directly (it's passed to the
+ * PQAUTHDATA_PROMPT_OAUTH_DEVICE hook, in case the application wants to do
+ * something with it). We simply round and clamp to int range.
+ */
+static int
+parse_expires_in(struct async_ctx *actx, const char *expires_in_str)
+{
+	double		parsed;
+
+	parsed = parse_json_number(expires_in_str);
+	parsed = round(parsed);
+
+	if (INT_MAX <= parsed)
+		return INT_MAX;
+	else if (parsed <= INT_MIN)
+		return INT_MIN;
+
+	return parsed;
+}
+
 /*
  * Parses the Device Authorization Response (RFC 8628, Sec. 3.2).
  */
@@ -908,6 +949,7 @@ parse_device_authz(struct async_ctx *actx, struct device_authz *authz)
 		{"device_code", JSON_TOKEN_STRING, {&authz->device_code}, REQUIRED},
 		{"user_code", JSON_TOKEN_STRING, {&authz->user_code}, REQUIRED},
 		{"verification_uri", JSON_TOKEN_STRING, {&authz->verification_uri}, REQUIRED},
+		{"expires_in", JSON_TOKEN_NUMBER, {&authz->expires_in_str}, REQUIRED},
 
 		/*
 		 * Some services (Google, Azure) spell verification_uri differently.
@@ -915,13 +957,7 @@ parse_device_authz(struct async_ctx *actx, struct device_authz *authz)
 		 */
 		{"verification_url", JSON_TOKEN_STRING, {&authz->verification_uri}, REQUIRED},
 
-		/*
-		 * The following fields are technically REQUIRED, but we don't use
-		 * them anywhere yet:
-		 *
-		 * - expires_in
-		 */
-
+		{"verification_uri_complete", JSON_TOKEN_STRING, {&authz->verification_uri_complete}, OPTIONAL},
 		{"interval", JSON_TOKEN_NUMBER, {&authz->interval_str}, OPTIONAL},
 
 		{0},
@@ -945,6 +981,9 @@ parse_device_authz(struct async_ctx *actx, struct device_authz *authz)
 		authz->interval = 5;
 	}
 
+	Assert(authz->expires_in_str);	/* ensured by parse_oauth_json() */
+	authz->expires_in = parse_expires_in(actx, authz->expires_in_str);
+
 	return true;
 }
 
@@ -2301,7 +2340,8 @@ prompt_user(struct async_ctx *actx, PGconn *conn)
 	PGpromptOAuthDevice prompt = {
 		.verification_uri = actx->authz.verification_uri,
 		.user_code = actx->authz.user_code,
-		/* TODO: optional fields */
+		.verification_uri_complete = actx->authz.verification_uri_complete,
+		.expires_in = actx->authz.expires_in,
 	};
 
 	res = PQauthDataHook(PQAUTHDATA_PROMPT_OAUTH_DEVICE, conn, &prompt);
diff --git a/src/interfaces/libpq/libpq-fe.h b/src/interfaces/libpq/libpq-fe.h
index 5f8d608261e..b7399dee58e 100644
--- a/src/interfaces/libpq/libpq-fe.h
+++ b/src/interfaces/libpq/libpq-fe.h
@@ -733,6 +733,9 @@ typedef struct _PGpromptOAuthDevice
 {
 	const char *verification_uri;	/* verification URI to visit */
 	const char *user_code;		/* user code to enter */
+	const char *verification_uri_complete;	/* optional combination of URI and
+											 * code, or NULL */
+	int			expires_in;		/* seconds until user code expires */
 } PGpromptOAuthDevice;
 
 /* for PGoauthBearerRequest.async() */
diff --git a/src/test/modules/oauth_validator/t/oauth_server.py b/src/test/modules/oauth_validator/t/oauth_server.py
index 8ec09102027..4faf3323d38 100755
--- a/src/test/modules/oauth_validator/t/oauth_server.py
+++ b/src/test/modules/oauth_validator/t/oauth_server.py
@@ -298,7 +298,7 @@ class OAuthHandler(http.server.BaseHTTPRequestHandler):
             "device_code": "postgres",
             "user_code": "postgresuser",
             self._uri_spelling: uri,
-            "expires-in": 5,
+            "expires_in": 5,
             **self._response_padding,
         }
 
-- 
2.34.1



  [application/octet-stream] v47-0009-fixup-Add-OAUTHBEARER-SASL-mechanism.patch (10.8K, ../../CAOYmi+nHG7oy+ybHH72WjiXAQG3tE6v_at-K9ebRy2oqo92V+A@mail.gmail.com/11-v47-0009-fixup-Add-OAUTHBEARER-SASL-mechanism.patch)
  download | inline diff:
From 27135876559a1c5b5cac7e68c43403f257f391fd Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Tue, 4 Feb 2025 17:00:47 -0800
Subject: [PATCH v47 09/11] fixup! Add OAUTHBEARER SASL mechanism

---
 src/interfaces/libpq/fe-auth-oauth-curl.c     | 131 +++++++++++++++---
 .../oauth_validator/oauth_hook_client.c       |  35 ++++-
 .../modules/oauth_validator/t/001_server.pl   |  15 ++
 3 files changed, 159 insertions(+), 22 deletions(-)

diff --git a/src/interfaces/libpq/fe-auth-oauth-curl.c b/src/interfaces/libpq/fe-auth-oauth-curl.c
index 02c5b50afcd..96c5096e4ca 100644
--- a/src/interfaces/libpq/fe-auth-oauth-curl.c
+++ b/src/interfaces/libpq/fe-auth-oauth-curl.c
@@ -167,9 +167,7 @@ struct async_ctx
 {
 	enum OAuthStep step;		/* where are we in the flow? */
 
-#ifdef HAVE_SYS_EPOLL_H
-	int			timerfd;		/* a timerfd for signaling async timeouts */
-#endif
+	int			timerfd;		/* descriptor for signaling async timeouts */
 	pgsocket	mux;			/* the multiplexer socket containing all
 								 * descriptors tracked by libcurl, plus the
 								 * timerfd */
@@ -275,10 +273,8 @@ free_async_ctx(PGconn *conn, struct async_ctx *actx)
 
 	if (actx->mux != PGINVALID_SOCKET)
 		close(actx->mux);
-#ifdef HAVE_SYS_EPOLL_H
 	if (actx->timerfd >= 0)
 		close(actx->timerfd);
-#endif
 
 	free(actx);
 }
@@ -1089,8 +1085,9 @@ parse_access_token(struct async_ctx *actx, struct token *tok)
  * select() on instead of the Postgres socket during OAuth negotiation.
  *
  * This is just an epoll set or kqueue abstracting multiple other descriptors.
- * A timerfd is always part of the set when using epoll; it's just disabled
- * when we're not using it.
+ * For epoll, the timerfd is always part of the set; it's just disabled when
+ * we're not using it. For kqueue, the "timerfd" is actually a second kqueue
+ * instance which is only added to the set when needed.
  */
 static bool
 setup_multiplexer(struct async_ctx *actx)
@@ -1128,6 +1125,19 @@ setup_multiplexer(struct async_ctx *actx)
 		return false;
 	}
 
+	/*
+	 * Originally, we set EVFILT_TIMER directly on the top-level multiplexer.
+	 * This makes it difficult to implement timer_expired(), though, so now we
+	 * set EVFILT_TIMER on a separate actx->timerfd, which is chained to
+	 * actx->mux while the timer is active.
+	 */
+	actx->timerfd = kqueue();
+	if (actx->timerfd < 0)
+	{
+		actx_error(actx, "failed to create timer kqueue: %m");
+		return false;
+	}
+
 	return true;
 #endif
 
@@ -1286,9 +1296,12 @@ register_socket(CURL *curl, curl_socket_t socket, int what, void *ctx,
 
 /*
  * Enables or disables the timer in the multiplexer set. The timeout value is
- * in milliseconds (negative values disable the timer). Rather than continually
- * adding and removing the timer, we keep it in the set at all times and just
- * disarm it when it's not needed.
+ * in milliseconds (negative values disable the timer).
+ *
+ * For epoll, rather than continually adding and removing the timer, we keep it
+ * in the set at all times and just disarm it when it's not needed. For kqueue,
+ * the timer is removed completely when disabled to prevent stale timeouts from
+ * remaining in the queue.
  */
 static bool
 set_timer(struct async_ctx *actx, long timeout)
@@ -1320,20 +1333,87 @@ set_timer(struct async_ctx *actx, long timeout)
 		actx_error(actx, "setting timerfd to %ld: %m", timeout);
 		return false;
 	}
+
+	return true;
 #endif
 #ifdef HAVE_SYS_EVENT_H
 	struct kevent ev;
 
+	/* Enable/disable the timer itself. */
 	EV_SET(&ev, 1, EVFILT_TIMER, timeout < 0 ? EV_DELETE : (EV_ADD | EV_ONESHOT),
 		   0, timeout, 0);
-	if (kevent(actx->mux, &ev, 1, NULL, 0, NULL) < 0 && errno != ENOENT)
+	if (kevent(actx->timerfd, &ev, 1, NULL, 0, NULL) < 0 && errno != ENOENT)
 	{
 		actx_error(actx, "setting kqueue timer to %ld: %m", timeout);
 		return false;
 	}
-#endif
+
+	/*
+	 * Add/remove the timer to/from the mux. (In contrast with epoll, if we
+	 * allowed the timer to remain registered here after being disabled, the
+	 * mux queue would retain any previous stale timeout notifications and
+	 * remain readable.)
+	 */
+	EV_SET(&ev, actx->timerfd, EVFILT_READ, timeout < 0 ? EV_DELETE : EV_ADD,
+		   0, 0, 0);
+	if (kevent(actx->mux, &ev, 1, NULL, 0, NULL) < 0 && errno != ENOENT)
+	{
+		actx_error(actx, "could not update timer on kqueue: %m");
+		return false;
+	}
 
 	return true;
+#endif
+
+	actx_error(actx, "libpq does not support timers on this platform");
+	return false;
+}
+
+/*
+ * Returns 1 if the timeout in the multiplexer set has expired since the last
+ * call to set_timer(), 0 if the timer is still running, or -1 (with an
+ * actx_error() report) if the timer cannot be queried.
+ */
+static int
+timer_expired(struct async_ctx *actx)
+{
+#if HAVE_SYS_EPOLL_H
+	struct itimerspec spec = {0};
+
+	if (timerfd_gettime(actx->timerfd, &spec) < 0)
+	{
+		actx_error(actx, "getting timerfd value: %m");
+		return -1;
+	}
+
+	/*
+	 * This implementation assumes we're using single-shot timers. If you
+	 * change to using intervals, you'll need to reimplement this function
+	 * too, possibly with the read() or select() interfaces for timerfd.
+	 */
+	Assert(spec.it_interval.tv_sec == 0
+		   && spec.it_interval.tv_nsec == 0);
+
+	/* If the remaining time to expiration is zero, we're done. */
+	return (spec.it_value.tv_sec == 0
+			&& spec.it_value.tv_nsec == 0);
+#endif
+#ifdef HAVE_SYS_EVENT_H
+	int			res;
+
+	/* Is the timer queue ready? */
+	res = PQsocketPoll(actx->timerfd, 1 /* forRead */ , 0, 0);
+	if (res < 0)
+	{
+		actx_error(actx, "checking kqueue for timeout: %m");
+		return -1;
+	}
+
+	return (res > 0);
+#endif
+
+	actx_error(actx, "libpq does not support timers on this platform");
+	return -1;
 }
 
 /*
@@ -2510,9 +2590,7 @@ pg_fe_run_oauth_flow_impl(PGconn *conn)
 		}
 
 		actx->mux = PGINVALID_SOCKET;
-#ifdef HAVE_SYS_EPOLL_H
 		actx->timerfd = -1;
-#endif
 
 		/* Should we enable unsafe features? */
 		actx->debugging = oauth_unsafe_debugging_enabled();
@@ -2556,10 +2634,28 @@ pg_fe_run_oauth_flow_impl(PGconn *conn)
 						/* not done yet */
 						return status;
 					}
+
+					break;
 				}
 
 			case OAUTH_STEP_WAIT_INTERVAL:
-				/* TODO check that the timer has expired */
+
+				/*
+				 * The client application is supposed to wait until our timer
+				 * expires before calling PQconnectPoll() again, but that
+				 * might not happen. To avoid sending a token request early,
+				 * check the timer before continuing.
+				 */
+				if (!timer_expired(actx))
+				{
+					conn->altsock = actx->timerfd;
+					return PGRES_POLLING_READING;
+				}
+
+				/* Disable the expired timer. */
+				if (!set_timer(actx, -1))
+					goto error_return;
+
 				break;
 		}
 
@@ -2633,15 +2729,12 @@ pg_fe_run_oauth_flow_impl(PGconn *conn)
 				if (!set_timer(actx, actx->authz.interval * 1000))
 					goto error_return;
 
-#ifdef HAVE_SYS_EPOLL_H
-
 				/*
 				 * No Curl requests are running, so we can simplify by having
 				 * the client wait directly on the timerfd rather than the
-				 * multiplexer. (This isn't possible for kqueue.)
+				 * multiplexer.
 				 */
 				conn->altsock = actx->timerfd;
-#endif
 
 				actx->step = OAUTH_STEP_WAIT_INTERVAL;
 				actx->running = 1;
diff --git a/src/test/modules/oauth_validator/oauth_hook_client.c b/src/test/modules/oauth_validator/oauth_hook_client.c
index 12fe70c990b..fc003030ff8 100644
--- a/src/test/modules/oauth_validator/oauth_hook_client.c
+++ b/src/test/modules/oauth_validator/oauth_hook_client.c
@@ -40,14 +40,16 @@ usage(char *argv[])
 	printf(" --expected-uri URI		fail if received configuration link does not match URI\n");
 	printf(" --misbehave=MODE		have the hook fail required postconditions\n"
 		   "						(MODEs: no-hook, fail-async, no-token, no-socket)\n");
-	printf(" --no-hook				don't install OAuth hooks (connection will fail)\n");
+	printf(" --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(" --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_scope = NULL;
 static const char *misbehave_mode = NULL;
@@ -65,6 +67,7 @@ main(int argc, char *argv[])
 		{"token", required_argument, NULL, 1003},
 		{"hang-forever", no_argument, NULL, 1004},
 		{"misbehave", required_argument, NULL, 1005},
+		{"stress-async", no_argument, NULL, 1006},
 		{0}
 	};
 
@@ -104,6 +107,10 @@ main(int argc, char *argv[])
 				misbehave_mode = optarg;
 				break;
 
+			case 1006:			/* --stress-async */
+				stress_async = true;
+				break;
+
 			default:
 				usage(argv);
 				return 1;
@@ -122,10 +129,32 @@ main(int argc, char *argv[])
 	PQsetAuthDataHook(handle_auth_data);
 
 	/* Connect. (All the actual work is in the hook.) */
-	conn = PQconnectdb(conninfo);
+	if (stress_async)
+	{
+		/*
+		 * Perform an asynchronous connection, busy-looping on PQconnectPoll()
+		 * without actually waiting on socket events. This stresses code paths
+		 * that rely on asynchronous work to be done before continuing with
+		 * the next step in the flow.
+		 */
+		PostgresPollingStatusType res;
+
+		conn = PQconnectStart(conninfo);
+
+		do
+		{
+			res = PQconnectPoll(conn);
+		} while (res != PGRES_POLLING_FAILED && res != PGRES_POLLING_OK);
+	}
+	else
+	{
+		/* Perform a standard synchronous connection. */
+		conn = PQconnectdb(conninfo);
+	}
+
 	if (PQstatus(conn) != CONNECTION_OK)
 	{
-		fprintf(stderr, "Connection to database failed: %s\n",
+		fprintf(stderr, "connection to database failed: %s\n",
 				PQerrorMessage(conn));
 		PQfinish(conn);
 		return 1;
diff --git a/src/test/modules/oauth_validator/t/001_server.pl b/src/test/modules/oauth_validator/t/001_server.pl
index 80f52585896..f0b918390fd 100644
--- a/src/test/modules/oauth_validator/t/001_server.pl
+++ b/src/test/modules/oauth_validator/t/001_server.pl
@@ -389,6 +389,21 @@ $node->connect_fails(
 	  qr/failed to obtain access token: mutual TLS required for client \(invalid_client\)/
 );
 
+# Stress test: make sure our builtin flow operates correctly even if the client
+# application isn't respecting PGRES_POLLING_READING/WRITING signals returned
+# from PQconnectPoll().
+$base_connstr =
+  "$common_connstr port=" . $node->port . " host=" . $node->host;
+my @cmd = (
+	"oauth_hook_client", "--no-hook", "--stress-async",
+	connstr(stage => 'all', retries => 1, interval => 1));
+
+note "running '" . join("' '", @cmd) . "'";
+my ($stdout, $stderr) = run_command(\@cmd);
+
+like($stdout, qr/connection succeeded/, "stress-async: stdout matches");
+unlike($stderr, qr/connection to database failed/, "stress-async: stderr matches");
+
 #
 # This section of tests reconfigures the validator module itself, rather than
 # the OAuth server.
-- 
2.34.1



  [application/octet-stream] v47-0010-XXX-fix-libcurl-link-error.patch (1.1K, ../../CAOYmi+nHG7oy+ybHH72WjiXAQG3tE6v_at-K9ebRy2oqo92V+A@mail.gmail.com/12-v47-0010-XXX-fix-libcurl-link-error.patch)
  download | inline diff:
From d8c1f2980802d1bb7561bdd2c4662174b9e43087 Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Mon, 13 Jan 2025 12:31:59 -0800
Subject: [PATCH v47 10/11] XXX fix libcurl link error

The ftp/curl port appears to be missing a minimum version dependency on
libssh2, so the following starts showing up after upgrading to curl
8.11.1_1:

    libcurl.so.4: Undefined symbol "libssh2_session_callback_set2"

But 13.3 is EOL, so it's not clear if anyone would be interested in a
bug report, and a FreeBSD 14 Cirrus image is in progress. Hack past it
for now.
---
 .cirrus.tasks.yml | 1 +
 1 file changed, 1 insertion(+)

diff --git a/.cirrus.tasks.yml b/.cirrus.tasks.yml
index c192a077701..3afea832bc9 100644
--- a/.cirrus.tasks.yml
+++ b/.cirrus.tasks.yml
@@ -168,6 +168,7 @@ task:
     sysctl kern.corefile='/tmp/cores/%N.%P.core'
   setup_additional_packages_script: |
     pkg install -y curl
+    pkg upgrade -y libssh2 # XXX shouldn't be necessary. revisit w/ FreeBSD 14
 
   # NB: Intentionally build without -Dllvm. The freebsd image size is already
   # large enough to make VM startup slow, and even without llvm freebsd
-- 
2.34.1



  [application/octet-stream] v47-0011-DO-NOT-MERGE-Add-pytest-suite-for-OAuth.patch (212.4K, ../../CAOYmi+nHG7oy+ybHH72WjiXAQG3tE6v_at-K9ebRy2oqo92V+A@mail.gmail.com/13-v47-0011-DO-NOT-MERGE-Add-pytest-suite-for-OAuth.patch)
  download | inline diff:
From dbf305d048966be086b4bf62c12aa4631c40ae88 Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Fri, 4 Jun 2021 09:06:38 -0700
Subject: [PATCH v47 11/11] DO NOT MERGE: Add pytest suite for OAuth

Requires Python 3. On the first run of `make installcheck` or `meson
test` the dependencies will be installed into a local virtualenv for
you. See the README for more details.

Cirrus has been updated to build OAuth support on Debian and FreeBSD.

The suite contains a --temp-instance option, analogous to pg_regress's
option of the same name, which allows an ephemeral server to be spun up
during a test run.

TODOs:
- The --tap-stream option to pytest-tap is slightly broken during test
  failures (it suppresses error information), which impedes debugging.
- pyca/cryptography is pinned at an old version. Since we use it for
  testing and not security, this isn't a critical problem yet, but it's
  not ideal. Newer versions require a Rust compiler to build, and while
  many platforms have precompiled wheels, some (FreeBSD) do not. Even
  with the Rust pieces bypassed, compilation on FreeBSD takes a while.
- The with_oauth test skip logic should probably be integrated into the
  Makefile side as well...
- See if 32-bit tests can be enabled with a 32-bit Python.
---
 .cirrus.tasks.yml                     |    6 +-
 meson.build                           |  103 +
 src/test/meson.build                  |    1 +
 src/test/python/.gitignore            |    2 +
 src/test/python/Makefile              |   38 +
 src/test/python/README                |   66 +
 src/test/python/client/__init__.py    |    0
 src/test/python/client/conftest.py    |  196 ++
 src/test/python/client/test_client.py |  186 ++
 src/test/python/client/test_oauth.py  | 2663 +++++++++++++++++++++++++
 src/test/python/conftest.py           |   34 +
 src/test/python/meson.build           |   47 +
 src/test/python/pq3.py                |  740 +++++++
 src/test/python/pytest.ini            |    4 +
 src/test/python/requirements.txt      |   11 +
 src/test/python/server/__init__.py    |    0
 src/test/python/server/conftest.py    |  141 ++
 src/test/python/server/meson.build    |   18 +
 src/test/python/server/oauthtest.c    |  118 ++
 src/test/python/server/test_oauth.py  | 1080 ++++++++++
 src/test/python/server/test_server.py |   21 +
 src/test/python/test_internals.py     |  138 ++
 src/test/python/test_pq3.py           |  574 ++++++
 src/test/python/tls.py                |  195 ++
 src/tools/make_venv                   |   56 +
 src/tools/testwrap                    |    7 +
 26 files changed, 6444 insertions(+), 1 deletion(-)
 create mode 100644 src/test/python/.gitignore
 create mode 100644 src/test/python/Makefile
 create mode 100644 src/test/python/README
 create mode 100644 src/test/python/client/__init__.py
 create mode 100644 src/test/python/client/conftest.py
 create mode 100644 src/test/python/client/test_client.py
 create mode 100644 src/test/python/client/test_oauth.py
 create mode 100644 src/test/python/conftest.py
 create mode 100644 src/test/python/meson.build
 create mode 100644 src/test/python/pq3.py
 create mode 100644 src/test/python/pytest.ini
 create mode 100644 src/test/python/requirements.txt
 create mode 100644 src/test/python/server/__init__.py
 create mode 100644 src/test/python/server/conftest.py
 create mode 100644 src/test/python/server/meson.build
 create mode 100644 src/test/python/server/oauthtest.c
 create mode 100644 src/test/python/server/test_oauth.py
 create mode 100644 src/test/python/server/test_server.py
 create mode 100644 src/test/python/test_internals.py
 create mode 100644 src/test/python/test_pq3.py
 create mode 100644 src/test/python/tls.py
 create mode 100755 src/tools/make_venv

diff --git a/.cirrus.tasks.yml b/.cirrus.tasks.yml
index 3afea832bc9..06efe5f9b0a 100644
--- a/.cirrus.tasks.yml
+++ b/.cirrus.tasks.yml
@@ -23,7 +23,7 @@ env:
   MTEST_ARGS: --print-errorlogs --no-rebuild -C build
   PGCTLTIMEOUT: 120 # avoids spurious failures during parallel tests
   TEMP_CONFIG: ${CIRRUS_WORKING_DIR}/src/tools/ci/pg_ci_base.conf
-  PG_TEST_EXTRA: kerberos ldap ssl libpq_encryption load_balance oauth
+  PG_TEST_EXTRA: kerberos ldap ssl libpq_encryption load_balance oauth python
 
 
 # What files to preserve in case tests fail
@@ -321,6 +321,7 @@ task:
     DEBIAN_FRONTEND=noninteractive apt-get -y install \
       libcurl4-openssl-dev \
       libcurl4-openssl-dev:i386 \
+      python3-venv \
 
   matrix:
     - name: Linux - Debian Bookworm - Autoconf
@@ -405,8 +406,11 @@ task:
       # can easily provide some here by running one of the sets of tests that
       # way. Newer versions of python insist on changing the LC_CTYPE away
       # from C, prevent that with PYTHONCOERCECLOCALE.
+      # XXX 32-bit Python tests are currently disabled, as the system's 64-bit
+      # Python modules can't link against libpq.
       test_world_32_script: |
         su postgres <<-EOF
+          export PG_TEST_EXTRA="${PG_TEST_EXTRA//python}"
           ulimit -c unlimited
           PYTHONCOERCECLOCALE=0 LANG=C meson test $MTEST_ARGS -C build-32 --num-processes ${TEST_JOBS}
         EOF
diff --git a/meson.build b/meson.build
index 96e5f0f6434..6e60c8d3dae 100644
--- a/meson.build
+++ b/meson.build
@@ -3458,6 +3458,9 @@ else
 endif
 
 testwrap = files('src/tools/testwrap')
+make_venv = files('src/tools/make_venv')
+
+checked_working_venv = false
 
 foreach test_dir : tests
   testwrap_base = [
@@ -3626,6 +3629,106 @@ foreach test_dir : tests
         )
       endforeach
       install_suites += test_group
+    elif kind == 'pytest'
+      venv_name = test_dir['name'] + '_venv'
+      venv_path = meson.build_root() / venv_name
+
+      # The Python tests require a working venv module. This is part of the
+      # standard library, but some platforms disable it until a separate package
+      # is installed. Those same platforms don't provide an easy way to check
+      # whether the venv command will work until the first time you try it, so
+      # we decide whether or not to enable these tests on the fly.
+      if not checked_working_venv
+        cmd = run_command(python, '-m', 'venv', venv_path, check: false)
+
+        have_working_venv = (cmd.returncode() == 0)
+        if not have_working_venv
+          warning('A working Python venv module is required to run Python tests.')
+        endif
+
+        checked_working_venv = true
+      endif
+
+      if not have_working_venv
+        continue
+      endif
+
+      # Make sure the temporary installation is in PATH (necessary both for
+      # --temp-instance and for any pip modules compiling against libpq, like
+      # psycopg2).
+      env = test_env
+      env.prepend('PATH', temp_install_bindir, test_dir['bd'])
+
+      foreach name, value : t.get('env', {})
+        env.set(name, value)
+      endforeach
+
+      reqs = files(t['requirements'])
+      test('install_' + venv_name,
+        python,
+        args: [ make_venv, '--requirements', reqs, venv_path ],
+        env: env,
+        priority: setup_tests_priority - 1,  # must run after tmp_install
+        is_parallel: false,
+        suite: ['setup'],
+        timeout: 60,  # 30s is too short for the cryptography package compile
+      )
+
+      test_group = test_dir['name']
+      test_output = test_result_dir / test_group / kind
+      test_kwargs = {
+        #'protocol': 'tap',
+        'suite': test_group,
+        'timeout': 1000,
+        'depends': test_deps,
+        'env': env,
+      } + t.get('test_kwargs', {})
+
+      if fs.is_dir(venv_path / 'Scripts')
+        # Windows virtualenv layout
+        pytest = venv_path / 'Scripts' / 'py.test'
+      else
+        pytest = venv_path / 'bin' / 'py.test'
+      endif
+
+      test_command = [
+        pytest,
+        # Avoid running these tests against an existing database.
+        '--temp-instance', test_output / 'data',
+
+        # FIXME pytest-tap's stream feature accidentally suppresses errors that
+        # are critical for debugging:
+        #     https://github.com/python-tap/pytest-tap/issues/30
+        # Don't use the meson TAP protocol for now...
+        #'--tap-stream',
+      ]
+
+      foreach pyt : t['tests']
+        # Similarly to TAP, strip ./ and .py to make the names prettier
+        pyt_p = pyt
+        if pyt_p.startswith('./')
+          pyt_p = pyt_p.split('./')[1]
+        endif
+        if pyt_p.endswith('.py')
+          pyt_p = fs.stem(pyt_p)
+        endif
+
+        testwrap_pytest = testwrap_base + [
+          '--testgroup', test_group,
+          '--testname', pyt_p,
+          '--skip-without-extra', 'python',
+        ]
+
+        test(test_group / pyt_p,
+          python,
+          kwargs: test_kwargs,
+          args: testwrap_pytest + [
+            '--', test_command,
+            test_dir['sd'] / pyt,
+          ],
+        )
+      endforeach
+      install_suites += test_group
     else
       error('unknown kind @0@ of test in @1@'.format(kind, test_dir['sd']))
     endif
diff --git a/src/test/meson.build b/src/test/meson.build
index ccc31d6a86a..236057cd99e 100644
--- a/src/test/meson.build
+++ b/src/test/meson.build
@@ -8,6 +8,7 @@ subdir('postmaster')
 subdir('recovery')
 subdir('subscription')
 subdir('modules')
+subdir('python')
 
 if ssl.found()
   subdir('ssl')
diff --git a/src/test/python/.gitignore b/src/test/python/.gitignore
new file mode 100644
index 00000000000..0e8f027b2ec
--- /dev/null
+++ b/src/test/python/.gitignore
@@ -0,0 +1,2 @@
+__pycache__/
+/venv/
diff --git a/src/test/python/Makefile b/src/test/python/Makefile
new file mode 100644
index 00000000000..b0695b6287e
--- /dev/null
+++ b/src/test/python/Makefile
@@ -0,0 +1,38 @@
+#
+# Copyright 2021 VMware, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+# Only Python 3 is supported, but if it's named something different on your
+# system you can override it with the PYTHON3 variable.
+PYTHON3 := python3
+
+# All dependencies are placed into this directory. The default is .gitignored
+# for you, but you can override it if you'd like.
+VENV := ./venv
+
+override VBIN   := $(VENV)/bin
+override PIP    := $(VBIN)/pip
+override PYTEST := $(VBIN)/py.test
+override ISORT  := $(VBIN)/isort
+override BLACK  := $(VBIN)/black
+
+.PHONY: installcheck indent
+
+installcheck: $(PYTEST)
+	$(PYTEST) -v -rs
+
+indent: $(ISORT) $(BLACK)
+	$(ISORT) --profile black *.py client/*.py server/*.py
+	$(BLACK) *.py client/*.py server/*.py
+
+$(PYTEST) $(ISORT) $(BLACK) &: requirements.txt | $(PIP)
+	$(PIP) install --force-reinstall -r $<
+
+$(PIP):
+	$(PYTHON3) -m venv $(VENV)
+
+# A convenience recipe to rebuild psycopg2 against the local libpq.
+.PHONY: rebuild-psycopg2
+rebuild-psycopg2: | $(PIP)
+	$(PIP) install --force-reinstall --no-binary :all: $(shell grep psycopg2 requirements.txt)
diff --git a/src/test/python/README b/src/test/python/README
new file mode 100644
index 00000000000..acf339a5899
--- /dev/null
+++ b/src/test/python/README
@@ -0,0 +1,66 @@
+A test suite for exercising both the libpq client and the server backend at the
+protocol level, based on pytest and Construct.
+
+WARNING! This suite takes superuser-level control of the cluster under test,
+writing to the server config, creating and destroying databases, etc. It also
+spins up various ephemeral TCP services. This is not safe for production servers
+and therefore must be explicitly opted into by setting PG_TEST_EXTRA=python in
+the environment.
+
+The test suite currently assumes that the standard PG* environment variables
+point to the database under test and are sufficient to log in a superuser on
+that system. In other words, a bare `psql` needs to Just Work before the test
+suite can do its thing. For a newly built dev cluster, typically all that I need
+to do is a
+
+    export PGDATABASE=postgres
+
+but you can adjust as needed for your setup. See also 'Advanced Usage' below.
+
+## Requirements
+
+A supported version (3.6+) of Python.
+
+The first run of
+
+    make installcheck PG_TEST_EXTRA=python
+
+will install a local virtual environment and all needed dependencies. During
+development, if libpq changes incompatibly, you can issue
+
+    $ make rebuild-psycopg2
+
+to force a rebuild of the client library.
+
+## Hacking
+
+The code style is enforced by a _very_ opinionated autoformatter. Running the
+
+    make indent
+
+recipe will invoke it for you automatically. Don't fight the tool; part of the
+zen is in knowing that if the formatter makes your code ugly, there's probably a
+cleaner way to write your code.
+
+## Advanced Usage
+
+The Makefile is there for convenience, but you don't have to use it. Activate
+the virtualenv to be able to use pytest directly:
+
+    $ export PG_TEST_EXTRA=python
+    $ source venv/bin/activate
+    $ py.test -k oauth
+    ...
+    $ py.test ./server/test_server.py
+    ...
+    $ deactivate  # puts the PATH et al back the way it was before
+
+To make quick smoke tests possible, slow tests have been marked explicitly. You
+can skip them by saying e.g.
+
+    $ py.test -m 'not slow'
+
+If you'd rather not test against an existing server, you can have the suite spin
+up a temporary one using whatever pg_ctl it finds in PATH:
+
+    $ py.test --temp-instance=./tmp_check
diff --git a/src/test/python/client/__init__.py b/src/test/python/client/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/src/test/python/client/conftest.py b/src/test/python/client/conftest.py
new file mode 100644
index 00000000000..20e72a404aa
--- /dev/null
+++ b/src/test/python/client/conftest.py
@@ -0,0 +1,196 @@
+#
+# Copyright 2021 VMware, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import contextlib
+import datetime
+import functools
+import ipaddress
+import os
+import socket
+import sys
+import threading
+
+import psycopg2
+import psycopg2.extras
+import pytest
+from cryptography import x509
+from cryptography.hazmat.primitives import hashes, serialization
+from cryptography.hazmat.primitives.asymmetric import rsa
+from cryptography.x509.oid import NameOID
+
+import pq3
+
+BLOCKING_TIMEOUT = 2  # the number of seconds to wait for blocking calls
+
+
[email protected]
+def server_socket(unused_tcp_port_factory):
+    """
+    Returns a listening socket bound to an ephemeral port.
+    """
+    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+        s.bind(("127.0.0.1", unused_tcp_port_factory()))
+        s.listen(1)
+        s.settimeout(BLOCKING_TIMEOUT)
+        yield s
+
+
+class ClientHandshake(threading.Thread):
+    """
+    A thread that connects to a local Postgres server using psycopg2. Once the
+    opening handshake completes, the connection will be immediately closed.
+    """
+
+    def __init__(self, *, port, **kwargs):
+        super().__init__()
+
+        kwargs["port"] = port
+        self._kwargs = kwargs
+
+        self.exception = None
+
+    def run(self):
+        try:
+            conn = psycopg2.connect(host="127.0.0.1", **self._kwargs)
+            with contextlib.closing(conn):
+                self._pump_async(conn)
+        except Exception as e:
+            self.exception = e
+
+    def check_completed(self, timeout=BLOCKING_TIMEOUT):
+        """
+        Joins the client thread. Raises an exception if the thread could not be
+        joined, or if it threw an exception itself. (The exception will be
+        cleared, so future calls to check_completed will succeed.)
+        """
+        self.join(timeout)
+
+        if self.is_alive():
+            raise TimeoutError("client thread did not handshake within the timeout")
+        elif self.exception:
+            e = self.exception
+            self.exception = None
+            raise e
+
+    def _pump_async(self, conn):
+        """
+        Polls a psycopg2 connection until it's completed. (Synchronous
+        connections will work here too; they'll just immediately return OK.)
+        """
+        psycopg2.extras.wait_select(conn)
+
+
[email protected]
+def accept(server_socket):
+    """
+    Returns a factory function that, when called, returns a pair (sock, client)
+    where sock is a server socket that has accepted a connection from client,
+    and client is an instance of ClientHandshake. Clients will complete their
+    handshakes and cleanly disconnect.
+
+    The default connstring options may be extended or overridden by passing
+    arbitrary keyword arguments. Keep in mind that you generally should not
+    override the host or port, since they point to the local test server.
+
+    For situations where a client needs to connect more than once to complete a
+    handshake, the accept function may be called more than once. (The client
+    returned for subsequent calls will always be the same client that was
+    returned for the first call.)
+
+    Tests must either complete the handshake so that the client thread can be
+    automatically joined during teardown, or else call client.check_completed()
+    and manually handle any expected errors.
+    """
+    _, port = server_socket.getsockname()
+
+    client = None
+    default_opts = dict(
+        port=port,
+        user=pq3.pguser(),
+        sslmode="disable",
+    )
+
+    def factory(**kwargs):
+        nonlocal client
+
+        if client is None:
+            opts = dict(default_opts)
+            opts.update(kwargs)
+
+            # The server_socket is already listening, so the client thread can
+            # be safely started; it'll block on the connection until we accept.
+            client = ClientHandshake(**opts)
+            client.start()
+
+        sock, _ = server_socket.accept()
+        sock.settimeout(BLOCKING_TIMEOUT)
+        return sock, client
+
+    yield factory
+
+    if client is not None:
+        client.check_completed()
+
+
[email protected]
+def conn(accept):
+    """
+    Returns an accepted, wrapped pq3 connection to a psycopg2 client. The socket
+    will be closed when the test finishes, and the client will be checked for a
+    cleanly completed handshake.
+    """
+    sock, client = accept()
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            yield conn
+
+
[email protected](scope="session")
+def certpair(tmp_path_factory):
+    """
+    Yields a (cert, key) pair of file paths that can be used by a TLS server.
+    The certificate is issued for "localhost" and its standard IPv4/6 addresses.
+    """
+
+    tmpdir = tmp_path_factory.mktemp("certs")
+    now = datetime.datetime.now(datetime.timezone.utc)
+
+    # https://cryptography.io/en/latest/x509/tutorial/#creating-a-self-signed-certificate
+    key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
+
+    subject = issuer = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "localhost")])
+    altNames = [
+        x509.DNSName("localhost"),
+        x509.IPAddress(ipaddress.IPv4Address("127.0.0.1")),
+        x509.IPAddress(ipaddress.IPv6Address("::1")),
+    ]
+    cert = (
+        x509.CertificateBuilder()
+        .subject_name(subject)
+        .issuer_name(issuer)
+        .public_key(key.public_key())
+        .serial_number(x509.random_serial_number())
+        .not_valid_before(now)
+        .not_valid_after(now + datetime.timedelta(minutes=10))
+        .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
+        .add_extension(x509.SubjectAlternativeName(altNames), critical=False)
+    ).sign(key, hashes.SHA256())
+
+    # Writing the key with mode 0600 lets us use this from the server side, too.
+    keypath = str(tmpdir / "key.pem")
+    with open(keypath, "wb", opener=functools.partial(os.open, mode=0o600)) as f:
+        f.write(
+            key.private_bytes(
+                encoding=serialization.Encoding.PEM,
+                format=serialization.PrivateFormat.PKCS8,
+                encryption_algorithm=serialization.NoEncryption(),
+            )
+        )
+
+    certpath = str(tmpdir / "cert.pem")
+    with open(certpath, "wb") as f:
+        f.write(cert.public_bytes(serialization.Encoding.PEM))
+
+    return certpath, keypath
diff --git a/src/test/python/client/test_client.py b/src/test/python/client/test_client.py
new file mode 100644
index 00000000000..8372376ede4
--- /dev/null
+++ b/src/test/python/client/test_client.py
@@ -0,0 +1,186 @@
+#
+# Copyright 2021 VMware, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import base64
+import sys
+
+import psycopg2
+import pytest
+from cryptography.hazmat.primitives import hashes, hmac
+
+import pq3
+
+from .test_oauth import alt_patterns
+
+
+def finish_handshake(conn):
+    """
+    Sends the AuthenticationOK message and the standard opening salvo of server
+    messages, then asserts that the client immediately sends a Terminate message
+    to close the connection cleanly.
+    """
+    pq3.send(conn, pq3.types.AuthnRequest, type=pq3.authn.OK)
+    pq3.send(conn, pq3.types.ParameterStatus, name=b"client_encoding", value=b"UTF-8")
+    pq3.send(conn, pq3.types.ParameterStatus, name=b"DateStyle", value=b"ISO, MDY")
+    pq3.send(conn, pq3.types.ReadyForQuery, status=b"I")
+
+    pkt = pq3.recv1(conn)
+    assert pkt.type == pq3.types.Terminate
+
+
+def test_handshake(conn):
+    startup = pq3.recv1(conn, cls=pq3.Startup)
+    assert startup.proto == pq3.protocol(3, 0)
+
+    finish_handshake(conn)
+
+
+def test_aborted_connection(accept):
+    """
+    Make sure the client correctly reports an early close during handshakes.
+    """
+    sock, client = accept()
+    sock.close()
+
+    expected = alt_patterns(
+        "server closed the connection unexpectedly",
+        # On some platforms, ECONNABORTED gets set instead.
+        "Software caused connection abort",
+    )
+    with pytest.raises(psycopg2.OperationalError, match=expected):
+        client.check_completed()
+
+
+#
+# SCRAM-SHA-256 (see RFC 5802: https://tools.ietf.org/html/rfc5802)
+#
+
+
[email protected]
+def password():
+    """
+    Returns a password for use by both client and server.
+    """
+    # TODO: parameterize this with passwords that require SASLprep.
+    return "secret"
+
+
[email protected]
+def pwconn(accept, password):
+    """
+    Like the conn fixture, but uses a password in the connection.
+    """
+    sock, client = accept(password=password)
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            yield conn
+
+
+def sha256(data):
+    """The H(str) function from Section 2.2."""
+    digest = hashes.Hash(hashes.SHA256())
+    digest.update(data)
+    return digest.finalize()
+
+
+def hmac_256(key, data):
+    """The HMAC(key, str) function from Section 2.2."""
+    h = hmac.HMAC(key, hashes.SHA256())
+    h.update(data)
+    return h.finalize()
+
+
+def xor(a, b):
+    """The XOR operation from Section 2.2."""
+    res = bytearray(a)
+    for i, byte in enumerate(b):
+        res[i] ^= byte
+    return bytes(res)
+
+
+def h_i(data, salt, i):
+    """The Hi(str, salt, i) function from Section 2.2."""
+    assert i > 0
+
+    acc = hmac_256(data, salt + b"\x00\x00\x00\x01")
+    last = acc
+    i -= 1
+
+    while i:
+        u = hmac_256(data, last)
+        acc = xor(acc, u)
+
+        last = u
+        i -= 1
+
+    return acc
+
+
+def test_scram(pwconn, password):
+    startup = pq3.recv1(pwconn, cls=pq3.Startup)
+    assert startup.proto == pq3.protocol(3, 0)
+
+    pq3.send(
+        pwconn,
+        pq3.types.AuthnRequest,
+        type=pq3.authn.SASL,
+        body=[b"SCRAM-SHA-256", b""],
+    )
+
+    # Get the client-first-message.
+    pkt = pq3.recv1(pwconn)
+    assert pkt.type == pq3.types.PasswordMessage
+
+    initial = pq3.SASLInitialResponse.parse(pkt.payload)
+    assert initial.name == b"SCRAM-SHA-256"
+
+    c_bind, authzid, c_name, c_nonce = initial.data.split(b",")
+    assert c_bind == b"n"  # no channel bindings on a plaintext connection
+    assert authzid == b""  # we don't support authzid currently
+    assert c_name == b"n="  # libpq doesn't honor the GS2 username
+    assert c_nonce.startswith(b"r=")
+
+    # Send the server-first-message.
+    salt = b"12345"
+    iterations = 2
+
+    s_nonce = c_nonce + b"somenonce"
+    s_salt = b"s=" + base64.b64encode(salt)
+    s_iterations = b"i=%d" % iterations
+
+    msg = b",".join([s_nonce, s_salt, s_iterations])
+    pq3.send(pwconn, pq3.types.AuthnRequest, type=pq3.authn.SASLContinue, body=msg)
+
+    # Get the client-final-message.
+    pkt = pq3.recv1(pwconn)
+    assert pkt.type == pq3.types.PasswordMessage
+
+    c_bind_final, c_nonce_final, c_proof = pkt.payload.split(b",")
+    assert c_bind_final == b"c=" + base64.b64encode(c_bind + b"," + authzid + b",")
+    assert c_nonce_final == s_nonce
+
+    # Calculate what the client proof should be.
+    salted_password = h_i(password.encode("ascii"), salt, iterations)
+    client_key = hmac_256(salted_password, b"Client Key")
+    stored_key = sha256(client_key)
+
+    auth_message = b",".join(
+        [c_name, c_nonce, s_nonce, s_salt, s_iterations, c_bind_final, c_nonce_final]
+    )
+    client_signature = hmac_256(stored_key, auth_message)
+    client_proof = xor(client_key, client_signature)
+
+    expected = b"p=" + base64.b64encode(client_proof)
+    assert c_proof == expected
+
+    # Send the correct server signature.
+    server_key = hmac_256(salted_password, b"Server Key")
+    server_signature = hmac_256(server_key, auth_message)
+
+    s_verify = b"v=" + base64.b64encode(server_signature)
+    pq3.send(pwconn, pq3.types.AuthnRequest, type=pq3.authn.SASLFinal, body=s_verify)
+
+    # Done!
+    finish_handshake(pwconn)
diff --git a/src/test/python/client/test_oauth.py b/src/test/python/client/test_oauth.py
new file mode 100644
index 00000000000..b8f260cf97a
--- /dev/null
+++ b/src/test/python/client/test_oauth.py
@@ -0,0 +1,2663 @@
+#
+# Copyright 2021 VMware, Inc.
+# Portions Copyright 2023 Timescale, Inc.
+# Portions Copyright 2024 PostgreSQL Global Development Group
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import base64
+import collections
+import contextlib
+import ctypes
+import http.server
+import json
+import logging
+import os
+import platform
+import secrets
+import socket
+import ssl
+import sys
+import threading
+import time
+import traceback
+import types
+import urllib.parse
+from numbers import Number
+
+import psycopg2
+import pytest
+
+import pq3
+
+from .conftest import BLOCKING_TIMEOUT
+
+# The client tests need libpq to have been compiled with OAuth support; skip
+# them otherwise.
+pytestmark = pytest.mark.skipif(
+    os.getenv("with_libcurl") != "yes",
+    reason="OAuth client tests require --with-libcurl support",
+)
+
+if platform.system() == "Darwin":
+    libpq = ctypes.cdll.LoadLibrary("libpq.5.dylib")
+elif platform.system() == "Windows":
+    pass  # TODO
+else:
+    libpq = ctypes.cdll.LoadLibrary("libpq.so.5")
+
+
+def finish_handshake(conn):
+    """
+    Sends the AuthenticationOK message and the standard opening salvo of server
+    messages, then asserts that the client immediately sends a Terminate message
+    to close the connection cleanly.
+    """
+    pq3.send(conn, pq3.types.AuthnRequest, type=pq3.authn.OK)
+    pq3.send(conn, pq3.types.ParameterStatus, name=b"client_encoding", value=b"UTF-8")
+    pq3.send(conn, pq3.types.ParameterStatus, name=b"DateStyle", value=b"ISO, MDY")
+    pq3.send(conn, pq3.types.ReadyForQuery, status=b"I")
+
+    pkt = pq3.recv1(conn)
+    assert pkt.type == pq3.types.Terminate
+
+
+#
+# OAUTHBEARER (see RFC 7628: https://tools.ietf.org/html/rfc7628)
+#
+
+
+def start_oauth_handshake(conn):
+    """
+    Negotiates an OAUTHBEARER SASL challenge. Returns the client's initial
+    response data.
+    """
+    startup = pq3.recv1(conn, cls=pq3.Startup)
+    assert startup.proto == pq3.protocol(3, 0)
+
+    pq3.send(
+        conn, pq3.types.AuthnRequest, type=pq3.authn.SASL, body=[b"OAUTHBEARER", b""]
+    )
+
+    pkt = pq3.recv1(conn)
+    assert pkt.type == pq3.types.PasswordMessage
+
+    initial = pq3.SASLInitialResponse.parse(pkt.payload)
+    assert initial.name == b"OAUTHBEARER"
+
+    return initial.data
+
+
+def get_auth_value(initial):
+    """
+    Finds the auth value (e.g. "Bearer somedata..." in the client's initial SASL
+    response.
+    """
+    kvpairs = initial.split(b"\x01")
+    assert kvpairs[0] == b"n,,"  # no channel binding or authzid
+    assert kvpairs[2] == b""  # ends with an empty kvpair
+    assert kvpairs[3] == b""  # ...and there's nothing after it
+    assert len(kvpairs) == 4
+
+    key, value = kvpairs[1].split(b"=", 2)
+    assert key == b"auth"
+
+    return value
+
+
+def fail_oauth_handshake(conn, sasl_resp, *, errmsg="doesn't matter"):
+    """
+    Sends a failure response via the OAUTHBEARER mechanism, consumes the
+    client's dummy response, and issues a FATAL error to end the exchange.
+
+    sasl_resp is a dictionary which will be serialized as the OAUTHBEARER JSON
+    response. If provided, errmsg is used in the FATAL ErrorResponse.
+    """
+    resp = json.dumps(sasl_resp)
+    pq3.send(
+        conn,
+        pq3.types.AuthnRequest,
+        type=pq3.authn.SASLContinue,
+        body=resp.encode("utf-8"),
+    )
+
+    # Per RFC, the client is required to send a dummy ^A response.
+    pkt = pq3.recv1(conn)
+    assert pkt.type == pq3.types.PasswordMessage
+    assert pkt.payload == b"\x01"
+
+    # Now fail the SASL exchange.
+    pq3.send(
+        conn,
+        pq3.types.ErrorResponse,
+        fields=[
+            b"SFATAL",
+            b"C28000",
+            b"M" + errmsg.encode("utf-8"),
+            b"",
+        ],
+    )
+
+
+def handle_discovery_connection(sock, discovery=None, *, response=None):
+    """
+    Helper for all tests that expect an initial discovery connection from the
+    client. The provided discovery URI will be used in a standard error response
+    from the server (or response may be set, to provide a custom dictionary),
+    and the SASL exchange will be failed.
+
+    By default, the client is expected to complete the entire handshake. Set
+    finish to False if the client should immediately disconnect when it receives
+    the error response.
+    """
+    if response is None:
+        response = {"status": "invalid_token"}
+        if discovery is not None:
+            response["openid-configuration"] = discovery
+
+    with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+        # Initiate a handshake.
+        initial = start_oauth_handshake(conn)
+
+        # For discovery, the client should send an empty auth header. See RFC
+        # 7628, Sec. 4.3.
+        auth = get_auth_value(initial)
+        assert auth == b""
+
+        # The discovery handshake is doomed to fail.
+        fail_oauth_handshake(conn, response)
+
+
+class RawResponse(str):
+    """
+    Returned by registered endpoint callbacks to take full control of the
+    response. Usually, return values are converted to JSON; a RawResponse body
+    will be passed to the client as-is, allowing endpoint implementations to
+    issue invalid JSON.
+    """
+
+    pass
+
+
+class RawBytes(bytes):
+    """
+    Like RawResponse, but bypasses the UTF-8 encoding step as well, allowing
+    implementations to issue invalid encodings.
+    """
+
+    pass
+
+
+class OpenIDProvider(threading.Thread):
+    """
+    A thread that runs a mock OpenID provider server on an SSL-enabled socket.
+    """
+
+    def __init__(self, ssl_socket):
+        super().__init__()
+
+        self.exception = None
+
+        _, port = ssl_socket.getsockname()
+
+        oauth = self._OAuthState()
+        oauth.host = f"localhost:{port}"
+        oauth.issuer = f"https://localhost:{port}"
+
+        # The following endpoints are required to be advertised by providers,
+        # even though our chosen client implementation does not actually make
+        # use of them.
+        oauth.register_endpoint(
+            "authorization_endpoint", "POST", "/authorize", self._authorization_handler
+        )
+        oauth.register_endpoint("jwks_uri", "GET", "/keys", self._jwks_handler)
+
+        self.server = self._HTTPSServer(ssl_socket, self._Handler)
+        self.server.oauth = oauth
+
+    def run(self):
+        try:
+            # XXX socketserver.serve_forever() has a serious architectural
+            # issue: its select loop wakes up every `poll_interval` seconds to
+            # see if the server is shutting down. The default, 500 ms, only lets
+            # us run two tests every second. But the faster we go, the more CPU
+            # we burn unnecessarily...
+            self.server.serve_forever(poll_interval=0.01)
+        except Exception as e:
+            self.exception = e
+
+    def stop(self, timeout=BLOCKING_TIMEOUT):
+        """
+        Shuts down the server and joins its thread. Raises an exception if the
+        thread could not be joined, or if it threw an exception itself. Must
+        only be called once, after start().
+        """
+        self.server.shutdown()
+        self.join(timeout)
+
+        if self.is_alive():
+            raise TimeoutError("client thread did not handshake within the timeout")
+        elif self.exception:
+            e = self.exception
+            raise e
+
+    class _OAuthState(object):
+        def __init__(self):
+            self.endpoint_paths = {}
+            self._endpoints = {}
+
+            # Provide a standard discovery document by default; tests can
+            # override it.
+            self.register_endpoint(
+                None,
+                "GET",
+                "/.well-known/openid-configuration",
+                self._default_discovery_handler,
+            )
+
+            # Default content type unless overridden.
+            self.content_type = "application/json"
+
+        @property
+        def discovery_uri(self):
+            return f"{self.issuer}/.well-known/openid-configuration"
+
+        def register_endpoint(self, name, method, path, func):
+            if method not in self._endpoints:
+                self._endpoints[method] = {}
+
+            self._endpoints[method][path] = func
+
+            if name is not None:
+                self.endpoint_paths[name] = path
+
+        def endpoint(self, method, path):
+            if method not in self._endpoints:
+                return None
+
+            return self._endpoints[method].get(path)
+
+        def _default_discovery_handler(self, headers, params):
+            doc = {
+                "issuer": self.issuer,
+                "response_types_supported": ["token"],
+                "subject_types_supported": ["public"],
+                "id_token_signing_alg_values_supported": ["RS256"],
+                "grant_types_supported": [
+                    "authorization_code",
+                    "urn:ietf:params:oauth:grant-type:device_code",
+                ],
+            }
+
+            for name, path in self.endpoint_paths.items():
+                doc[name] = self.issuer + path
+
+            return 200, doc
+
+    class _HTTPSServer(http.server.HTTPServer):
+        def __init__(self, ssl_socket, handler_cls):
+            # Attach the SSL socket to the server. We don't bind/activate since
+            # the socket is already listening.
+            super().__init__(None, handler_cls, bind_and_activate=False)
+            self.socket = ssl_socket
+            self.server_address = self.socket.getsockname()
+
+        def shutdown_request(self, request):
+            # Cleanly unwrap the SSL socket before shutting down the connection;
+            # otherwise careful clients will complain about truncation.
+            try:
+                request = request.unwrap()
+            except (ssl.SSLEOFError, ConnectionResetError, BrokenPipeError):
+                # The client already closed (or aborted) the connection without
+                # a clean shutdown. This is seen on some platforms during tests
+                # that break the HTTP protocol. Just return and have the server
+                # close the socket.
+                return
+            except ssl.SSLError as err:
+                # FIXME OpenSSL 3.4 introduced an incompatibility with Python's
+                # TLS error handling, resulting in a bogus "[SYS] unknown error"
+                # on some platforms. Hopefully this is fixed in 2025's set of
+                # maintenance releases and this case can be removed.
+                #
+                #     https://github.com/python/cpython/issues/127257
+                #
+                if "[SYS] unknown error" in str(err):
+                    return
+                raise
+
+            super().shutdown_request(request)
+
+        def handle_error(self, request, addr):
+            self.shutdown_request(request)
+            raise
+
+    @staticmethod
+    def _jwks_handler(headers, params):
+        return 200, {"keys": []}
+
+    @staticmethod
+    def _authorization_handler(headers, params):
+        # We don't actually want this to be called during these tests -- we
+        # should be using the device authorization endpoint instead.
+        assert (
+            False
+        ), "authorization handler called instead of device authorization handler"
+
+    class _Handler(http.server.BaseHTTPRequestHandler):
+        timeout = BLOCKING_TIMEOUT
+
+        def _handle(self, *, params=None, handler=None):
+            oauth = self.server.oauth
+            assert self.headers["Host"] == oauth.host
+
+            # XXX: BaseHTTPRequestHandler collapses leading slashes in the path
+            # to work around an open redirection vuln (gh-87389) in
+            # SimpleHTTPServer. But we're not using SimpleHTTPServer, and we
+            # want to test repeating leading slashes, so that's not very
+            # helpful. Put them back.
+            orig_path = self.raw_requestline.split()[1]
+            orig_path = str(orig_path, "iso-8859-1")
+            assert orig_path.endswith(self.path)  # sanity check
+            self.path = orig_path
+
+            if handler is None:
+                handler = oauth.endpoint(self.command, self.path)
+                assert (
+                    handler is not None
+                ), f"no registered endpoint for {self.command} {self.path}"
+
+            result = handler(self.headers, params)
+
+            if len(result) == 2:
+                headers = {"Content-Type": oauth.content_type}
+                code, resp = result
+            else:
+                code, headers, resp = result
+
+            self.send_response(code)
+            for h, v in headers.items():
+                self.send_header(h, v)
+            self.end_headers()
+
+            if resp is not None:
+                if not isinstance(resp, RawBytes):
+                    if not isinstance(resp, RawResponse):
+                        resp = json.dumps(resp)
+                    resp = resp.encode("utf-8")
+                self.wfile.write(resp)
+
+            self.close_connection = True
+
+        def do_GET(self):
+            self._handle()
+
+        def _request_body(self):
+            length = self.headers["Content-Length"]
+
+            # Handle only an explicit content-length.
+            assert length is not None
+            length = int(length)
+
+            return self.rfile.read(length).decode("utf-8")
+
+        def do_POST(self):
+            assert self.headers["Content-Type"] == "application/x-www-form-urlencoded"
+
+            body = self._request_body()
+            if body:
+                # parse_qs() is understandably fairly lax when it comes to
+                # acceptable characters, but we're stricter. Spaces must be
+                # encoded, and they must use the '+' encoding rather than "%20".
+                assert " " not in body
+                assert "%20" not in body
+
+                params = urllib.parse.parse_qs(
+                    body,
+                    keep_blank_values=True,
+                    strict_parsing=True,
+                    encoding="utf-8",
+                    errors="strict",
+                )
+            else:
+                params = {}
+
+            self._handle(params=params)
+
+
[email protected](autouse=True)
+def enable_client_oauth_debugging(monkeypatch):
+    """
+    HTTP providers aren't allowed by default; enable them via envvar.
+    """
+    monkeypatch.setenv("PGOAUTHDEBUG", "UNSAFE")
+
+
[email protected](autouse=True)
+def trust_certpair_in_client(monkeypatch, certpair):
+    """
+    Set a trusted CA file for OAuth client connections.
+    """
+    monkeypatch.setenv("PGOAUTHCAFILE", certpair[0])
+
+
[email protected](scope="session")
+def ssl_socket(certpair):
+    """
+    A listening server-side socket for SSL connections, using the certpair
+    fixture.
+    """
+    sock = socket.create_server(("", 0))
+
+    # The TLS connections we're making are incredibly sensitive to delayed ACKs
+    # from the client. (Without TCP_NODELAY, test performance degrades 4-5x.)
+    sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
+
+    with contextlib.closing(sock):
+        # Wrap the server socket for TLS.
+        ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
+        ctx.load_cert_chain(*certpair)
+
+        yield ctx.wrap_socket(sock, server_side=True)
+
+
[email protected]
+def openid_provider(ssl_socket):
+    """
+    A fixture that returns the OAuth state of a running OpenID provider server. The
+    server will be stopped when the fixture is torn down.
+    """
+    thread = OpenIDProvider(ssl_socket)
+    thread.start()
+
+    try:
+        yield thread.server.oauth
+    finally:
+        thread.stop()
+
+
+#
+# PQAuthDataHook implementation, matching libpq.h
+#
+
+
+PQAUTHDATA_PROMPT_OAUTH_DEVICE = 0
+PQAUTHDATA_OAUTH_BEARER_TOKEN = 1
+
+PGRES_POLLING_FAILED = 0
+PGRES_POLLING_READING = 1
+PGRES_POLLING_WRITING = 2
+PGRES_POLLING_OK = 3
+
+
+class PGPromptOAuthDevice(ctypes.Structure):
+    _fields_ = [
+        ("verification_uri", ctypes.c_char_p),
+        ("user_code", ctypes.c_char_p),
+        ("verification_uri_complete", ctypes.c_char_p),
+        ("expires_in", ctypes.c_int),
+    ]
+
+
+class PGOAuthBearerRequest(ctypes.Structure):
+    pass
+
+
+PGOAuthBearerRequest._fields_ = [
+    ("openid_configuration", ctypes.c_char_p),
+    ("scope", ctypes.c_char_p),
+    (
+        "async_",
+        ctypes.CFUNCTYPE(
+            ctypes.c_int,
+            ctypes.c_void_p,
+            ctypes.POINTER(PGOAuthBearerRequest),
+            ctypes.POINTER(ctypes.c_int),
+        ),
+    ),
+    (
+        "cleanup",
+        ctypes.CFUNCTYPE(None, ctypes.c_void_p, ctypes.POINTER(PGOAuthBearerRequest)),
+    ),
+    ("token", ctypes.c_char_p),
+    ("user", ctypes.c_void_p),
+]
+
+
[email protected]
+def auth_data_cb():
+    """
+    Tracks calls to the libpq authdata hook. The yielded object contains a calls
+    member that records the data sent to the hook. If a test needs to perform
+    custom actions during a call, it can set the yielded object's impl callback;
+    beware that the callback takes place on a different thread.
+
+    This is done differently from the other callback implementations on purpose.
+    For the others, we can declare test-specific callbacks and have them perform
+    direct assertions on the data they receive. But that won't work for a C
+    callback, because there's no way for us to bubble up the assertion through
+    libpq. Instead, this mock-style approach is taken, where we just record the
+    calls and let the test examine them later.
+    """
+
+    class _Call:
+        pass
+
+    class _cb(object):
+        def __init__(self):
+            self.calls = []
+
+    cb = _cb()
+    cb.impl = None
+
+    # The callback will occur on a different thread, so protect the cb object.
+    cb_lock = threading.Lock()
+
+    @ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_byte, ctypes.c_void_p, ctypes.c_void_p)
+    def auth_data_cb(typ, pgconn, data):
+        handle_by_default = 0  # does an implementation have to be provided?
+
+        if typ == PQAUTHDATA_PROMPT_OAUTH_DEVICE:
+            cls = PGPromptOAuthDevice
+            handle_by_default = 1
+        elif typ == PQAUTHDATA_OAUTH_BEARER_TOKEN:
+            cls = PGOAuthBearerRequest
+        else:
+            return 0
+
+        call = _Call()
+        call.type = typ
+
+        # The lifetime of the underlying data being pointed to doesn't
+        # necessarily match the lifetime of the Python object, so we can't
+        # reference a Structure's fields after returning. Explicitly copy the
+        # contents over, field by field.
+        data = ctypes.cast(data, ctypes.POINTER(cls))
+        for name, _ in cls._fields_:
+            setattr(call, name, getattr(data.contents, name))
+
+        with cb_lock:
+            cb.calls.append(call)
+
+        if cb.impl:
+            # Pass control back to the test.
+            try:
+                return cb.impl(typ, pgconn, data.contents)
+            except Exception:
+                # This can't escape into the C stack, but we can fail the flow
+                # and hope the traceback gives us enough detail.
+                logging.error(
+                    "Exception during authdata hook callback:\n"
+                    + traceback.format_exc()
+                )
+                return -1
+
+        return handle_by_default
+
+    libpq.PQsetAuthDataHook(auth_data_cb)
+    try:
+        yield cb
+    finally:
+        # The callback is about to go out of scope, so make sure libpq is
+        # disconnected from it. (We wouldn't want to accidentally influence
+        # later tests anyway.)
+        libpq.PQsetAuthDataHook(None)
+
+
[email protected](
+    "success, abnormal_failure",
+    [
+        pytest.param(True, False, id="success"),
+        pytest.param(False, False, id="normal failure"),
+        pytest.param(False, True, id="abnormal failure"),
+    ],
+)
[email protected]("secret", [None, "", "hunter2"])
[email protected]("scope", [None, "", "openid email"])
[email protected]("retries", [0, 1])
[email protected](
+    "content_type",
+    [
+        pytest.param("application/json", id="standard"),
+        pytest.param("application/json;charset=utf-8", id="charset"),
+        pytest.param("application/json \t;\t charset=utf-8", id="charset (whitespace)"),
+    ],
+)
[email protected]("uri_spelling", ["verification_url", "verification_uri"])
[email protected](
+    "asynchronous",
+    [
+        pytest.param(False, id="synchronous"),
+        pytest.param(True, id="asynchronous"),
+    ],
+)
+def test_oauth_with_explicit_discovery_uri(
+    accept,
+    openid_provider,
+    asynchronous,
+    uri_spelling,
+    content_type,
+    retries,
+    scope,
+    secret,
+    auth_data_cb,
+    success,
+    abnormal_failure,
+):
+    client_id = secrets.token_hex()
+    openid_provider.content_type = content_type
+
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id=client_id,
+        oauth_client_secret=secret,
+        oauth_scope=scope,
+        async_=asynchronous,
+    )
+
+    device_code = secrets.token_hex()
+    user_code = f"{secrets.token_hex(2)}-{secrets.token_hex(2)}"
+    verification_url = "https://example.com/device"
+
+    access_token = secrets.token_urlsafe()
+
+    def check_client_authn(headers, params):
+        if secret is None:
+            assert "Authorization" not in headers
+            assert params["client_id"] == [client_id]
+            return
+
+        # Require the client to use Basic authn; request-body credentials are
+        # NOT RECOMMENDED (RFC 6749, Sec. 2.3.1).
+        assert "Authorization" in headers
+        assert "client_id" not in params
+
+        method, creds = headers["Authorization"].split()
+        assert method == "Basic"
+
+        expected = f"{client_id}:{secret}"
+        assert base64.b64decode(creds) == expected.encode("ascii")
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        check_client_authn(headers, params)
+
+        if scope:
+            assert params["scope"] == [scope]
+        else:
+            assert "scope" not in params
+
+        resp = {
+            "device_code": device_code,
+            "user_code": user_code,
+            "interval": 0,
+            uri_spelling: verification_url,
+            "expires_in": 5,
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    attempts = 0
+    retry_lock = threading.Lock()
+
+    def token_endpoint(headers, params):
+        check_client_authn(headers, params)
+
+        assert params["grant_type"] == ["urn:ietf:params:oauth:grant-type:device_code"]
+        assert params["device_code"] == [device_code]
+
+        now = time.monotonic()
+
+        with retry_lock:
+            nonlocal attempts
+
+            # If the test wants to force the client to retry, return an
+            # authorization_pending response and decrement the retry count.
+            if attempts < retries:
+                attempts += 1
+                return 400, {"error": "authorization_pending"}
+
+        # Successfully finish the request by sending the access bearer token.
+        resp = {
+            "access_token": access_token,
+            "token_type": "bearer",
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    # First connection is a discovery request, which should result in the above
+    # endpoints being called.
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    # Client should reconnect.
+    sock, _ = accept()
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            initial = start_oauth_handshake(conn)
+
+            # Validate and accept the token.
+            auth = get_auth_value(initial)
+            assert auth == f"Bearer {access_token}".encode("ascii")
+
+            if success:
+                finish_handshake(conn)
+
+            elif abnormal_failure:
+                # Send an empty error response, which should result in a
+                # mechanism-level failure in the client. This test ensures that
+                # the client doesn't try a third connection for this case.
+                expected_error = "server sent error response without a status"
+                fail_oauth_handshake(conn, {})
+
+            else:
+                # Simulate token validation failure.
+                resp = {
+                    "status": "invalid_token",
+                    "openid-configuration": openid_provider.discovery_uri,
+                }
+                expected_error = "test token validation failure"
+                fail_oauth_handshake(conn, resp, errmsg=expected_error)
+
+    if retries:
+        # Finally, make sure that the client prompted the user once with the
+        # expected authorization URL and user code.
+        assert len(auth_data_cb.calls) == 2
+
+        # First call should have been for a custom flow, which we ignored.
+        assert auth_data_cb.calls[0].type == PQAUTHDATA_OAUTH_BEARER_TOKEN
+
+        # Second call is for our user prompt.
+        call = auth_data_cb.calls[1]
+        assert call.type == PQAUTHDATA_PROMPT_OAUTH_DEVICE
+        assert call.verification_uri.decode() == verification_url
+        assert call.user_code.decode() == user_code
+        assert call.verification_uri_complete is None
+        assert call.expires_in == 5
+
+    if not success:
+        # The client should not try to connect again.
+        with pytest.raises(psycopg2.OperationalError, match=expected_error):
+            client.check_completed()
+
+
[email protected](
+    "server_discovery",
+    [
+        pytest.param(True, id="server discovery"),
+        pytest.param(False, id="direct discovery"),
+    ],
+)
[email protected](
+    "issuer, path",
+    [
+        pytest.param(
+            "{issuer}",
+            "/.well-known/oauth-authorization-server",
+            id="oauth",
+        ),
+        pytest.param(
+            "{issuer}/alt",
+            "/.well-known/oauth-authorization-server/alt",
+            id="oauth with path, IETF style",
+        ),
+        pytest.param(
+            "{issuer}/alt",
+            "/alt/.well-known/oauth-authorization-server",
+            id="oauth with path, broken OIDC style",
+        ),
+        pytest.param(
+            "{issuer}/alt",
+            "/alt/.well-known/openid-configuration",
+            id="openid with path, OIDC style",
+        ),
+        pytest.param(
+            "{issuer}/alt",
+            "/.well-known/openid-configuration/alt",
+            id="openid with path, IETF style",
+        ),
+        pytest.param(
+            "{issuer}/",
+            "//.well-known/openid-configuration",
+            id="empty path segment, OIDC style",
+        ),
+        pytest.param(
+            "{issuer}/",
+            "/.well-known/openid-configuration/",
+            id="empty path segment, IETF style",
+        ),
+    ],
+)
+def test_alternate_well_known_paths(
+    accept, openid_provider, issuer, path, server_discovery
+):
+    issuer = issuer.format(issuer=openid_provider.issuer)
+    discovery_uri = openid_provider.issuer + path
+
+    client_id = secrets.token_hex()
+    access_token = secrets.token_urlsafe()
+
+    def discovery_handler(*args):
+        """
+        Pass-through implementation of the discovery handler. Modifies the
+        default document to contain this test's issuer identifier.
+        """
+        code, doc = openid_provider._default_discovery_handler(*args)
+        doc["issuer"] = issuer
+        return code, doc
+
+    openid_provider.register_endpoint(None, "GET", path, discovery_handler)
+
+    def authorization_endpoint(headers, params):
+        resp = {
+            "device_code": "12345",
+            "user_code": "ABCDE",
+            "interval": 0,
+            "verification_url": "https://example.com/device",
+            "expires_in": 5,
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    def token_endpoint(headers, params):
+        # Successfully finish the request by sending the access bearer token.
+        resp = {
+            "access_token": access_token,
+            "token_type": "bearer",
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    kwargs = dict(oauth_client_id=client_id)
+    if server_discovery:
+        kwargs.update(oauth_issuer=issuer)
+    else:
+        kwargs.update(oauth_issuer=discovery_uri)
+
+    sock, client = accept(**kwargs)
+
+    with sock:
+        handle_discovery_connection(sock, discovery_uri)
+
+    # Expect the client to connect again.
+    sock, _ = accept()
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            initial = start_oauth_handshake(conn)
+
+            # Validate the token.
+            auth = get_auth_value(initial)
+            assert auth == f"Bearer {access_token}".encode("ascii")
+
+            finish_handshake(conn)
+
+
[email protected](
+    "server_discovery",
+    [
+        pytest.param(True, id="server discovery"),
+        pytest.param(False, id="direct discovery"),
+    ],
+)
[email protected](
+    "issuer, path, expected_error",
+    [
+        pytest.param(
+            "{issuer}",
+            "/.well-known/oauth-authorization-server/",
+            None,
+            id="extra empty segment (no path)",
+        ),
+        pytest.param(
+            "{issuer}/path",
+            "/.well-known/oauth-authorization-server/path/",
+            None,
+            id="extra empty segment (with path)",
+        ),
+        pytest.param(
+            "{issuer}",
+            "?/.well-known/oauth-authorization-server",
+            r'OAuth discovery URI ".*" must not contain query or fragment components',
+            id="query",
+        ),
+        pytest.param(
+            "{issuer}",
+            "#/.well-known/oauth-authorization-server",
+            r'OAuth discovery URI ".*" must not contain query or fragment components',
+            id="fragment",
+        ),
+        pytest.param(
+            "{issuer}/sub/path",
+            "/sub/.well-known/oauth-authorization-server/path",
+            r'OAuth discovery URI ".*" uses an invalid format',
+            id="sandwiched prefix",
+        ),
+        pytest.param(
+            "{issuer}/path",
+            "/path/openid-configuration",
+            r'OAuth discovery URI ".*" is not a .well-known URI',
+            id="not .well-known",
+        ),
+        pytest.param(
+            "{issuer}",
+            "https://.well-known/oauth-authorization-server",
+            r'OAuth discovery URI ".*" is not a .well-known URI',
+            id=".well-known prefix buried in the authority",
+        ),
+        pytest.param(
+            "{issuer}",
+            "/.well-known/oauth-protected-resource",
+            r'OAuth discovery URI ".*" uses an unsupported .well-known suffix',
+            id="unknown well-known suffix",
+        ),
+        pytest.param(
+            "{issuer}/path",
+            "/path/.well-known/openid-configuration-2",
+            r'OAuth discovery URI ".*" uses an unsupported .well-known suffix',
+            id="unknown well-known suffix, OIDC style",
+        ),
+        pytest.param(
+            "{issuer}/path",
+            "/.well-known/oauth-authorization-server-2/path",
+            r'OAuth discovery URI ".*" uses an unsupported .well-known suffix',
+            id="unknown well-known suffix, IETF style",
+        ),
+        pytest.param(
+            "{issuer}",
+            "file:///.well-known/oauth-authorization-server",
+            r'OAuth discovery URI ".*" must use HTTPS',
+            id="unsupported scheme",
+        ),
+    ],
+)
+def test_bad_well_known_paths(
+    accept, openid_provider, issuer, path, expected_error, server_discovery
+):
+    if not server_discovery and "/.well-known/" not in path:
+        # An oauth_issuer without a /.well-known/ path segment is just a normal
+        # issuer identifier, so this isn't an interesting test.
+        pytest.skip("not interesting: direct discovery requires .well-known")
+
+    issuer = issuer.format(issuer=openid_provider.issuer)
+    discovery_uri = urllib.parse.urljoin(openid_provider.issuer, path)
+
+    client_id = secrets.token_hex()
+
+    def discovery_handler(*args):
+        """
+        Pass-through implementation of the discovery handler. Modifies the
+        default document to contain this test's issuer identifier.
+        """
+        code, doc = openid_provider._default_discovery_handler(*args)
+        doc["issuer"] = issuer
+        return code, doc
+
+    openid_provider.register_endpoint(None, "GET", path, discovery_handler)
+
+    def fail(*args):
+        """
+        No other endpoints should be contacted; fail if the client tries.
+        """
+        assert False, "endpoint unexpectedly called"
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", fail
+    )
+    openid_provider.register_endpoint("token_endpoint", "POST", "/token", fail)
+
+    kwargs = dict(oauth_client_id=client_id)
+    if server_discovery:
+        kwargs.update(oauth_issuer=issuer)
+    else:
+        kwargs.update(oauth_issuer=discovery_uri)
+
+    sock, client = accept(**kwargs)
+    with sock:
+        if expected_error and not server_discovery:
+            # If the client already knows the URL, it should disconnect as soon
+            # as it realizes it's not valid.
+            expect_disconnected_handshake(sock)
+        else:
+            # Otherwise, it should complete the connection.
+            handle_discovery_connection(sock, discovery_uri)
+
+    # The client should not reconnect.
+
+    if expected_error is None:
+        if server_discovery:
+            expected_error = rf"server's discovery document at {discovery_uri} \(issuer \".*\"\) is incompatible with oauth_issuer \({issuer}\)"
+        else:
+            expected_error = rf"the issuer identifier \({issuer}\) does not match oauth_issuer \(.*\)"
+
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
+def expect_disconnected_handshake(sock):
+    """
+    Helper for any tests that expect the client to disconnect immediately after
+    being sent the OAUTHBEARER SASL method. Generally speaking, this requires
+    the client to have an oauth_issuer set so that it doesn't try to go through
+    discovery.
+    """
+    with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+        # Initiate a handshake.
+        startup = pq3.recv1(conn, cls=pq3.Startup)
+        assert startup.proto == pq3.protocol(3, 0)
+
+        pq3.send(
+            conn,
+            pq3.types.AuthnRequest,
+            type=pq3.authn.SASL,
+            body=[b"OAUTHBEARER", b""],
+        )
+
+        # The client should disconnect at this point.
+        assert not conn.read(1), "client sent unexpected data"
+
+
[email protected](
+    "missing",
+    [
+        pytest.param(["oauth_issuer"], id="missing oauth_issuer"),
+        pytest.param(["oauth_client_id"], id="missing oauth_client_id"),
+        pytest.param(["oauth_client_id", "oauth_issuer"], id="missing both"),
+    ],
+)
+def test_oauth_requires_issuer_and_client_id(accept, openid_provider, missing):
+    params = dict(
+        oauth_issuer=openid_provider.issuer,
+        oauth_client_id="some-id",
+    )
+
+    # Remove required parameters. This should cause a client error after the
+    # server asks for OAUTHBEARER and the client tries to contact the issuer.
+    for k in missing:
+        del params[k]
+
+    sock, client = accept(**params)
+    with sock:
+        expect_disconnected_handshake(sock)
+
+    expected_error = "oauth_issuer and oauth_client_id are not both set"
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
+# See https://datatracker.ietf.org/doc/html/rfc6749#appendix-A for character
+# class definitions.
+all_vschars = "".join([chr(c) for c in range(0x20, 0x7F)])
+all_nqchars = "".join([chr(c) for c in range(0x21, 0x7F) if c not in (0x22, 0x5C)])
+
+
[email protected]("client_id", ["", ":", " + ", r'+=&"\/~', all_vschars])
[email protected]("secret", [None, "", ":", " + ", r'+=&"\/~', all_vschars])
[email protected]("device_code", ["", " + ", r'+=&"\/~', all_vschars])
[email protected]("scope", ["&", r"+=&/", all_nqchars])
+def test_url_encoding(accept, openid_provider, client_id, secret, device_code, scope):
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id=client_id,
+        oauth_client_secret=secret,
+        oauth_scope=scope,
+    )
+
+    user_code = f"{secrets.token_hex(2)}-{secrets.token_hex(2)}"
+    verification_url = "https://example.com/device"
+
+    access_token = secrets.token_urlsafe()
+
+    def check_client_authn(headers, params):
+        if secret is None:
+            assert "Authorization" not in headers
+            assert params["client_id"] == [client_id]
+            return
+
+        # Require the client to use Basic authn; request-body credentials are
+        # NOT RECOMMENDED (RFC 6749, Sec. 2.3.1).
+        assert "Authorization" in headers
+        assert "client_id" not in params
+
+        method, creds = headers["Authorization"].split()
+        assert method == "Basic"
+
+        decoded = base64.b64decode(creds).decode("utf-8")
+        username, password = decoded.split(":", 1)
+
+        expected_username = urllib.parse.quote_plus(client_id)
+        expected_password = urllib.parse.quote_plus(secret)
+
+        assert [username, password] == [expected_username, expected_password]
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        check_client_authn(headers, params)
+
+        if scope:
+            assert params["scope"] == [scope]
+        else:
+            assert "scope" not in params
+
+        resp = {
+            "device_code": device_code,
+            "user_code": user_code,
+            "interval": 0,
+            "verification_url": verification_url,
+            "expires_in": 5,
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    def token_endpoint(headers, params):
+        check_client_authn(headers, params)
+
+        assert params["grant_type"] == ["urn:ietf:params:oauth:grant-type:device_code"]
+        assert params["device_code"] == [device_code]
+
+        # Successfully finish the request by sending the access bearer token.
+        resp = {
+            "access_token": access_token,
+            "token_type": "bearer",
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    # First connection is a discovery request, which should result in the above
+    # endpoints being called.
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    # Second connection sends the token.
+    sock, _ = accept()
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            initial = start_oauth_handshake(conn)
+
+            # Validate and accept the token.
+            auth = get_auth_value(initial)
+            assert auth == f"Bearer {access_token}".encode("ascii")
+
+            finish_handshake(conn)
+
+
[email protected]
[email protected]("error_code", ["authorization_pending", "slow_down"])
[email protected]("retries", [1, 2])
[email protected]("omit_interval", [True, False])
+def test_oauth_retry_interval(
+    accept, openid_provider, omit_interval, retries, error_code
+):
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id="some-id",
+    )
+
+    expected_retry_interval = 5 if omit_interval else 1
+    access_token = secrets.token_urlsafe()
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        resp = {
+            "device_code": "my-device-code",
+            "user_code": "my-user-code",
+            "verification_uri": "https://example.com",
+            "expires_in": 5,
+        }
+
+        if not omit_interval:
+            resp["interval"] = expected_retry_interval
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    attempts = 0
+    last_retry = None
+    retry_lock = threading.Lock()
+    token_sent = threading.Event()
+
+    def token_endpoint(headers, params):
+        now = time.monotonic()
+
+        with retry_lock:
+            nonlocal attempts, last_retry, expected_retry_interval
+
+            # Make sure the retry interval is being respected by the client.
+            if last_retry is not None:
+                interval = now - last_retry
+                assert interval >= expected_retry_interval
+
+            last_retry = now
+
+            # If the test wants to force the client to retry, return the desired
+            # error response and decrement the retry count.
+            if attempts < retries:
+                attempts += 1
+
+                # A slow_down code requires the client to additionally increase
+                # its interval by five seconds.
+                if error_code == "slow_down":
+                    expected_retry_interval += 5
+
+                return 400, {"error": error_code}
+
+        # Successfully finish the request by sending the access bearer token,
+        # and signal the main thread to continue.
+        resp = {
+            "access_token": access_token,
+            "token_type": "bearer",
+        }
+        token_sent.set()
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    # First connection is a discovery request, which should result in the above
+    # endpoints being called.
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    # At this point the client is talking to the authorization server. Wait for
+    # that to succeed so we don't run into the accept() timeout.
+    token_sent.wait()
+
+    # Client should reconnect and send the token.
+    sock, _ = accept()
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            initial = start_oauth_handshake(conn)
+
+            # Validate and accept the token.
+            auth = get_auth_value(initial)
+            assert auth == f"Bearer {access_token}".encode("ascii")
+
+            finish_handshake(conn)
+
+
[email protected]
+def self_pipe():
+    """
+    Yields a pipe fd pair.
+    """
+
+    class _Pipe:
+        pass
+
+    p = _Pipe()
+    p.readfd, p.writefd = os.pipe()
+
+    try:
+        yield p
+    finally:
+        os.close(p.readfd)
+        os.close(p.writefd)
+
+
[email protected]("scope", [None, "", "openid email"])
[email protected](
+    "retries",
+    [
+        -1,  # no async callback
+        0,  # async callback immediately returns token
+        1,  # async callback waits on altsock once
+        2,  # async callback waits on altsock twice
+    ],
+)
[email protected](
+    "asynchronous",
+    [
+        pytest.param(False, id="synchronous"),
+        pytest.param(True, id="asynchronous"),
+    ],
+)
+def test_user_defined_flow(
+    accept, auth_data_cb, self_pipe, scope, retries, asynchronous
+):
+    issuer = "http://localhost"
+    discovery_uri = issuer + "/.well-known/openid-configuration"
+    access_token = secrets.token_urlsafe()
+
+    sock, client = accept(
+        oauth_issuer=discovery_uri,
+        oauth_client_id="some-id",
+        oauth_scope=scope,
+        async_=asynchronous,
+    )
+
+    # Track callbacks.
+    attempts = 0
+    wakeup_called = False
+    cleanup_calls = 0
+    lock = threading.Lock()
+
+    def wakeup():
+        """Writes a byte to the wakeup pipe."""
+        nonlocal wakeup_called
+        with lock:
+            wakeup_called = True
+            os.write(self_pipe.writefd, b"\0")
+
+    def get_token(pgconn, request, p_altsock):
+        """
+        Async token callback. While attempts < retries, libpq will be instructed
+        to wait on the self_pipe. When attempts == retries, the token will be
+        set.
+
+        Note that assertions and exceptions raised here are allowed but not very
+        helpful, since they can't bubble through the libpq stack to be collected
+        by the test suite. Try not to rely too heavily on them.
+        """
+        # Make sure libpq passed our user data through.
+        assert request.user == 42
+
+        with lock:
+            nonlocal attempts, wakeup_called
+
+            if attempts:
+                # If we've already started the timer, we shouldn't get a
+                # call back before it trips.
+                assert wakeup_called, "authdata hook was called before the timer"
+
+                # Drain the wakeup byte.
+                os.read(self_pipe.readfd, 1)
+
+            if attempts < retries:
+                attempts += 1
+
+                # Wake up the client in a little bit of time.
+                wakeup_called = False
+                threading.Timer(0.1, wakeup).start()
+
+                # Tell libpq to wait on the other end of the wakeup pipe.
+                p_altsock[0] = self_pipe.readfd
+                return PGRES_POLLING_READING
+
+        # Done!
+        request.token = access_token.encode()
+        return PGRES_POLLING_OK
+
+    @ctypes.CFUNCTYPE(
+        ctypes.c_int,
+        ctypes.c_void_p,
+        ctypes.POINTER(PGOAuthBearerRequest),
+        ctypes.POINTER(ctypes.c_int),
+    )
+    def get_token_wrapper(pgconn, p_request, p_altsock):
+        """
+        Translation layer between C and Python for the async callback.
+        Assertions and exceptions will be swallowed at the boundary, so make
+        sure they don't escape here.
+        """
+        try:
+            return get_token(pgconn, p_request.contents, p_altsock)
+        except Exception:
+            logging.error("Exception during async callback:\n" + traceback.format_exc())
+            return PGRES_POLLING_FAILED
+
+    @ctypes.CFUNCTYPE(None, ctypes.c_void_p, ctypes.POINTER(PGOAuthBearerRequest))
+    def cleanup(pgconn, p_request):
+        """
+        Should be called exactly once per connection.
+        """
+        nonlocal cleanup_calls
+        with lock:
+            cleanup_calls += 1
+
+    def bearer_hook(typ, pgconn, request):
+        """
+        Implementation of the PQAuthDataHook, which either sets up an async
+        callback or returns the token directly, depending on the value of
+        retries.
+
+        As above, try not to rely too much on assertions/exceptions here.
+        """
+        assert typ == PQAUTHDATA_OAUTH_BEARER_TOKEN
+        request.cleanup = cleanup
+
+        if retries < 0:
+            # Special case: return a token immediately without a callback.
+            request.token = access_token.encode()
+            return 1
+
+        # Tell libpq to call us back.
+        request.async_ = get_token_wrapper
+        request.user = ctypes.c_void_p(42)  # will be checked in the callback
+        return 1
+
+    auth_data_cb.impl = bearer_hook
+
+    # Now drive the server side.
+    if retries >= 0:
+        # First connection is a discovery request, which should result in the
+        # hook being invoked.
+        with sock:
+            handle_discovery_connection(sock, discovery_uri)
+
+        # Client should reconnect to send the token.
+        sock, _ = accept()
+
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            # Initiate a handshake, which should result in our custom callback
+            # being invoked to fetch the token.
+            initial = start_oauth_handshake(conn)
+
+            # Validate and accept the token.
+            auth = get_auth_value(initial)
+            assert auth == f"Bearer {access_token}".encode("ascii")
+
+            finish_handshake(conn)
+
+    # Check the data provided to the hook.
+    assert len(auth_data_cb.calls) == 1
+
+    call = auth_data_cb.calls[0]
+    assert call.type == PQAUTHDATA_OAUTH_BEARER_TOKEN
+    assert call.openid_configuration.decode() == discovery_uri
+    assert call.scope == (None if scope is None else scope.encode())
+
+    # Make sure we clean up after ourselves when the connection is finished.
+    client.check_completed()
+    assert cleanup_calls == 1
+
+
+def alt_patterns(*patterns):
+    """
+    Just combines multiple alternative regexes into one. It's not very efficient
+    but IMO it's easier to read and maintain.
+    """
+    pat = ""
+
+    for p in patterns:
+        if pat:
+            pat += "|"
+        pat += f"({p})"
+
+    return pat
+
+
[email protected](
+    "failure_mode, error_pattern",
+    [
+        pytest.param(
+            (
+                401,
+                {
+                    "error": "invalid_client",
+                    "error_description": "client authentication failed",
+                },
+            ),
+            r"failed to obtain device authorization: client authentication failed \(invalid_client\)",
+            id="authentication failure with description",
+        ),
+        pytest.param(
+            (400, {"error": "invalid_request"}),
+            r"failed to obtain device authorization: \(invalid_request\)",
+            id="invalid request without description",
+        ),
+        pytest.param(
+            (400, {"error": "invalid_request", "padding": "x" * 1024 * 1024}),
+            r"failed to obtain device authorization: response is too large",
+            id="gigantic authz response",
+        ),
+        pytest.param(
+            (400, {}),
+            r'failed to parse token error response: field "error" is missing',
+            id="broken error response",
+        ),
+        pytest.param(
+            (401, {"error": "invalid_client"}),
+            r"failed to obtain device authorization: provider requires client authentication, and no oauth_client_secret is set \(invalid_client\)",
+            id="failed authentication without description",
+        ),
+        pytest.param(
+            (200, RawResponse(r'{ "interval": 3.5.8 }')),
+            r"failed to parse device authorization: Token .* is invalid",
+            id="non-numeric interval",
+        ),
+        pytest.param(
+            (200, RawResponse(r'{ "interval": 08 }')),
+            r"failed to parse device authorization: Token .* is invalid",
+            id="invalid numeric interval",
+        ),
+    ],
+)
+def test_oauth_device_authorization_failures(
+    accept, openid_provider, failure_mode, error_pattern
+):
+    client_id = secrets.token_hex()
+
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id=client_id,
+    )
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        return failure_mode
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    def token_endpoint(headers, params):
+        assert False, "token endpoint was invoked unexpectedly"
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    # Now make sure the client correctly failed.
+    with pytest.raises(psycopg2.OperationalError, match=error_pattern):
+        client.check_completed()
+
+
+Missing = object()  # sentinel for test_oauth_device_authorization_bad_json()
+
+
[email protected](
+    "bad_value",
+    [
+        pytest.param({"device_code": 3}, id="object"),
+        pytest.param([1, 2, 3], id="array"),
+        pytest.param("some string", id="string"),
+        pytest.param(4, id="numeric"),
+        pytest.param(False, id="boolean"),
+        pytest.param(None, id="null"),
+        pytest.param(Missing, id="missing"),
+    ],
+)
[email protected](
+    "field_name,ok_type,required",
+    [
+        ("device_code", str, True),
+        ("user_code", str, True),
+        ("verification_uri", str, True),
+        ("interval", int, False),
+    ],
+)
+def test_oauth_device_authorization_bad_json_schema(
+    accept, openid_provider, field_name, ok_type, required, bad_value
+):
+    # To make the test matrix easy, just skip the tests that aren't actually
+    # interesting (field of the correct type, missing optional field).
+    if bad_value is Missing and not required:
+        pytest.skip("not interesting: optional field")
+    elif type(bad_value) == ok_type:  # not isinstance(), because bool is an int
+        pytest.skip("not interesting: correct type")
+
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id=secrets.token_hex(),
+    )
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        # Begin with an acceptable base response...
+        resp = {
+            "device_code": "my-device-code",
+            "user_code": "my-user-code",
+            "interval": 0,
+            "verification_uri": "https://example.com",
+            "expires_in": 5,
+        }
+
+        # ...then tweak it so the client fails.
+        if bad_value is Missing:
+            del resp[field_name]
+        else:
+            resp[field_name] = bad_value
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    def token_endpoint(headers, params):
+        assert False, "token endpoint was invoked unexpectedly"
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    # Now make sure the client correctly failed.
+    if bad_value is Missing:
+        error_pattern = f'field "{field_name}" is missing'
+    elif ok_type == str:
+        error_pattern = f'field "{field_name}" must be a string'
+    elif ok_type == int:
+        error_pattern = f'field "{field_name}" must be a number'
+    else:
+        assert False, "update error_pattern for new failure mode"
+
+    with pytest.raises(psycopg2.OperationalError, match=error_pattern):
+        client.check_completed()
+
+
[email protected](
+    "failure_mode, error_pattern",
+    [
+        pytest.param(
+            (
+                400,
+                {
+                    "error": "expired_token",
+                    "error_description": "the device code has expired",
+                },
+            ),
+            r"failed to obtain access token: the device code has expired \(expired_token\)",
+            id="expired token with description",
+        ),
+        pytest.param(
+            (400, {"error": "access_denied"}),
+            r"failed to obtain access token: \(access_denied\)",
+            id="access denied without description",
+        ),
+        pytest.param(
+            (400, {"error": "access_denied", "padding": "x" * 1024 * 1024}),
+            r"failed to obtain access token: response is too large",
+            id="gigantic token response",
+        ),
+        pytest.param(
+            (400, {}),
+            r'failed to parse token error response: field "error" is missing',
+            id="empty error response",
+        ),
+        pytest.param(
+            (401, {"error": "invalid_client"}),
+            r"failed to obtain access token: provider requires client authentication, and no oauth_client_secret is set \(invalid_client\)",
+            id="authentication failure without description",
+        ),
+        pytest.param(
+            (200, {}, {}),
+            r"failed to parse access token response: no content type was provided",
+            id="missing content type",
+        ),
+        pytest.param(
+            (200, {"Content-Type": "text/plain"}, {}),
+            r"failed to parse access token response: unexpected content type",
+            id="wrong content type",
+        ),
+        pytest.param(
+            (200, {"Content-Type": "application/jsonx"}, {}),
+            r"failed to parse access token response: unexpected content type",
+            id="wrong content type (correct prefix)",
+        ),
+    ],
+)
[email protected]("retries", [0, 1])
+def test_oauth_token_failures(
+    accept, openid_provider, retries, failure_mode, error_pattern
+):
+    client_id = secrets.token_hex()
+
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id=client_id,
+    )
+
+    device_code = secrets.token_hex()
+    user_code = f"{secrets.token_hex(2)}-{secrets.token_hex(2)}"
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        assert params["client_id"] == [client_id]
+
+        resp = {
+            "device_code": device_code,
+            "user_code": user_code,
+            "interval": 0,
+            "verification_uri": "https://example.com/device",
+            "expires_in": 5,
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    retry_lock = threading.Lock()
+    final_sent = False
+
+    def token_endpoint(headers, params):
+        with retry_lock:
+            nonlocal retries, final_sent
+
+            # If the test wants to force the client to retry, return an
+            # authorization_pending response and decrement the retry count.
+            if retries > 0:
+                retries -= 1
+                return 400, {"error": "authorization_pending"}
+
+            # We should only return our failure_mode response once; any further
+            # requests indicate that the client isn't correctly bailing out.
+            assert not final_sent, "client continued after token error"
+
+            final_sent = True
+
+        return failure_mode
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    # Now make sure the client correctly failed.
+    with pytest.raises(psycopg2.OperationalError, match=error_pattern):
+        client.check_completed()
+
+
[email protected](
+    "bad_value",
+    [
+        pytest.param({"device_code": 3}, id="object"),
+        pytest.param([1, 2, 3], id="array"),
+        pytest.param("some string", id="string"),
+        pytest.param(4, id="numeric"),
+        pytest.param(False, id="boolean"),
+        pytest.param(None, id="null"),
+        pytest.param(Missing, id="missing"),
+    ],
+)
[email protected](
+    "field_name,ok_type,required",
+    [
+        ("access_token", str, True),
+        ("token_type", str, True),
+    ],
+)
+def test_oauth_token_bad_json_schema(
+    accept, openid_provider, field_name, ok_type, required, bad_value
+):
+    # To make the test matrix easy, just skip the tests that aren't actually
+    # interesting (field of the correct type, missing optional field).
+    if bad_value is Missing and not required:
+        pytest.skip("not interesting: optional field")
+    elif type(bad_value) == ok_type:  # not isinstance(), because bool is an int
+        pytest.skip("not interesting: correct type")
+
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id=secrets.token_hex(),
+    )
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        resp = {
+            "device_code": "my-device-code",
+            "user_code": "my-user-code",
+            "interval": 0,
+            "verification_uri": "https://example.com",
+            "expires_in": 5,
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    def token_endpoint(headers, params):
+        # Begin with an acceptable base response...
+        resp = {
+            "access_token": secrets.token_urlsafe(),
+            "token_type": "bearer",
+        }
+
+        # ...then tweak it so the client fails.
+        if bad_value is Missing:
+            del resp[field_name]
+        else:
+            resp[field_name] = bad_value
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    # Now make sure the client correctly failed.
+    error_pattern = "failed to parse access token response: "
+    if bad_value is Missing:
+        error_pattern += f'field "{field_name}" is missing'
+    elif ok_type == str:
+        error_pattern += f'field "{field_name}" must be a string'
+    elif ok_type == int:
+        error_pattern += f'field "{field_name}" must be a number'
+    else:
+        assert False, "update error_pattern for new failure mode"
+
+    with pytest.raises(psycopg2.OperationalError, match=error_pattern):
+        client.check_completed()
+
+
[email protected]("success", [True, False])
[email protected]("scope", [None, "openid email"])
[email protected](
+    "base_response",
+    [
+        {"status": "invalid_token"},
+        {"extra_object": {"key": "value"}, "status": "invalid_token"},
+        {"extra_object": {"status": 1}, "status": "invalid_token"},
+    ],
+)
+def test_oauth_discovery(accept, openid_provider, base_response, scope, success):
+    sock, client = accept(
+        oauth_issuer=openid_provider.issuer,
+        oauth_client_id=secrets.token_hex(),
+    )
+
+    device_code = secrets.token_hex()
+    user_code = f"{secrets.token_hex(2)}-{secrets.token_hex(2)}"
+    verification_url = "https://example.com/device"
+
+    access_token = secrets.token_urlsafe()
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        if scope:
+            assert params["scope"] == [scope]
+        else:
+            assert "scope" not in params
+
+        resp = {
+            "device_code": device_code,
+            "user_code": user_code,
+            "interval": 0,
+            "verification_uri": verification_url,
+            "expires_in": 5,
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    def token_endpoint(headers, params):
+        assert params["grant_type"] == ["urn:ietf:params:oauth:grant-type:device_code"]
+        assert params["device_code"] == [device_code]
+
+        # Successfully finish the request by sending the access bearer token.
+        resp = {
+            "access_token": access_token,
+            "token_type": "bearer",
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    # Construct the response to use when failing the SASL exchange. Return a
+    # link to the discovery document, pointing to the test provider server.
+    fail_resp = {
+        **base_response,
+        "openid-configuration": openid_provider.discovery_uri,
+    }
+
+    if scope:
+        fail_resp["scope"] = scope
+
+    with sock:
+        handle_discovery_connection(sock, response=fail_resp)
+
+    # The client will connect to us a second time, using the parameters we sent
+    # it.
+    sock, _ = accept()
+
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            initial = start_oauth_handshake(conn)
+
+            # Validate the token.
+            auth = get_auth_value(initial)
+            assert auth == f"Bearer {access_token}".encode("ascii")
+
+            if success:
+                finish_handshake(conn)
+
+            else:
+                # Simulate token validation failure.
+                expected_error = "test token validation failure"
+                fail_oauth_handshake(conn, fail_resp, errmsg=expected_error)
+
+    if not success:
+        # The client should not try to connect again.
+        with pytest.raises(psycopg2.OperationalError, match=expected_error):
+            client.check_completed()
+
+
[email protected](
+    "response,expected_error",
+    [
+        pytest.param(
+            "abcde",
+            'Token "abcde" is invalid',
+            id="bad JSON: invalid syntax",
+        ),
+        pytest.param(
+            b"\xFF\xFF\xFF\xFF",
+            "server's error response is not valid UTF-8",
+            id="bad JSON: invalid encoding",
+        ),
+        pytest.param(
+            '"abcde"',
+            "top-level element must be an object",
+            id="bad JSON: top-level element is a string",
+        ),
+        pytest.param(
+            "[]",
+            "top-level element must be an object",
+            id="bad JSON: top-level element is an array",
+        ),
+        pytest.param(
+            "{}",
+            "server sent error response without a status",
+            id="bad JSON: no status member",
+        ),
+        pytest.param(
+            '{ "status": null }',
+            'field "status" must be a string',
+            id="bad JSON: null status member",
+        ),
+        pytest.param(
+            '{ "status": 0 }',
+            'field "status" must be a string',
+            id="bad JSON: int status member",
+        ),
+        pytest.param(
+            '{ "status": [ "bad" ] }',
+            'field "status" must be a string',
+            id="bad JSON: array status member",
+        ),
+        pytest.param(
+            '{ "status": { "bad": "bad" } }',
+            'field "status" must be a string',
+            id="bad JSON: object status member",
+        ),
+        pytest.param(
+            '{ "nested": { "status": "bad" } }',
+            "server sent error response without a status",
+            id="bad JSON: nested status",
+        ),
+        pytest.param(
+            '{ "status": "invalid_token" ',
+            "The input string ended unexpectedly",
+            id="bad JSON: unterminated object",
+        ),
+        pytest.param(
+            '{ "status": "invalid_token" } { }',
+            'Expected end of input, but found "{"',
+            id="bad JSON: trailing data",
+        ),
+        pytest.param(
+            '{ "status": "invalid_token", "openid-configuration": 1 }',
+            'field "openid-configuration" must be a string',
+            id="bad JSON: int openid-configuration member",
+        ),
+        pytest.param(
+            '{ "status": "invalid_token", "openid-configuration": 1 }',
+            'field "openid-configuration" must be a string',
+            id="bad JSON: int openid-configuration member",
+        ),
+        pytest.param(
+            '{ "status": "invalid_token", "openid-configuration": "", "openid-configuration": "" }',
+            'field "openid-configuration" is duplicated',
+            id="bad JSON: duplicated field",
+        ),
+        pytest.param(
+            '{ "status": "invalid_token", "scope": 1 }',
+            'field "scope" must be a string',
+            id="bad JSON: int scope member",
+        ),
+    ],
+)
+def test_oauth_discovery_server_error(accept, response, expected_error):
+    sock, client = accept(
+        oauth_issuer="https://example.com",
+        oauth_client_id=secrets.token_hex(),
+    )
+
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            initial = start_oauth_handshake(conn)
+
+            if isinstance(response, str):
+                response = response.encode("utf-8")
+
+            # Fail the SASL exchange with an invalid JSON response.
+            pq3.send(
+                conn,
+                pq3.types.AuthnRequest,
+                type=pq3.authn.SASLContinue,
+                body=response,
+            )
+
+            # The client should disconnect, so the socket is closed here. (If
+            # the client doesn't disconnect, it will report a different error
+            # below and the test will fail.)
+
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
+# All of these tests are expected to fail before libpq tries to actually attempt
+# a connection to any endpoint. To avoid hitting the network in the event that a
+# test fails, an invalid IPv4 address (256.256.256.256) is used as a hostname.
[email protected](
+    "bad_response,expected_error",
+    [
+        pytest.param(
+            (200, {"Content-Type": "text/plain"}, {}),
+            r'failed to parse OpenID discovery document: unexpected content type: "text/plain"',
+            id="not JSON",
+        ),
+        pytest.param(
+            (200, {}, {}),
+            r"failed to parse OpenID discovery document: no content type was provided",
+            id="no Content-Type",
+        ),
+        pytest.param(
+            (204, {}, None),
+            r"failed to fetch OpenID discovery document: unexpected response code 204",
+            id="no content",
+        ),
+        pytest.param(
+            (301, {"Location": "https://localhost/"}, None),
+            r"failed to fetch OpenID discovery document: unexpected response code 301",
+            id="redirection",
+        ),
+        pytest.param(
+            (404, {}),
+            r"failed to fetch OpenID discovery document: unexpected response code 404",
+            id="not found",
+        ),
+        pytest.param(
+            (200, RawResponse("blah\x00blah")),
+            r"failed to parse OpenID discovery document: response contains embedded NULLs",
+            id="NULL bytes in document",
+        ),
+        pytest.param(
+            (200, RawBytes(b"blah\xFFblah")),
+            r"failed to parse OpenID discovery document: response is not valid UTF-8",
+            id="document is not UTF-8",
+        ),
+        pytest.param(
+            (200, 123),
+            r"failed to parse OpenID discovery document: top-level element must be an object",
+            id="scalar at top level",
+        ),
+        pytest.param(
+            (200, []),
+            r"failed to parse OpenID discovery document: top-level element must be an object",
+            id="array at top level",
+        ),
+        pytest.param(
+            (200, RawResponse("{")),
+            r"failed to parse OpenID discovery document.* input string ended unexpectedly",
+            id="unclosed object",
+        ),
+        pytest.param(
+            (200, RawResponse(r'{ "hello": ] }')),
+            r"failed to parse OpenID discovery document.* Expected JSON value",
+            id="bad array",
+        ),
+        pytest.param(
+            (200, {"issuer": 123}),
+            r'failed to parse OpenID discovery document: field "issuer" must be a string',
+            id="non-string issuer",
+        ),
+        pytest.param(
+            (200, {"issuer": ["something"]}),
+            r'failed to parse OpenID discovery document: field "issuer" must be a string',
+            id="issuer array",
+        ),
+        pytest.param(
+            (200, {"issuer": {}}),
+            r'failed to parse OpenID discovery document: field "issuer" must be a string',
+            id="issuer object",
+        ),
+        pytest.param(
+            (200, {"grant_types_supported": 123}),
+            r'failed to parse OpenID discovery document: field "grant_types_supported" must be an array of strings',
+            id="numeric grant types field",
+        ),
+        pytest.param(
+            (
+                200,
+                {
+                    "grant_types_supported": "urn:ietf:params:oauth:grant-type:device_code"
+                },
+            ),
+            r'failed to parse OpenID discovery document: field "grant_types_supported" must be an array of strings',
+            id="string grant types field",
+        ),
+        pytest.param(
+            (200, {"grant_types_supported": {}}),
+            r'failed to parse OpenID discovery document: field "grant_types_supported" must be an array of strings',
+            id="object grant types field",
+        ),
+        pytest.param(
+            (200, {"grant_types_supported": [123]}),
+            r'failed to parse OpenID discovery document: field "grant_types_supported" must be an array of strings',
+            id="non-string grant types",
+        ),
+        pytest.param(
+            (200, {"grant_types_supported": ["something", 123]}),
+            r'failed to parse OpenID discovery document: field "grant_types_supported" must be an array of strings',
+            id="non-string grant types later in the list",
+        ),
+        pytest.param(
+            (200, {"grant_types_supported": ["something", {}]}),
+            r'failed to parse OpenID discovery document: field "grant_types_supported" must be an array of strings',
+            id="object grant types later in the list",
+        ),
+        pytest.param(
+            (200, {"grant_types_supported": ["something", ["something"]]}),
+            r'failed to parse OpenID discovery document: field "grant_types_supported" must be an array of strings',
+            id="embedded array grant types later in the list",
+        ),
+        pytest.param(
+            (
+                200,
+                {
+                    "grant_types_supported": ["something"],
+                    "token_endpoint": "https://256.256.256.256/",
+                    "issuer": 123,
+                },
+            ),
+            r'failed to parse OpenID discovery document: field "issuer" must be a string',
+            id="non-string issuer after other valid fields",
+        ),
+        pytest.param(
+            (
+                200,
+                {
+                    "ignored": {"grant_types_supported": 123, "token_endpoint": 123},
+                    "issuer": 123,
+                },
+            ),
+            r'failed to parse OpenID discovery document: field "issuer" must be a string',
+            id="non-string issuer after other ignored fields",
+        ),
+        pytest.param(
+            (200, {"token_endpoint": "https://256.256.256.256/"}),
+            r'failed to parse OpenID discovery document: field "issuer" is missing',
+            id="missing issuer",
+        ),
+        pytest.param(
+            (200, {"issuer": "{issuer}"}),
+            r'failed to parse OpenID discovery document: field "token_endpoint" is missing',
+            id="missing token endpoint",
+        ),
+        pytest.param(
+            (
+                200,
+                {
+                    "issuer": "{issuer}",
+                    "token_endpoint": "https://256.256.256.256/token",
+                    "grant_types_supported": [
+                        "urn:ietf:params:oauth:grant-type:device_code"
+                    ],
+                },
+            ),
+            r'cannot run OAuth device authorization: issuer "https://.*" does not provide a device authorization endpoint',
+            id="missing device_authorization_endpoint",
+        ),
+        pytest.param(
+            (
+                200,
+                {
+                    "issuer": "{issuer}",
+                    "token_endpoint": "https://256.256.256.256/token",
+                    "grant_types_supported": [
+                        "urn:ietf:params:oauth:grant-type:device_code"
+                    ],
+                    "device_authorization_endpoint": "https://256.256.256.256/dev",
+                    "filler": "x" * 1024 * 1024,
+                },
+            ),
+            r"failed to fetch OpenID discovery document: response is too large",
+            id="gigantic discovery response",
+        ),
+        pytest.param(
+            (
+                200,
+                {
+                    "issuer": "{issuer}/path",
+                    "token_endpoint": "https://256.256.256.256/token",
+                    "grant_types_supported": [
+                        "urn:ietf:params:oauth:grant-type:device_code"
+                    ],
+                    "device_authorization_endpoint": "https://256.256.256.256/dev",
+                },
+            ),
+            r"failed to parse OpenID discovery document: the issuer identifier \(https://.*/path\) does not match oauth_issuer \(https://.*\)",
+            id="mismatched issuer identifier",
+        ),
+        pytest.param(
+            (
+                200,
+                RawResponse(
+                    """{
+                        "issuer": "https://256.256.256.256/path",
+                        "token_endpoint": "https://256.256.256.256/token",
+                        "grant_types_supported": [
+                            "urn:ietf:params:oauth:grant-type:device_code"
+                        ],
+                        "device_authorization_endpoint": "https://256.256.256.256/dev",
+                        "device_authorization_endpoint": "https://256.256.256.256/dev"
+                    }"""
+                ),
+            ),
+            r'failed to parse OpenID discovery document: field "device_authorization_endpoint" is duplicated',
+            id="duplicated field",
+        ),
+        #
+        # Exercise HTTP-level failures by breaking the protocol. Note that the
+        # error messages here are implementation-dependent.
+        #
+        pytest.param(
+            (1000, {}),
+            r"failed to fetch OpenID discovery document: Unsupported protocol \(.*\)",
+            id="invalid HTTP response code",
+        ),
+        pytest.param(
+            (200, {"Content-Length": -1}, {}),
+            r"failed to fetch OpenID discovery document: Weird server reply \(.*Content-Length.*\)",
+            id="bad HTTP Content-Length",
+        ),
+    ],
+)
+def test_oauth_discovery_provider_failure(
+    accept, openid_provider, bad_response, expected_error
+):
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id=secrets.token_hex(),
+    )
+
+    def failing_discovery_handler(headers, params):
+        try:
+            # Insert the correct issuer value if the test wants to.
+            resp = bad_response[1]
+            iss = resp["issuer"]
+            resp["issuer"] = iss.format(issuer=openid_provider.issuer)
+        except (AttributeError, KeyError, TypeError):
+            pass
+
+        return bad_response
+
+    openid_provider.register_endpoint(
+        None,
+        "GET",
+        "/.well-known/openid-configuration",
+        failing_discovery_handler,
+    )
+
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
[email protected](
+    "sasl_err,resp_type,resp_payload,expected_error",
+    [
+        pytest.param(
+            {"status": "invalid_request"},
+            pq3.types.ErrorResponse,
+            dict(
+                fields=[b"SFATAL", b"C28000", b"Mexpected error message", b""],
+            ),
+            "server rejected OAuth bearer token: invalid_request",
+            id="standard server error: invalid_request",
+        ),
+        pytest.param(
+            {"status": "invalid_token"},
+            pq3.types.ErrorResponse,
+            dict(
+                fields=[b"SFATAL", b"C28000", b"Mexpected error message", b""],
+            ),
+            "expected error message",
+            id="standard server error: invalid_token without discovery URI",
+        ),
+        pytest.param(
+            {"status": "invalid_token", "openid-configuration": ""},
+            pq3.types.AuthnRequest,
+            dict(type=pq3.authn.SASLContinue, body=b""),
+            "server sent additional OAuth data",
+            id="broken server: additional challenge after error",
+        ),
+        pytest.param(
+            {"status": "invalid_token", "openid-configuration": ""},
+            pq3.types.AuthnRequest,
+            dict(type=pq3.authn.SASLFinal),
+            "server sent additional OAuth data",
+            id="broken server: SASL success after error",
+        ),
+        pytest.param(
+            {"status": "invalid_token", "openid-configuration": ""},
+            pq3.types.AuthnRequest,
+            dict(type=pq3.authn.SASL, body=[b"OAUTHBEARER", b""]),
+            "duplicate SASL authentication request",
+            id="broken server: SASL reinitialization after error",
+        ),
+    ],
+)
+def test_oauth_server_error(
+    accept, auth_data_cb, sasl_err, resp_type, resp_payload, expected_error
+):
+    wkuri = f"https://256.256.256.256/.well-known/openid-configuration"
+    sock, client = accept(
+        oauth_issuer=wkuri,
+        oauth_client_id="some-id",
+    )
+
+    def bearer_hook(typ, pgconn, request):
+        """
+        Implementation of the PQAuthDataHook, which returns a token directly so
+        we don't need an openid_provider instance.
+        """
+        assert typ == PQAUTHDATA_OAUTH_BEARER_TOKEN
+        request.token = secrets.token_urlsafe().encode()
+        return 1
+
+    auth_data_cb.impl = bearer_hook
+
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            start_oauth_handshake(conn)
+
+            # Ignore the client data. Return an error "challenge".
+            if "openid-configuration" in sasl_err:
+                sasl_err["openid-configuration"] = wkuri
+
+            resp = json.dumps(sasl_err)
+            resp = resp.encode("utf-8")
+
+            pq3.send(
+                conn, pq3.types.AuthnRequest, type=pq3.authn.SASLContinue, body=resp
+            )
+
+            # Per RFC, the client is required to send a dummy ^A response.
+            pkt = pq3.recv1(conn)
+            assert pkt.type == pq3.types.PasswordMessage
+            assert pkt.payload == b"\x01"
+
+            # Now fail the SASL exchange (in either a valid way, or an
+            # invalid one, depending on the test).
+            pq3.send(conn, resp_type, **resp_payload)
+
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
+def test_oauth_interval_overflow(accept, openid_provider):
+    """
+    A really badly behaved server could send a huge interval and then
+    immediately tell us to slow_down; ensure we handle this without breaking.
+    """
+    # (should be equivalent to the INT_MAX in limits.h)
+    int_max = ctypes.c_uint(-1).value // 2
+
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id=secrets.token_hex(),
+    )
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        resp = {
+            "device_code": "my-device-code",
+            "user_code": "my-user-code",
+            "verification_uri": "https://example.com",
+            "expires_in": 5,
+            "interval": int_max,
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    def token_endpoint(headers, params):
+        return 400, {"error": "slow_down"}
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    expected_error = "slow_down interval overflow"
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
+def test_oauth_refuses_http(accept, openid_provider, monkeypatch):
+    """
+    HTTP must be refused without PGOAUTHDEBUG.
+    """
+    monkeypatch.delenv("PGOAUTHDEBUG")
+
+    def to_http(uri):
+        """Swaps out a URI's scheme for http."""
+        parts = urllib.parse.urlparse(uri)
+        parts = parts._replace(scheme="http")
+        return urllib.parse.urlunparse(parts)
+
+    sock, client = accept(
+        oauth_issuer=to_http(openid_provider.issuer),
+        oauth_client_id=secrets.token_hex(),
+    )
+
+    # No provider callbacks necessary; we should fail immediately.
+
+    with sock:
+        handle_discovery_connection(sock, to_http(openid_provider.discovery_uri))
+
+    expected_error = r'OAuth discovery URI ".*" must use HTTPS'
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
[email protected]("auth_type", [pq3.authn.OK, pq3.authn.SASLFinal])
+def test_discovery_incorrectly_permits_connection(accept, auth_type):
+    """
+    Incorrectly responds to a client's discovery request with AuthenticationOK
+    or AuthenticationSASLFinal. require_auth=oauth should catch the former, and
+    the mechanism itself should catch the latter.
+    """
+    issuer = "https://256.256.256.256"
+    sock, client = accept(
+        oauth_issuer=issuer,
+        oauth_client_id=secrets.token_hex(),
+        require_auth="oauth",
+    )
+
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            initial = start_oauth_handshake(conn)
+
+            auth = get_auth_value(initial)
+            assert auth == b""
+
+            # Incorrectly log the client in. It should immediately disconnect.
+            pq3.send(conn, pq3.types.AuthnRequest, type=auth_type)
+            assert not conn.read(1), "client sent unexpected data"
+
+    if auth_type == pq3.authn.OK:
+        expected_error = "server did not complete authentication"
+    else:
+        expected_error = "server sent unexpected additional OAuth data"
+
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
+def test_no_discovery_url_provided(accept):
+    """
+    Tests what happens when the client doesn't know who to contact and the
+    server doesn't tell it.
+    """
+    issuer = "https://256.256.256.256"
+    sock, client = accept(
+        oauth_issuer=issuer,
+        oauth_client_id=secrets.token_hex(),
+    )
+
+    with sock:
+        handle_discovery_connection(sock, discovery=None)
+
+    expected_error = "no discovery metadata was provided"
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
[email protected]("change_between_connections", [False, True])
+def test_discovery_url_changes(accept, openid_provider, change_between_connections):
+    """
+    Ensures that the client complains if the server agrees on the issuer, but
+    disagrees on the discovery URL to be used.
+    """
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        resp = {
+            "device_code": "DEV",
+            "user_code": "USER",
+            "interval": 0,
+            "verification_uri": "https://example.org",
+            "expires_in": 5,
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    def token_endpoint(headers, params):
+        resp = {
+            "access_token": secrets.token_urlsafe(),
+            "token_type": "bearer",
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    # Have the client connect.
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id="some-id",
+    )
+
+    other_wkuri = f"{openid_provider.issuer}/.well-known/oauth-authorization-server"
+
+    if not change_between_connections:
+        # Immediately respond with the wrong URL.
+        with sock:
+            handle_discovery_connection(sock, other_wkuri)
+
+    else:
+        # First connection; use the right URL to begin with.
+        with sock:
+            handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+        # Second connection. Reject the token and switch the URL.
+        sock, _ = accept()
+        with sock:
+            with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+                initial = start_oauth_handshake(conn)
+                get_auth_value(initial)
+
+                # Ignore the token; fail with a different discovery URL.
+                resp = {
+                    "status": "invalid_token",
+                    "openid-configuration": other_wkuri,
+                }
+                fail_oauth_handshake(conn, resp)
+
+    expected_error = rf"server's discovery document has moved to {other_wkuri} \(previous location was {openid_provider.discovery_uri}\)"
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
diff --git a/src/test/python/conftest.py b/src/test/python/conftest.py
new file mode 100644
index 00000000000..1a73865ee47
--- /dev/null
+++ b/src/test/python/conftest.py
@@ -0,0 +1,34 @@
+#
+# Copyright 2023 Timescale, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import os
+
+import pytest
+
+
+def pytest_addoption(parser):
+    """
+    Adds custom command line options to py.test. We add one to signal temporary
+    Postgres instance creation for the server tests.
+
+    Per pytest documentation, this must live in the top level test directory.
+    """
+    parser.addoption(
+        "--temp-instance",
+        metavar="DIR",
+        help="create a temporary Postgres instance in DIR",
+    )
+
+
[email protected](scope="session", autouse=True)
+def _check_PG_TEST_EXTRA(request):
+    """
+    Automatically skips the whole suite if PG_TEST_EXTRA doesn't contain
+    'python'. pytestmark doesn't seem to work in a top-level conftest.py, so
+    I've made this an autoused fixture instead.
+    """
+    extra_tests = os.getenv("PG_TEST_EXTRA", "").split()
+    if "python" not in extra_tests:
+        pytest.skip("Potentially unsafe test 'python' not enabled in PG_TEST_EXTRA")
diff --git a/src/test/python/meson.build b/src/test/python/meson.build
new file mode 100644
index 00000000000..e137df852ef
--- /dev/null
+++ b/src/test/python/meson.build
@@ -0,0 +1,47 @@
+# Copyright (c) 2023, PostgreSQL Global Development Group
+
+subdir('server')
+
+pytest_env = {
+  'with_libcurl': libcurl.found() ? 'yes' : 'no',
+
+  # Point to the default database; the tests will create their own databases as
+  # needed.
+  'PGDATABASE': 'postgres',
+
+  # Avoid the need for a Rust compiler on platforms without prebuilt wheels for
+  # pyca/cryptography.
+  'CRYPTOGRAPHY_DONT_BUILD_RUST': '1',
+}
+
+# Some modules (psycopg2) need OpenSSL at compile time; for platforms where we
+# might have multiple implementations installed (macOS+brew), try to use the
+# same one that libpq is using.
+if ssl.found()
+  pytest_incdir = ssl.get_variable(pkgconfig: 'includedir', default_value: '')
+  if pytest_incdir != ''
+    pytest_env += { 'CPPFLAGS': '-I@0@'.format(pytest_incdir) }
+  endif
+
+  pytest_libdir = ssl.get_variable(pkgconfig: 'libdir', default_value: '')
+  if pytest_libdir != ''
+    pytest_env += { 'LDFLAGS': '-L@0@'.format(pytest_libdir) }
+  endif
+endif
+
+tests += {
+  'name': 'python',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'pytest': {
+	'requirements': meson.current_source_dir() / 'requirements.txt',
+    'tests': [
+      './client',
+      './server',
+      './test_internals.py',
+      './test_pq3.py',
+    ],
+    'env': pytest_env,
+    'test_kwargs': {'priority': 50}, # python tests are slow, start early
+  },
+}
diff --git a/src/test/python/pq3.py b/src/test/python/pq3.py
new file mode 100644
index 00000000000..ef809e288af
--- /dev/null
+++ b/src/test/python/pq3.py
@@ -0,0 +1,740 @@
+#
+# Copyright 2021 VMware, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import contextlib
+import getpass
+import io
+import os
+import platform
+import ssl
+import sys
+import textwrap
+
+from construct import *
+
+import tls
+
+
+def protocol(major, minor):
+    """
+    Returns the protocol version, in integer format, corresponding to the given
+    major and minor version numbers.
+    """
+    return (major << 16) | minor
+
+
+# Startup
+
+StringList = GreedyRange(NullTerminated(GreedyBytes))
+
+
+class KeyValueAdapter(Adapter):
+    """
+    Turns a key-value store into a null-terminated list of null-terminated
+    strings, as presented on the wire in the startup packet.
+    """
+
+    def _encode(self, obj, context, path):
+        if isinstance(obj, list):
+            return obj
+
+        l = []
+
+        for k, v in obj.items():
+            if isinstance(k, str):
+                k = k.encode("utf-8")
+            l.append(k)
+
+            if isinstance(v, str):
+                v = v.encode("utf-8")
+            l.append(v)
+
+        l.append(b"")
+        return l
+
+    def _decode(self, obj, context, path):
+        # TODO: turn a list back into a dict
+        return obj
+
+
+KeyValues = KeyValueAdapter(StringList)
+
+_startup_payload = Switch(
+    this.proto,
+    {
+        protocol(3, 0): KeyValues,
+    },
+    default=GreedyBytes,
+)
+
+
+def _default_protocol(this):
+    try:
+        if isinstance(this.payload, (list, dict)):
+            return protocol(3, 0)
+    except AttributeError:
+        pass  # no payload passed during build
+
+    return 0
+
+
+def _startup_payload_len(this):
+    """
+    The payload field has a fixed size based on the length of the packet. But
+    if the caller hasn't supplied an explicit length at build time, we have to
+    build the payload to figure out how long it is, which requires us to know
+    the length first... This function exists solely to break the cycle.
+    """
+    assert this._building, "_startup_payload_len() cannot be called during parsing"
+
+    try:
+        payload = this.payload
+    except AttributeError:
+        return 0  # no payload
+
+    if isinstance(payload, bytes):
+        # already serialized; just use the given length
+        return len(payload)
+
+    try:
+        proto = this.proto
+    except AttributeError:
+        proto = _default_protocol(this)
+
+    data = _startup_payload.build(payload, proto=proto)
+    return len(data)
+
+
+Startup = Struct(
+    "len" / Default(Int32sb, lambda this: _startup_payload_len(this) + 8),
+    "proto" / Default(Hex(Int32sb), _default_protocol),
+    "payload" / FixedSized(this.len - 8, Default(_startup_payload, b"")),
+)
+
+# Pq3
+
+
+# Adapted from construct.core.EnumIntegerString
+class EnumNamedByte:
+    def __init__(self, val, name):
+        self._val = val
+        self._name = name
+
+    def __int__(self):
+        return ord(self._val)
+
+    def __str__(self):
+        return "(enum) %s %r" % (self._name, self._val)
+
+    def __repr__(self):
+        return "EnumNamedByte(%r)" % self._val
+
+    def __eq__(self, other):
+        if isinstance(other, EnumNamedByte):
+            other = other._val
+        if not isinstance(other, bytes):
+            return NotImplemented
+
+        return self._val == other
+
+    def __hash__(self):
+        return hash(self._val)
+
+
+# Adapted from construct.core.Enum
+class ByteEnum(Adapter):
+    def __init__(self, **mapping):
+        super(ByteEnum, self).__init__(Byte)
+        self.namemapping = {k: EnumNamedByte(v, k) for k, v in mapping.items()}
+        self.decmapping = {v: EnumNamedByte(v, k) for k, v in mapping.items()}
+
+    def __getattr__(self, name):
+        if name in self.namemapping:
+            return self.decmapping[self.namemapping[name]]
+        raise AttributeError
+
+    def _decode(self, obj, context, path):
+        b = bytes([obj])
+        try:
+            return self.decmapping[b]
+        except KeyError:
+            return EnumNamedByte(b, "(unknown)")
+
+    def _encode(self, obj, context, path):
+        if isinstance(obj, int):
+            return obj
+        elif isinstance(obj, bytes):
+            return ord(obj)
+        return int(obj)
+
+
+types = ByteEnum(
+    ErrorResponse=b"E",
+    ReadyForQuery=b"Z",
+    Query=b"Q",
+    EmptyQueryResponse=b"I",
+    AuthnRequest=b"R",
+    PasswordMessage=b"p",
+    BackendKeyData=b"K",
+    CommandComplete=b"C",
+    ParameterStatus=b"S",
+    DataRow=b"D",
+    Terminate=b"X",
+)
+
+
+authn = Enum(
+    Int32ub,
+    OK=0,
+    SASL=10,
+    SASLContinue=11,
+    SASLFinal=12,
+)
+
+
+_authn_body = Switch(
+    this.type,
+    {
+        authn.OK: Terminated,
+        authn.SASL: StringList,
+    },
+    default=GreedyBytes,
+)
+
+
+def _data_len(this):
+    assert this._building, "_data_len() cannot be called during parsing"
+
+    if not hasattr(this, "data") or this.data is None:
+        return -1
+
+    return len(this.data)
+
+
+# The protocol reuses the PasswordMessage for several authentication response
+# types, and there's no good way to figure out which is which without keeping
+# state for the entire stream. So this is a separate Construct that can be
+# explicitly parsed/built by code that knows it's needed.
+SASLInitialResponse = Struct(
+    "name" / NullTerminated(GreedyBytes),
+    "len" / Default(Int32sb, lambda this: _data_len(this)),
+    "data"
+    / IfThenElse(
+        # Allow tests to explicitly pass an incorrect length during testing, by
+        # not enforcing a FixedSized during build. (The len calculation above
+        # defaults to the correct size.)
+        this._building,
+        Optional(GreedyBytes),
+        If(this.len != -1, Default(FixedSized(this.len, GreedyBytes), b"")),
+    ),
+    Terminated,  # make sure the entire response is consumed
+)
+
+
+_column = FocusedSeq(
+    "data",
+    "len" / Default(Int32sb, lambda this: _data_len(this)),
+    "data" / If(this.len != -1, FixedSized(this.len, GreedyBytes)),
+)
+
+
+_payload_map = {
+    types.ErrorResponse: Struct("fields" / StringList),
+    types.ReadyForQuery: Struct("status" / Bytes(1)),
+    types.Query: Struct("query" / NullTerminated(GreedyBytes)),
+    types.EmptyQueryResponse: Terminated,
+    types.AuthnRequest: Struct("type" / authn, "body" / Default(_authn_body, b"")),
+    types.BackendKeyData: Struct("pid" / Int32ub, "key" / Hex(Int32ub)),
+    types.CommandComplete: Struct("tag" / NullTerminated(GreedyBytes)),
+    types.ParameterStatus: Struct(
+        "name" / NullTerminated(GreedyBytes), "value" / NullTerminated(GreedyBytes)
+    ),
+    types.DataRow: Struct("columns" / Default(PrefixedArray(Int16sb, _column), b"")),
+    types.Terminate: Terminated,
+}
+
+
+_payload = FocusedSeq(
+    "_payload",
+    "_payload"
+    / Switch(
+        this._.type,
+        _payload_map,
+        default=GreedyBytes,
+    ),
+    Terminated,  # make sure every payload consumes the entire packet
+)
+
+
+def _payload_len(this):
+    """
+    See _startup_payload_len() for an explanation.
+    """
+    assert this._building, "_payload_len() cannot be called during parsing"
+
+    try:
+        payload = this.payload
+    except AttributeError:
+        return 0  # no payload
+
+    if isinstance(payload, bytes):
+        # already serialized; just use the given length
+        return len(payload)
+
+    data = _payload.build(payload, type=this.type)
+    return len(data)
+
+
+Pq3 = Struct(
+    "type" / types,
+    "len" / Default(Int32ub, lambda this: _payload_len(this) + 4),
+    "payload"
+    / IfThenElse(
+        # Allow tests to explicitly pass an incorrect length during testing, by
+        # not enforcing a FixedSized during build. (The len calculation above
+        # defaults to the correct size.)
+        this._building,
+        Optional(_payload),
+        FixedSized(this.len - 4, Default(_payload, b"")),
+    ),
+)
+
+
+# Environment
+
+
+def pghost():
+    return os.environ.get("PGHOST", default="localhost")
+
+
+def pgport():
+    return int(os.environ.get("PGPORT", default=5432))
+
+
+def pguser():
+    try:
+        return os.environ["PGUSER"]
+    except KeyError:
+        if platform.system() == "Windows":
+            # libpq defaults to GetUserName() on Windows.
+            return os.getlogin()
+        return getpass.getuser()
+
+
+def pgdatabase():
+    return os.environ.get("PGDATABASE", default="postgres")
+
+
+# Connections
+
+
+def _hexdump_translation_map():
+    """
+    For hexdumps. Translates any unprintable or non-ASCII bytes into '.'.
+    """
+    input = bytearray()
+
+    for i in range(128):
+        c = chr(i)
+
+        if not c.isprintable():
+            input += bytes([i])
+
+    input += bytes(range(128, 256))
+
+    return bytes.maketrans(input, b"." * len(input))
+
+
+class _DebugStream(object):
+    """
+    Wraps a file-like object and adds hexdumps of the read and write data. Call
+    end_packet() on a _DebugStream to write the accumulated hexdumps to the
+    output stream, along with the packet that was sent.
+    """
+
+    _translation_map = _hexdump_translation_map()
+
+    def __init__(self, stream, out=sys.stdout):
+        """
+        Creates a new _DebugStream wrapping the given stream (which must have
+        been created by wrap()). All attributes not provided by the _DebugStream
+        are delegated to the wrapped stream. out is the text stream to which
+        hexdumps are written.
+        """
+        self.raw = stream
+        self._out = out
+        self._rbuf = io.BytesIO()
+        self._wbuf = io.BytesIO()
+
+    def __getattr__(self, name):
+        return getattr(self.raw, name)
+
+    def __setattr__(self, name, value):
+        if name in ("raw", "_out", "_rbuf", "_wbuf"):
+            return object.__setattr__(self, name, value)
+
+        setattr(self.raw, name, value)
+
+    def read(self, *args, **kwargs):
+        buf = self.raw.read(*args, **kwargs)
+
+        self._rbuf.write(buf)
+        return buf
+
+    def write(self, b):
+        self._wbuf.write(b)
+        return self.raw.write(b)
+
+    def recv(self, *args):
+        buf = self.raw.recv(*args)
+
+        self._rbuf.write(buf)
+        return buf
+
+    def _flush(self, buf, prefix):
+        width = 16
+        hexwidth = width * 3 - 1
+
+        count = 0
+        buf.seek(0)
+
+        while True:
+            line = buf.read(16)
+
+            if not line:
+                if count:
+                    self._out.write("\n")  # separate the output block with a newline
+                return
+
+            self._out.write("%s %04X:\t" % (prefix, count))
+            self._out.write("%*s\t" % (-hexwidth, line.hex(" ")))
+            self._out.write(line.translate(self._translation_map).decode("ascii"))
+            self._out.write("\n")
+
+            count += 16
+
+    def print_debug(self, obj, *, prefix=""):
+        contents = ""
+        if obj is not None:
+            contents = str(obj)
+
+        for line in contents.splitlines():
+            self._out.write("%s%s\n" % (prefix, line))
+
+        self._out.write("\n")
+
+    def flush_debug(self, *, prefix=""):
+        self._flush(self._rbuf, prefix + "<")
+        self._rbuf = io.BytesIO()
+
+        self._flush(self._wbuf, prefix + ">")
+        self._wbuf = io.BytesIO()
+
+    def end_packet(self, pkt, *, read=False, prefix="", indent="  "):
+        """
+        Marks the end of a logical "packet" of data. A string representation of
+        pkt will be printed, and the debug buffers will be flushed with an
+        indent. All lines can be optionally prefixed.
+
+        If read is True, the packet representation is written after the debug
+        buffers; otherwise the default of False (meaning write) causes the
+        packet representation to be dumped first. This is meant to capture the
+        logical flow of layer translation.
+        """
+        write = not read
+
+        if write:
+            self.print_debug(pkt, prefix=prefix + "> ")
+
+        self.flush_debug(prefix=prefix + indent)
+
+        if read:
+            self.print_debug(pkt, prefix=prefix + "< ")
+
+
[email protected]
+def wrap(socket, *, debug_stream=None):
+    """
+    Transforms a raw socket into a connection that can be used for Construct
+    building and parsing. The return value is a context manager and can be used
+    in a with statement.
+    """
+    # It is critical that buffering be disabled here, so that we can still
+    # manipulate the raw socket without desyncing the stream.
+    with socket.makefile("rwb", buffering=0) as sfile:
+        # Expose the original socket's recv() on the SocketIO object we return.
+        def recv(self, *args):
+            return socket.recv(*args)
+
+        sfile.recv = recv.__get__(sfile)
+
+        conn = sfile
+        if debug_stream:
+            conn = _DebugStream(conn, debug_stream)
+
+        try:
+            yield conn
+        finally:
+            if debug_stream:
+                conn.flush_debug(prefix="? ")
+
+
+def _send(stream, cls, obj):
+    debugging = hasattr(stream, "flush_debug")
+    out = io.BytesIO()
+
+    # Ideally we would build directly to the passed stream, but because we need
+    # to reparse the generated output for the debugging case, build to an
+    # intermediate BytesIO and send it instead.
+    cls.build_stream(obj, out)
+    buf = out.getvalue()
+
+    stream.write(buf)
+    if debugging:
+        pkt = cls.parse(buf)
+        stream.end_packet(pkt)
+
+    stream.flush()
+
+
+def send(stream, packet_type, payload_data=None, **payloadkw):
+    """
+    Sends a packet on the given pq3 connection. type is the pq3.types member
+    that should be assigned to the packet. If payload_data is given, it will be
+    used as the packet payload; otherwise the key/value pairs in payloadkw will
+    be the payload contents.
+    """
+    data = payloadkw
+
+    if payload_data is not None:
+        if payloadkw:
+            raise ValueError(
+                "payload_data and payload keywords may not be used simultaneously"
+            )
+
+        data = payload_data
+
+    _send(stream, Pq3, dict(type=packet_type, payload=data))
+
+
+def send_startup(stream, proto=None, **kwargs):
+    """
+    Sends a startup packet on the given pq3 connection. In most cases you should
+    use the handshake functions instead, which will do this for you.
+
+    By default, a protocol version 3 packet will be sent. This can be overridden
+    with the proto parameter.
+    """
+    pkt = {}
+
+    if proto is not None:
+        pkt["proto"] = proto
+    if kwargs:
+        pkt["payload"] = kwargs
+
+    _send(stream, Startup, pkt)
+
+
+def recv1(stream, *, cls=Pq3):
+    """
+    Receives a single pq3 packet from the given stream and returns it.
+    """
+    resp = cls.parse_stream(stream)
+
+    debugging = hasattr(stream, "flush_debug")
+    if debugging:
+        stream.end_packet(resp, read=True)
+
+    return resp
+
+
+def handshake(stream, **kwargs):
+    """
+    Performs a libpq v3 startup handshake. kwargs should contain the key/value
+    parameters to send to the server in the startup packet.
+    """
+    # Send our startup parameters.
+    send_startup(stream, **kwargs)
+
+    # Receive and dump packets until the server indicates it's ready for our
+    # first query.
+    while True:
+        resp = recv1(stream)
+        if resp is None:
+            raise RuntimeError("server closed connection during handshake")
+
+        if resp.type == types.ReadyForQuery:
+            return
+        elif resp.type == types.ErrorResponse:
+            raise RuntimeError(
+                f"received error response from peer: {resp.payload.fields!r}"
+            )
+
+
+# TLS
+
+
+class _TLSStream(object):
+    """
+    A file-like object that performs TLS encryption/decryption on a wrapped
+    stream. Differs from ssl.SSLSocket in that we have full visibility and
+    control over the TLS layer.
+    """
+
+    def __init__(self, stream, context):
+        self._stream = stream
+        self._debugging = hasattr(stream, "flush_debug")
+
+        self._in = ssl.MemoryBIO()
+        self._out = ssl.MemoryBIO()
+        self._ssl = context.wrap_bio(self._in, self._out)
+
+    def handshake(self):
+        try:
+            self._pump(lambda: self._ssl.do_handshake())
+        finally:
+            self._flush_debug(prefix="? ")
+
+    def read(self, *args):
+        return self._pump(lambda: self._ssl.read(*args))
+
+    def write(self, *args):
+        return self._pump(lambda: self._ssl.write(*args))
+
+    def _decode(self, buf):
+        """
+        Attempts to decode a buffer of TLS data into a packet representation
+        that can be printed.
+
+        TODO: handle buffers (and record fragments) that don't align with packet
+        boundaries.
+        """
+        end = len(buf)
+        bio = io.BytesIO(buf)
+
+        ret = io.StringIO()
+
+        while bio.tell() < end:
+            record = tls.Plaintext.parse_stream(bio)
+
+            if ret.tell() > 0:
+                ret.write("\n")
+            ret.write("[Record] ")
+            ret.write(str(record))
+            ret.write("\n")
+
+            if record.type == tls.ContentType.handshake:
+                record_cls = tls.Handshake
+            else:
+                continue
+
+            innerlen = len(record.fragment)
+            inner = io.BytesIO(record.fragment)
+
+            while inner.tell() < innerlen:
+                msg = record_cls.parse_stream(inner)
+
+                indented = "[Message] " + str(msg)
+                indented = textwrap.indent(indented, "    ")
+
+                ret.write("\n")
+                ret.write(indented)
+                ret.write("\n")
+
+        return ret.getvalue()
+
+    def flush(self):
+        if not self._out.pending:
+            self._stream.flush()
+            return
+
+        buf = self._out.read()
+        self._stream.write(buf)
+
+        if self._debugging:
+            pkt = self._decode(buf)
+            self._stream.end_packet(pkt, prefix="  ")
+
+        self._stream.flush()
+
+    def _pump(self, operation):
+        while True:
+            try:
+                return operation()
+            except (ssl.SSLWantReadError, ssl.SSLWantWriteError) as e:
+                want = e
+            self._read_write(want)
+
+    def _recv(self, maxsize):
+        buf = self._stream.recv(4096)
+        if not buf:
+            self._in.write_eof()
+            return
+
+        self._in.write(buf)
+
+        if not self._debugging:
+            return
+
+        pkt = self._decode(buf)
+        self._stream.end_packet(pkt, read=True, prefix="  ")
+
+    def _read_write(self, want):
+        # XXX This needs work. So many corner cases yet to handle. For one,
+        # doing blocking writes in flush may lead to distributed deadlock if the
+        # peer is already blocking on its writes.
+
+        if isinstance(want, ssl.SSLWantWriteError):
+            assert self._out.pending, "SSL backend wants write without data"
+
+        self.flush()
+
+        if isinstance(want, ssl.SSLWantReadError):
+            self._recv(4096)
+
+    def _flush_debug(self, prefix):
+        if not self._debugging:
+            return
+
+        self._stream.flush_debug(prefix=prefix)
+
+
[email protected]
+def tls_handshake(stream, context):
+    """
+    Performs a TLS handshake over the given stream (which must have been created
+    via a call to wrap()), and returns a new stream which transparently tunnels
+    data over the TLS connection.
+
+    If the passed stream has debugging enabled, the returned stream will also
+    have debugging, using the same output IO.
+    """
+    debugging = hasattr(stream, "flush_debug")
+
+    # Send our startup parameters.
+    send_startup(stream, proto=protocol(1234, 5679))
+
+    # Look at the SSL response.
+    resp = stream.read(1)
+    if debugging:
+        stream.flush_debug(prefix="  ")
+
+    if resp == b"N":
+        raise RuntimeError("server does not support SSLRequest")
+    if resp != b"S":
+        raise RuntimeError(f"unexpected response of type {resp!r} during TLS startup")
+
+    tls = _TLSStream(stream, context)
+    tls.handshake()
+
+    if debugging:
+        tls = _DebugStream(tls, stream._out)
+
+    try:
+        yield tls
+        # TODO: teardown/unwrap the connection?
+    finally:
+        if debugging:
+            tls.flush_debug(prefix="? ")
diff --git a/src/test/python/pytest.ini b/src/test/python/pytest.ini
new file mode 100644
index 00000000000..ab7a6e7fb96
--- /dev/null
+++ b/src/test/python/pytest.ini
@@ -0,0 +1,4 @@
+[pytest]
+
+markers =
+    slow: mark test as slow
diff --git a/src/test/python/requirements.txt b/src/test/python/requirements.txt
new file mode 100644
index 00000000000..0dfcffb83e0
--- /dev/null
+++ b/src/test/python/requirements.txt
@@ -0,0 +1,11 @@
+black
+# cryptography 35.x and later add many platform/toolchain restrictions, beware
+cryptography~=3.4.8
+# TODO: figure out why 2.10.70 broke things
+# (probably https://github.com/construct/construct/pull/1015)
+construct==2.10.69
+isort~=5.6
+# TODO: update to psycopg[c] 3.1
+psycopg2~=2.9.7
+pytest~=7.3
+pytest-asyncio~=0.21.0
diff --git a/src/test/python/server/__init__.py b/src/test/python/server/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/src/test/python/server/conftest.py b/src/test/python/server/conftest.py
new file mode 100644
index 00000000000..42af80c73ee
--- /dev/null
+++ b/src/test/python/server/conftest.py
@@ -0,0 +1,141 @@
+#
+# Portions Copyright 2021 VMware, Inc.
+# Portions Copyright 2023 Timescale, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import collections
+import contextlib
+import os
+import shutil
+import socket
+import subprocess
+import sys
+
+import pytest
+
+import pq3
+
+BLOCKING_TIMEOUT = 2  # the number of seconds to wait for blocking calls
+
+
+def cleanup_prior_instance(datadir):
+    """
+    Clean up an existing data directory, but make sure it actually looks like a
+    data directory first. (Empty folders will remain untouched, since initdb can
+    populate them.)
+    """
+    required_entries = set(["base", "PG_VERSION", "postgresql.conf"])
+    empty = True
+
+    try:
+        with os.scandir(datadir) as entries:
+            for e in entries:
+                empty = False
+                required_entries.discard(e.name)
+
+    except FileNotFoundError:
+        return  # nothing to clean up
+
+    if empty:
+        return  # initdb can handle an empty datadir
+
+    if required_entries:
+        pytest.fail(
+            f"--temp-instance directory \"{datadir}\" is not empty and doesn't look like a data directory (missing {', '.join(required_entries)})"
+        )
+
+    # Okay, seems safe enough now.
+    shutil.rmtree(datadir)
+
+
[email protected](scope="session")
+def postgres_instance(pytestconfig, unused_tcp_port_factory):
+    """
+    If --temp-instance has been passed to pytest, this fixture runs a temporary
+    Postgres instance on an available port. Otherwise, the fixture will attempt
+    to contact a running Postgres server on (PGHOST, PGPORT); dependent tests
+    will be skipped if the connection fails.
+
+    Yields a (host, port) tuple for connecting to the server.
+    """
+    PGInstance = collections.namedtuple("PGInstance", ["addr", "temporary"])
+
+    datadir = pytestconfig.getoption("temp_instance")
+    if datadir:
+        # We were told to create a temporary instance. Use pg_ctl to set it up
+        # on an unused port.
+        cleanup_prior_instance(datadir)
+        subprocess.run(["pg_ctl", "-D", datadir, "init"], check=True)
+
+        # The CI looks for *.log files to upload, so the file name here isn't
+        # completely arbitrary.
+        log = os.path.join(datadir, "postmaster.log")
+        port = unused_tcp_port_factory()
+
+        subprocess.run(
+            [
+                "pg_ctl",
+                "-D",
+                datadir,
+                "-l",
+                log,
+                "-o",
+                " ".join(
+                    [
+                        f"-c port={port}",
+                        "-c listen_addresses=localhost",
+                        "-c log_connections=on",
+                        "-c session_preload_libraries=oauthtest",
+                        "-c oauth_validator_libraries=oauthtest",
+                    ]
+                ),
+                "start",
+            ],
+            check=True,
+        )
+
+        yield ("localhost", port)
+
+        subprocess.run(["pg_ctl", "-D", datadir, "stop"], check=True)
+
+    else:
+        # Try to contact an already running server; skip the suite if we can't
+        # find one.
+        addr = (pq3.pghost(), pq3.pgport())
+
+        try:
+            with socket.create_connection(addr, timeout=BLOCKING_TIMEOUT):
+                pass
+        except ConnectionError as e:
+            pytest.skip(f"unable to connect to Postgres server at {addr}: {e}")
+
+        yield addr
+
+
[email protected]
+def connect(postgres_instance):
+    """
+    A factory fixture that, when called, returns a socket connected to a
+    Postgres server, wrapped in a pq3 connection. Dependent tests will be
+    skipped if no server is available.
+    """
+    addr = postgres_instance
+
+    # Set up an ExitStack to handle safe cleanup of all of the moving pieces.
+    with contextlib.ExitStack() as stack:
+
+        def conn_factory():
+            sock = socket.create_connection(addr, timeout=BLOCKING_TIMEOUT)
+
+            # Have ExitStack close our socket.
+            stack.enter_context(sock)
+
+            # Wrap the connection in a pq3 layer and have ExitStack clean it up
+            # too.
+            wrap_ctx = pq3.wrap(sock, debug_stream=sys.stdout)
+            conn = stack.enter_context(wrap_ctx)
+
+            return conn
+
+        yield conn_factory
diff --git a/src/test/python/server/meson.build b/src/test/python/server/meson.build
new file mode 100644
index 00000000000..85534b9cc99
--- /dev/null
+++ b/src/test/python/server/meson.build
@@ -0,0 +1,18 @@
+# Copyright (c) 2024, PostgreSQL Global Development Group
+
+oauthtest_sources = files(
+  'oauthtest.c',
+)
+
+if host_system == 'windows'
+  oauthtest_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'oauthtest',
+    '--FILEDESC', 'passthrough module to validate OAuth tests',
+  ])
+endif
+
+oauthtest = shared_module('oauthtest',
+  oauthtest_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += oauthtest
diff --git a/src/test/python/server/oauthtest.c b/src/test/python/server/oauthtest.c
new file mode 100644
index 00000000000..415748b9a66
--- /dev/null
+++ b/src/test/python/server/oauthtest.c
@@ -0,0 +1,118 @@
+/*-------------------------------------------------------------------------
+ *
+ * oauthtest.c
+ *	  Test module for serverside OAuth token validation callbacks
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/test/python/server/oauthtest.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "fmgr.h"
+#include "libpq/oauth.h"
+#include "utils/guc.h"
+#include "utils/memutils.h"
+
+PG_MODULE_MAGIC;
+
+static void test_startup(ValidatorModuleState *state);
+static void test_shutdown(ValidatorModuleState *state);
+static ValidatorModuleResult *test_validate(ValidatorModuleState *state,
+											const char *token,
+											const char *role);
+
+static const OAuthValidatorCallbacks callbacks = {
+	.startup_cb = test_startup,
+	.shutdown_cb = test_shutdown,
+	.validate_cb = test_validate,
+};
+
+static char *expected_bearer = "";
+static bool set_authn_id = false;
+static char *authn_id = "";
+static bool reflect_role = false;
+
+void
+_PG_init(void)
+{
+	DefineCustomStringVariable("oauthtest.expected_bearer",
+							   "Expected Bearer token for future connections",
+							   NULL,
+							   &expected_bearer,
+							   "",
+							   PGC_SIGHUP,
+							   0,
+							   NULL, NULL, NULL);
+
+	DefineCustomBoolVariable("oauthtest.set_authn_id",
+							 "Whether to set an authenticated identity",
+							 NULL,
+							 &set_authn_id,
+							 false,
+							 PGC_SIGHUP,
+							 0,
+							 NULL, NULL, NULL);
+	DefineCustomStringVariable("oauthtest.authn_id",
+							   "Authenticated identity to use for future connections",
+							   NULL,
+							   &authn_id,
+							   "",
+							   PGC_SIGHUP,
+							   0,
+							   NULL, NULL, NULL);
+
+	DefineCustomBoolVariable("oauthtest.reflect_role",
+							 "Ignore the bearer token; use the requested role as the authn_id",
+							 NULL,
+							 &reflect_role,
+							 false,
+							 PGC_SIGHUP,
+							 0,
+							 NULL, NULL, NULL);
+
+	MarkGUCPrefixReserved("oauthtest");
+}
+
+const OAuthValidatorCallbacks *
+_PG_oauth_validator_module_init(void)
+{
+	return &callbacks;
+}
+
+static void
+test_startup(ValidatorModuleState *state)
+{
+}
+
+static void
+test_shutdown(ValidatorModuleState *state)
+{
+}
+
+static ValidatorModuleResult *
+test_validate(ValidatorModuleState *state, const char *token, const char *role)
+{
+	ValidatorModuleResult *res;
+
+	res = palloc0(sizeof(ValidatorModuleResult));	/* TODO: palloc context? */
+
+	if (reflect_role)
+	{
+		res->authorized = true;
+		res->authn_id = pstrdup(role);	/* TODO: constify? */
+	}
+	else
+	{
+		if (*expected_bearer && strcmp(token, expected_bearer) == 0)
+			res->authorized = true;
+		if (set_authn_id)
+			res->authn_id = authn_id;
+	}
+
+	return res;
+}
diff --git a/src/test/python/server/test_oauth.py b/src/test/python/server/test_oauth.py
new file mode 100644
index 00000000000..2839343ffa1
--- /dev/null
+++ b/src/test/python/server/test_oauth.py
@@ -0,0 +1,1080 @@
+#
+# Copyright 2021 VMware, Inc.
+# Portions Copyright 2023 Timescale, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import base64
+import contextlib
+import json
+import os
+import pathlib
+import platform
+import secrets
+import shlex
+import shutil
+import socket
+import struct
+from multiprocessing import shared_memory
+
+import psycopg2
+import pytest
+from construct import Container
+from psycopg2 import sql
+
+import pq3
+
+from .conftest import BLOCKING_TIMEOUT
+
+MAX_SASL_MESSAGE_LENGTH = 65535
+
+INVALID_AUTHORIZATION_ERRCODE = b"28000"
+PROTOCOL_VIOLATION_ERRCODE = b"08P01"
+FEATURE_NOT_SUPPORTED_ERRCODE = b"0A000"
+
+SHARED_MEM_NAME = "oauth-pytest"
+MAX_UINT16 = 2**16 - 1
+
+
[email protected]
+def prepend_file(path, lines, *, suffix=".bak"):
+    """
+    A context manager that prepends a file on disk with the desired lines of
+    text. When the context manager is exited, the file will be restored to its
+    original contents.
+    """
+    # First make a backup of the original file.
+    bak = path + suffix
+    shutil.copy2(path, bak)
+
+    try:
+        # Write the new lines, followed by the original file content.
+        with open(path, "w") as new, open(bak, "r") as orig:
+            new.writelines(lines)
+            shutil.copyfileobj(orig, new)
+
+        # Return control to the calling code.
+        yield
+
+    finally:
+        # Put the backup back into place.
+        os.replace(bak, path)
+
+
[email protected](scope="module")
+def oauth_ctx(postgres_instance):
+    """
+    Creates a database and user that use the oauth auth method. The context
+    object contains the dbname and user attributes as strings to be used during
+    connection, as well as the issuer and scope that have been set in the HBA
+    configuration.
+
+    This fixture assumes that the standard PG* environment variables point to a
+    server running on a local machine, and that the PGUSER has rights to create
+    databases and roles.
+    """
+    id = secrets.token_hex(4)
+
+    class Context:
+        dbname = "oauth_test_" + id
+
+        user = "oauth_user_" + id
+        punct_user = "oauth_\"'? ;&!_user_" + id  # username w/ punctuation
+        map_user = "oauth_map_user_" + id
+        authz_user = "oauth_authz_user_" + id
+
+        issuer = "https://example.com/" + id
+        scope = "openid " + id
+
+    ctx = Context()
+    hba_lines = [
+        f'host {ctx.dbname} {ctx.map_user}   samehost oauth issuer="{ctx.issuer}" scope="{ctx.scope}" map=oauth\n',
+        f'host {ctx.dbname} {ctx.authz_user} samehost oauth issuer="{ctx.issuer}" scope="{ctx.scope}" delegate_ident_mapping=1\n',
+        f'host {ctx.dbname} all              samehost oauth issuer="{ctx.issuer}" scope="{ctx.scope}"\n',
+    ]
+    ident_lines = [r"oauth /^(.*)@example\.com$ \1"]
+
+    if platform.system() == "Windows":
+        # XXX why is 'samehost' not behaving as expected on Windows?
+        for l in list(hba_lines):
+            hba_lines.append(l.replace("samehost", "::1/128"))
+
+    host, port = postgres_instance
+    conn = psycopg2.connect(host=host, port=port)
+    conn.autocommit = True
+
+    with contextlib.closing(conn):
+        c = conn.cursor()
+
+        # Create our roles and database.
+        user = sql.Identifier(ctx.user)
+        punct_user = sql.Identifier(ctx.punct_user)
+        map_user = sql.Identifier(ctx.map_user)
+        authz_user = sql.Identifier(ctx.authz_user)
+        dbname = sql.Identifier(ctx.dbname)
+
+        c.execute(sql.SQL("CREATE ROLE {} LOGIN;").format(user))
+        c.execute(sql.SQL("CREATE ROLE {} LOGIN;").format(punct_user))
+        c.execute(sql.SQL("CREATE ROLE {} LOGIN;").format(map_user))
+        c.execute(sql.SQL("CREATE ROLE {} LOGIN;").format(authz_user))
+        c.execute(sql.SQL("CREATE DATABASE {};").format(dbname))
+
+        # Replace pg_hba and pg_ident.
+        c.execute("SHOW hba_file;")
+        hba = c.fetchone()[0]
+
+        c.execute("SHOW ident_file;")
+        ident = c.fetchone()[0]
+
+        with prepend_file(hba, hba_lines), prepend_file(ident, ident_lines):
+            c.execute("SELECT pg_reload_conf();")
+
+            # Use the new database and user.
+            yield ctx
+
+        # Put things back the way they were.
+        c.execute("SELECT pg_reload_conf();")
+
+        c.execute(sql.SQL("DROP DATABASE {};").format(dbname))
+        c.execute(sql.SQL("DROP ROLE {};").format(authz_user))
+        c.execute(sql.SQL("DROP ROLE {};").format(map_user))
+        c.execute(sql.SQL("DROP ROLE {};").format(punct_user))
+        c.execute(sql.SQL("DROP ROLE {};").format(user))
+
+
[email protected]()
+def conn(oauth_ctx, connect):
+    """
+    A convenience wrapper for connect(). The main purpose of this fixture is to
+    make sure oauth_ctx runs its setup code before the connection is made.
+    """
+    return connect()
+
+
+def bearer_token(*, size=16):
+    """
+    Generates a Bearer token using secrets.token_urlsafe(). The generated token
+    size in bytes may be specified; if unset, a small 16-byte token will be
+    generated.
+    """
+
+    if size % 4:
+        raise ValueError(f"requested token size {size} is not a multiple of 4")
+
+    token = secrets.token_urlsafe(size // 4 * 3)
+    assert len(token) == size
+
+    return token
+
+
+def begin_oauth_handshake(conn, oauth_ctx, *, user=None):
+    if user is None:
+        user = oauth_ctx.authz_user
+
+    pq3.send_startup(conn, user=user, database=oauth_ctx.dbname)
+
+    resp = pq3.recv1(conn)
+    assert resp.type == pq3.types.AuthnRequest
+
+    # The server should advertise exactly one mechanism.
+    assert resp.payload.type == pq3.authn.SASL
+    assert resp.payload.body == [b"OAUTHBEARER", b""]
+
+
+def send_initial_response(conn, *, auth=None, bearer=None):
+    """
+    Sends the OAUTHBEARER initial response on the connection, using the given
+    bearer token. Alternatively to a bearer token, the initial response's auth
+    field may be explicitly specified to test corner cases.
+    """
+    if bearer is not None and auth is not None:
+        raise ValueError("exactly one of the auth and bearer kwargs must be set")
+
+    if bearer is not None:
+        auth = b"Bearer " + bearer
+
+    if auth is None:
+        raise ValueError("exactly one of the auth and bearer kwargs must be set")
+
+    initial = pq3.SASLInitialResponse.build(
+        dict(
+            name=b"OAUTHBEARER",
+            data=b"n,,\x01auth=" + auth + b"\x01\x01",
+        )
+    )
+    pq3.send(conn, pq3.types.PasswordMessage, initial)
+
+
+def expect_handshake_success(conn):
+    """
+    Validates that the server responds with an AuthnOK message, and then drains
+    the connection until a ReadyForQuery message is received.
+    """
+    resp = pq3.recv1(conn)
+
+    assert resp.type == pq3.types.AuthnRequest
+    assert resp.payload.type == pq3.authn.OK
+    assert not resp.payload.body
+
+    receive_until(conn, pq3.types.ReadyForQuery)
+
+
+def expect_handshake_failure(conn, oauth_ctx):
+    """
+    Performs the OAUTHBEARER SASL failure "handshake" and validates the server's
+    side of the conversation, including the final ErrorResponse.
+    """
+
+    # We expect a discovery "challenge" back from the server before the authn
+    # failure message.
+    resp = pq3.recv1(conn)
+    assert resp.type == pq3.types.AuthnRequest
+
+    req = resp.payload
+    assert req.type == pq3.authn.SASLContinue
+
+    body = json.loads(req.body)
+    assert body["status"] == "invalid_token"
+    assert body["scope"] == oauth_ctx.scope
+
+    expected_config = oauth_ctx.issuer + "/.well-known/openid-configuration"
+    assert body["openid-configuration"] == expected_config
+
+    # Send the dummy response to complete the failed handshake.
+    pq3.send(conn, pq3.types.PasswordMessage, b"\x01")
+    resp = pq3.recv1(conn)
+
+    err = ExpectedError(INVALID_AUTHORIZATION_ERRCODE, "bearer authentication failed")
+    err.match(resp)
+
+
+def receive_until(conn, type):
+    """
+    receive_until pulls packets off the pq3 connection until a packet with the
+    desired type is found, or an error response is received.
+    """
+    while True:
+        pkt = pq3.recv1(conn)
+
+        if pkt.type == type:
+            return pkt
+        elif pkt.type == pq3.types.ErrorResponse:
+            raise RuntimeError(
+                f"received error response from peer: {pkt.payload.fields!r}"
+            )
+
+
[email protected]()
+def setup_validator(postgres_instance):
+    """
+    A per-test fixture that sets up the test validator with expected behavior.
+    The setting will be reverted during teardown.
+    """
+    host, port = postgres_instance
+    conn = psycopg2.connect(host=host, port=port)
+    conn.autocommit = True
+
+    with contextlib.closing(conn):
+        c = conn.cursor()
+        prev = dict()
+
+        def setter(**gucs):
+            for guc, val in gucs.items():
+                # Save the previous value.
+                c.execute(sql.SQL("SHOW oauthtest.{};").format(sql.Identifier(guc)))
+                prev[guc] = c.fetchone()[0]
+
+                c.execute(
+                    sql.SQL("ALTER SYSTEM SET oauthtest.{} TO %s;").format(
+                        sql.Identifier(guc)
+                    ),
+                    (val,),
+                )
+                c.execute("SELECT pg_reload_conf();")
+
+        yield setter
+
+        # Restore the previous values.
+        for guc, val in prev.items():
+            c.execute(
+                sql.SQL("ALTER SYSTEM SET oauthtest.{} TO %s;").format(
+                    sql.Identifier(guc)
+                ),
+                (val,),
+            )
+            c.execute("SELECT pg_reload_conf();")
+
+
[email protected]("token_len", [16, 1024, 4096])
[email protected](
+    "auth_prefix",
+    [
+        b"Bearer ",
+        b"bearer ",
+        b"Bearer    ",
+    ],
+)
+def test_oauth(setup_validator, connect, oauth_ctx, auth_prefix, token_len):
+    # Generate our bearer token with the desired length.
+    token = bearer_token(size=token_len)
+    setup_validator(expected_bearer=token)
+
+    conn = connect()
+    begin_oauth_handshake(conn, oauth_ctx)
+
+    auth = auth_prefix + token.encode("ascii")
+    send_initial_response(conn, auth=auth)
+    expect_handshake_success(conn)
+
+    # Make sure that the server has not set an authenticated ID.
+    pq3.send(conn, pq3.types.Query, query=b"SELECT system_user;")
+    resp = receive_until(conn, pq3.types.DataRow)
+
+    row = resp.payload
+    assert row.columns == [None]
+
+
[email protected](
+    "token_value",
+    [
+        "abcdzA==",
+        "123456M=",
+        "x-._~+/x",
+    ],
+)
+def test_oauth_bearer_corner_cases(setup_validator, connect, oauth_ctx, token_value):
+    setup_validator(expected_bearer=token_value)
+
+    conn = connect()
+    begin_oauth_handshake(conn, oauth_ctx)
+
+    send_initial_response(conn, bearer=token_value.encode("ascii"))
+
+    expect_handshake_success(conn)
+
+
[email protected](
+    "user,authn_id,should_succeed",
+    [
+        pytest.param(
+            lambda ctx: ctx.user,
+            lambda ctx: ctx.user,
+            True,
+            id="validator authn: succeeds when authn_id == username",
+        ),
+        pytest.param(
+            lambda ctx: ctx.user,
+            lambda ctx: None,
+            False,
+            id="validator authn: fails when authn_id is not set",
+        ),
+        pytest.param(
+            lambda ctx: ctx.user,
+            lambda ctx: "",
+            False,
+            id="validator authn: fails when authn_id is empty",
+        ),
+        pytest.param(
+            lambda ctx: ctx.user,
+            lambda ctx: ctx.authz_user,
+            False,
+            id="validator authn: fails when authn_id != username",
+        ),
+        pytest.param(
+            lambda ctx: ctx.map_user,
+            lambda ctx: ctx.map_user + "@example.com",
+            True,
+            id="validator with map: succeeds when authn_id matches map",
+        ),
+        pytest.param(
+            lambda ctx: ctx.map_user,
+            lambda ctx: None,
+            False,
+            id="validator with map: fails when authn_id is not set",
+        ),
+        pytest.param(
+            lambda ctx: ctx.map_user,
+            lambda ctx: ctx.map_user + "@example.net",
+            False,
+            id="validator with map: fails when authn_id doesn't match map",
+        ),
+        pytest.param(
+            lambda ctx: ctx.authz_user,
+            lambda ctx: None,
+            True,
+            id="validator authz: succeeds with no authn_id",
+        ),
+        pytest.param(
+            lambda ctx: ctx.authz_user,
+            lambda ctx: "",
+            True,
+            id="validator authz: succeeds with empty authn_id",
+        ),
+        pytest.param(
+            lambda ctx: ctx.authz_user,
+            lambda ctx: "postgres",
+            True,
+            id="validator authz: succeeds with basic username",
+        ),
+        pytest.param(
+            lambda ctx: ctx.authz_user,
+            lambda ctx: "[email protected]",
+            True,
+            id="validator authz: succeeds with email address",
+        ),
+    ],
+)
+def test_oauth_authn_id(
+    setup_validator, connect, oauth_ctx, user, authn_id, should_succeed
+):
+    token = bearer_token()
+    authn_id = authn_id(oauth_ctx)
+
+    # Set up the validator appropriately.
+    gucs = dict(expected_bearer=token)
+    if authn_id is not None:
+        gucs["set_authn_id"] = True
+        gucs["authn_id"] = authn_id
+    setup_validator(**gucs)
+
+    conn = connect()
+    username = user(oauth_ctx)
+    begin_oauth_handshake(conn, oauth_ctx, user=username)
+    send_initial_response(conn, bearer=token.encode("ascii"))
+
+    if not should_succeed:
+        expect_handshake_failure(conn, oauth_ctx)
+        return
+
+    expect_handshake_success(conn)
+
+    # Check the reported authn_id.
+    pq3.send(conn, pq3.types.Query, query=b"SELECT system_user;")
+    resp = receive_until(conn, pq3.types.DataRow)
+
+    expected = authn_id
+    if expected is not None:
+        expected = b"oauth:" + expected.encode("ascii")
+
+    row = resp.payload
+    assert row.columns == [expected]
+
+
+class ExpectedError(object):
+    def __init__(self, code, msg=None, detail=None):
+        self.code = code
+        self.msg = msg
+        self.detail = detail
+
+        # Protect against the footgun of an accidental empty string, which will
+        # "match" anything. If you don't want to match message or detail, just
+        # don't pass them.
+        if self.msg == "":
+            raise ValueError("msg must be non-empty or None")
+        if self.detail == "":
+            raise ValueError("detail must be non-empty or None")
+
+    def _getfield(self, resp, type):
+        """
+        Searches an ErrorResponse for a single field of the given type (e.g.
+        "M", "C", "D") and returns its value. Asserts if it doesn't find exactly
+        one field.
+        """
+        prefix = type.encode("ascii")
+        fields = [f for f in resp.payload.fields if f.startswith(prefix)]
+
+        assert len(fields) == 1
+        return fields[0][1:]  # strip off the type byte
+
+    def match(self, resp):
+        """
+        Checks that the given response matches the expected code, message, and
+        detail (if given). The error code must match exactly. The expected
+        message and detail must be contained within the actual strings.
+        """
+        assert resp.type == pq3.types.ErrorResponse
+
+        code = self._getfield(resp, "C")
+        assert code == self.code
+
+        if self.msg:
+            msg = self._getfield(resp, "M")
+            expected = self.msg.encode("utf-8")
+            assert expected in msg
+
+        if self.detail:
+            detail = self._getfield(resp, "D")
+            expected = self.detail.encode("utf-8")
+            assert expected in detail
+
+
+def test_oauth_rejected_bearer(conn, oauth_ctx):
+    begin_oauth_handshake(conn, oauth_ctx)
+
+    # Send a bearer token that doesn't match what the validator expects. It
+    # should fail the connection.
+    send_initial_response(conn, bearer=b"xxxxxx")
+
+    expect_handshake_failure(conn, oauth_ctx)
+
+
[email protected](
+    "bad_bearer",
+    [
+        b"Bearer    ",
+        b"Bearer a===b",
+        b"Bearer hello!",
+        b"Bearer trailingspace ",
+        b"Bearer trailingtab\t",
+        b"Bearer [email protected]",
+        b"Beare abcd",
+        b" Bearer leadingspace",
+        b'OAuth realm="Example"',
+        b"",
+    ],
+)
+def test_oauth_invalid_bearer(setup_validator, connect, oauth_ctx, bad_bearer):
+    # Tell the validator to accept any token. This ensures that the invalid
+    # bearer tokens are rejected before the validation step.
+    setup_validator(reflect_role=True)
+
+    conn = connect()
+    begin_oauth_handshake(conn, oauth_ctx)
+    send_initial_response(conn, auth=bad_bearer)
+
+    expect_handshake_failure(conn, oauth_ctx)
+
+
[email protected]
[email protected](
+    "resp_type,resp,err",
+    [
+        pytest.param(
+            None,
+            None,
+            None,
+            marks=pytest.mark.slow,
+            id="no response (expect timeout)",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            b"hello",
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "did not send a kvsep response",
+            ),
+            id="bad dummy response",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            b"\x01\x01",
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "did not send a kvsep response",
+            ),
+            id="multiple kvseps",
+        ),
+        pytest.param(
+            pq3.types.Query,
+            dict(query=b""),
+            ExpectedError(PROTOCOL_VIOLATION_ERRCODE, "expected SASL response"),
+            id="bad response message type",
+        ),
+    ],
+)
+def test_oauth_bad_response_to_error_challenge(conn, oauth_ctx, resp_type, resp, err):
+    begin_oauth_handshake(conn, oauth_ctx)
+
+    # Send an empty auth initial response, which will force an authn failure.
+    send_initial_response(conn, auth=b"")
+
+    # We expect a discovery "challenge" back from the server before the authn
+    # failure message.
+    pkt = pq3.recv1(conn)
+    assert pkt.type == pq3.types.AuthnRequest
+
+    req = pkt.payload
+    assert req.type == pq3.authn.SASLContinue
+
+    body = json.loads(req.body)
+    assert body["status"] == "invalid_token"
+
+    if resp_type is None:
+        # Do not send the dummy response. We should time out and not get a
+        # response from the server.
+        with pytest.raises(socket.timeout):
+            conn.read(1)
+
+        # Done with the test.
+        return
+
+    # Send the bad response.
+    pq3.send(conn, resp_type, resp)
+
+    # Make sure the server fails the connection correctly.
+    pkt = pq3.recv1(conn)
+    err.match(pkt)
+
+
[email protected](
+    "type,payload,err",
+    [
+        pytest.param(
+            pq3.types.ErrorResponse,
+            dict(fields=[b""]),
+            ExpectedError(PROTOCOL_VIOLATION_ERRCODE, "expected SASL response"),
+            id="error response in initial message",
+        ),
+        pytest.param(
+            None,
+            # Sending an actual 65k packet results in ECONNRESET on Windows, and
+            # it floods the tests' connection log uselessly, so just fake the
+            # length and send a smaller number of bytes.
+            dict(
+                type=pq3.types.PasswordMessage,
+                len=MAX_SASL_MESSAGE_LENGTH + 1,
+                payload=b"x" * 512,
+            ),
+            ExpectedError(
+                INVALID_AUTHORIZATION_ERRCODE, "bearer authentication failed"
+            ),
+            id="overlong initial response data",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"SCRAM-SHA-256")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE, "invalid SASL authentication mechanism"
+            ),
+            id="bad SASL mechanism selection",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", len=2, data=b"x")),
+            ExpectedError(PROTOCOL_VIOLATION_ERRCODE, "insufficient data"),
+            id="SASL data underflow",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", len=0, data=b"x")),
+            ExpectedError(PROTOCOL_VIOLATION_ERRCODE, "invalid message format"),
+            id="SASL data overflow",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "message is empty",
+            ),
+            id="empty",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(name=b"OAUTHBEARER", data=b"n,,\x01auth=\x01\x01\0")
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "length does not match input length",
+            ),
+            id="contains null byte",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"\x01")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "Unexpected channel-binding flag",  # XXX this is a bit strange
+            ),
+            id="initial error response",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(name=b"OAUTHBEARER", data=b"p=tls-server-end-point,,\x01")
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "server does not support channel binding",
+            ),
+            id="uses channel binding",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"x,,\x01")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "Unexpected channel-binding flag",
+            ),
+            id="invalid channel binding specifier",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"y")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "Comma expected",
+            ),
+            id="bad GS2 header: missing channel binding terminator",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"y,a")),
+            ExpectedError(
+                FEATURE_NOT_SUPPORTED_ERRCODE,
+                "client uses authorization identity",
+            ),
+            id="bad GS2 header: authzid in use",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"y,b,")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "Unexpected attribute",
+            ),
+            id="bad GS2 header: extra attribute",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"y,")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                'Unexpected attribute "0x00"',  # XXX this is a bit strange
+            ),
+            id="bad GS2 header: missing authzid terminator",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"y,,")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "Key-value separator expected",
+            ),
+            id="missing initial kvsep",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"y,,")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "Key-value separator expected",
+            ),
+            id="missing initial kvsep",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(name=b"OAUTHBEARER", data=b"y,,\x01\x01")
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "does not contain an auth value",
+            ),
+            id="missing auth value: empty key-value list",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(name=b"OAUTHBEARER", data=b"y,,\x01host=example.com\x01\x01")
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "does not contain an auth value",
+            ),
+            id="missing auth value: other keys present",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(name=b"OAUTHBEARER", data=b"y,,\x01host=example.com")
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "unterminated key/value pair",
+            ),
+            id="missing value terminator",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"y,,\x01")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "did not contain a final terminator",
+            ),
+            id="missing list terminator: empty list",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(name=b"OAUTHBEARER", data=b"y,,\x01auth=Bearer 0\x01")
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "did not contain a final terminator",
+            ),
+            id="missing list terminator: with auth value",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(name=b"OAUTHBEARER", data=b"y,,\x01auth=Bearer 0\x01\x01blah")
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "additional data after the final terminator",
+            ),
+            id="additional key after terminator",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(name=b"OAUTHBEARER", data=b"y,,\x01key\x01\x01")
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "key without a value",
+            ),
+            id="key without value",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(
+                    name=b"OAUTHBEARER",
+                    data=b"y,,\x01auth=Bearer 0\x01auth=Bearer 1\x01\x01",
+                )
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "contains multiple auth values",
+            ),
+            id="multiple auth values",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(
+                    name=b"OAUTHBEARER",
+                    data=b"y,,\x01=\x01\x01",
+                )
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "empty key name",
+            ),
+            id="empty key",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(
+                    name=b"OAUTHBEARER",
+                    data=b"y,,\x01my key= \x01\x01",
+                )
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "invalid key name",
+            ),
+            id="whitespace in key name",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(
+                    name=b"OAUTHBEARER",
+                    data=b"y,,\x01key=a\x05b\x01\x01",
+                )
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "invalid value",
+            ),
+            id="junk in value",
+        ),
+    ],
+)
+def test_oauth_bad_initial_response(conn, oauth_ctx, type, payload, err):
+    begin_oauth_handshake(conn, oauth_ctx)
+
+    # The server expects a SASL response; give it something else instead.
+    if type is not None:
+        # Build a new packet of the desired type.
+        if not isinstance(payload, dict):
+            payload = dict(payload_data=payload)
+        pq3.send(conn, type, **payload)
+    else:
+        # The test has a custom packet to send. (The only reason to do this is
+        # if the packet is corrupt or otherwise unbuildable/unparsable, so we
+        # don't use the standard pq3.send().)
+        conn.write(pq3.Pq3.build(payload))
+        conn.end_packet(Container(payload))
+
+    resp = pq3.recv1(conn)
+    err.match(resp)
+
+
+def test_oauth_empty_initial_response(setup_validator, connect, oauth_ctx):
+    token = bearer_token()
+    setup_validator(expected_bearer=token)
+
+    conn = connect()
+    begin_oauth_handshake(conn, oauth_ctx)
+
+    # Send an initial response without data.
+    initial = pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER"))
+    pq3.send(conn, pq3.types.PasswordMessage, initial)
+
+    # The server should respond with an empty challenge so we can send the data
+    # it wants.
+    pkt = pq3.recv1(conn)
+
+    assert pkt.type == pq3.types.AuthnRequest
+    assert pkt.payload.type == pq3.authn.SASLContinue
+    assert not pkt.payload.body
+
+    # Now send the initial data.
+    data = b"n,,\x01auth=Bearer " + token.encode("ascii") + b"\x01\x01"
+    pq3.send(conn, pq3.types.PasswordMessage, data)
+
+    # Server should now complete the handshake.
+    expect_handshake_success(conn)
+
+
+# TODO: see if there's a way to test this easily after the API switch
+def xtest_oauth_no_validator(setup_validator, oauth_ctx, connect):
+    # Clear out our validator command, then establish a new connection.
+    set_validator("")
+    conn = connect()
+
+    begin_oauth_handshake(conn, oauth_ctx)
+    send_initial_response(conn, bearer=bearer_token())
+
+    # The server should fail the connection.
+    expect_handshake_failure(conn, oauth_ctx)
+
+
[email protected](
+    "user",
+    [
+        pytest.param(
+            lambda ctx: ctx.user,
+            id="basic username",
+        ),
+        pytest.param(
+            lambda ctx: ctx.punct_user,
+            id="'unsafe' characters are passed through correctly",
+        ),
+    ],
+)
+def test_oauth_validator_role(setup_validator, oauth_ctx, connect, user):
+    username = user(oauth_ctx)
+
+    # Tell the validator to reflect the PGUSER as the authenticated identity.
+    setup_validator(reflect_role=True)
+    conn = connect()
+
+    # Log in. Note that reflection ignores the bearer token.
+    begin_oauth_handshake(conn, oauth_ctx, user=username)
+    send_initial_response(conn, bearer=b"dontcare")
+    expect_handshake_success(conn)
+
+    # Check the user identity.
+    pq3.send(conn, pq3.types.Query, query=b"SELECT system_user;")
+    resp = receive_until(conn, pq3.types.DataRow)
+
+    row = resp.payload
+    expected = b"oauth:" + username.encode("utf-8")
+    assert row.columns == [expected]
+
+
[email protected]
+def odd_oauth_ctx(postgres_instance, oauth_ctx):
+    """
+    Adds an HBA entry with messed up issuer/scope settings, to pin the server
+    behavior.
+
+    TODO: these should really be rejected in the HBA rather than passed through
+    by the server.
+    """
+    id = secrets.token_hex(4)
+
+    class Context:
+        user = oauth_ctx.user
+        dbname = oauth_ctx.dbname
+
+        # Both of these embedded double-quotes are invalid; they're prohibited
+        # in both URLs and OAuth scope identifiers.
+        issuer = oauth_ctx.issuer + '/"/'
+        scope = oauth_ctx.scope + ' quo"ted'
+
+    ctx = Context()
+    hba_issuer = ctx.issuer.replace('"', '""')
+    hba_scope = ctx.scope.replace('"', '""')
+    hba_lines = [
+        f'host {ctx.dbname} {ctx.user} samehost oauth issuer="{hba_issuer}" scope="{hba_scope}"\n',
+    ]
+
+    if platform.system() == "Windows":
+        # XXX why is 'samehost' not behaving as expected on Windows?
+        for l in list(hba_lines):
+            hba_lines.append(l.replace("samehost", "::1/128"))
+
+    host, port = postgres_instance
+    conn = psycopg2.connect(host=host, port=port)
+    conn.autocommit = True
+
+    with contextlib.closing(conn):
+        c = conn.cursor()
+
+        # Replace pg_hba. Note that it's already been replaced once by
+        # oauth_ctx, so use a different backup prefix in prepend_file().
+        c.execute("SHOW hba_file;")
+        hba = c.fetchone()[0]
+
+        with prepend_file(hba, hba_lines, suffix=".bak2"):
+            c.execute("SELECT pg_reload_conf();")
+
+            yield ctx
+
+        # Put things back the way they were.
+        c.execute("SELECT pg_reload_conf();")
+
+
+def test_odd_server_response(odd_oauth_ctx, connect):
+    """
+    Verifies that the server is correctly escaping the JSON in its failure
+    response.
+    """
+    conn = connect()
+    begin_oauth_handshake(conn, odd_oauth_ctx, user=odd_oauth_ctx.user)
+
+    # Send an empty auth initial response, which will force an authn failure.
+    send_initial_response(conn, auth=b"")
+
+    expect_handshake_failure(conn, odd_oauth_ctx)
diff --git a/src/test/python/server/test_server.py b/src/test/python/server/test_server.py
new file mode 100644
index 00000000000..02126dba792
--- /dev/null
+++ b/src/test/python/server/test_server.py
@@ -0,0 +1,21 @@
+#
+# Copyright 2021 VMware, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import pq3
+
+
+def test_handshake(connect):
+    """Basic sanity check."""
+    conn = connect()
+
+    pq3.handshake(conn, user=pq3.pguser(), database=pq3.pgdatabase())
+
+    pq3.send(conn, pq3.types.Query, query=b"")
+
+    resp = pq3.recv1(conn)
+    assert resp.type == pq3.types.EmptyQueryResponse
+
+    resp = pq3.recv1(conn)
+    assert resp.type == pq3.types.ReadyForQuery
diff --git a/src/test/python/test_internals.py b/src/test/python/test_internals.py
new file mode 100644
index 00000000000..dee4855fc0b
--- /dev/null
+++ b/src/test/python/test_internals.py
@@ -0,0 +1,138 @@
+#
+# Copyright 2021 VMware, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import io
+
+from pq3 import _DebugStream
+
+
+def test_DebugStream_read():
+    under = io.BytesIO(b"abcdefghijklmnopqrstuvwxyz")
+    out = io.StringIO()
+
+    stream = _DebugStream(under, out)
+
+    res = stream.read(5)
+    assert res == b"abcde"
+
+    res = stream.read(16)
+    assert res == b"fghijklmnopqrstu"
+
+    stream.flush_debug()
+
+    res = stream.read()
+    assert res == b"vwxyz"
+
+    stream.flush_debug()
+
+    expected = (
+        "< 0000:\t61 62 63 64 65 66 67 68 69 6a 6b 6c 6d 6e 6f 70\tabcdefghijklmnop\n"
+        "< 0010:\t71 72 73 74 75                                 \tqrstu\n"
+        "\n"
+        "< 0000:\t76 77 78 79 7a                                 \tvwxyz\n"
+        "\n"
+    )
+    assert out.getvalue() == expected
+
+
+def test_DebugStream_write():
+    under = io.BytesIO()
+    out = io.StringIO()
+
+    stream = _DebugStream(under, out)
+
+    stream.write(b"\x00\x01\x02")
+    stream.flush()
+
+    assert under.getvalue() == b"\x00\x01\x02"
+
+    stream.write(b"\xc0\xc1\xc2")
+    stream.flush()
+
+    assert under.getvalue() == b"\x00\x01\x02\xc0\xc1\xc2"
+
+    stream.flush_debug()
+
+    expected = "> 0000:\t00 01 02 c0 c1 c2                              \t......\n\n"
+    assert out.getvalue() == expected
+
+
+def test_DebugStream_read_write():
+    under = io.BytesIO(b"abcdefghijklmnopqrstuvwxyz")
+    out = io.StringIO()
+    stream = _DebugStream(under, out)
+
+    res = stream.read(5)
+    assert res == b"abcde"
+
+    stream.write(b"xxxxx")
+    stream.flush()
+
+    assert under.getvalue() == b"abcdexxxxxklmnopqrstuvwxyz"
+
+    res = stream.read(5)
+    assert res == b"klmno"
+
+    stream.write(b"xxxxx")
+    stream.flush()
+
+    assert under.getvalue() == b"abcdexxxxxklmnoxxxxxuvwxyz"
+
+    stream.flush_debug()
+
+    expected = (
+        "< 0000:\t61 62 63 64 65 6b 6c 6d 6e 6f                  \tabcdeklmno\n"
+        "\n"
+        "> 0000:\t78 78 78 78 78 78 78 78 78 78                  \txxxxxxxxxx\n"
+        "\n"
+    )
+    assert out.getvalue() == expected
+
+
+def test_DebugStream_end_packet():
+    under = io.BytesIO(b"abcdefghijklmnopqrstuvwxyz")
+    out = io.StringIO()
+    stream = _DebugStream(under, out)
+
+    stream.read(5)
+    stream.end_packet("read description", read=True, indent=" ")
+
+    stream.write(b"xxxxx")
+    stream.flush()
+    stream.end_packet("write description", indent=" ")
+
+    stream.read(5)
+    stream.write(b"xxxxx")
+    stream.flush()
+    stream.end_packet("read/write combo for read", read=True, indent=" ")
+
+    stream.read(5)
+    stream.write(b"xxxxx")
+    stream.flush()
+    stream.end_packet("read/write combo for write", indent=" ")
+
+    expected = (
+        " < 0000:\t61 62 63 64 65                                 \tabcde\n"
+        "\n"
+        "< read description\n"
+        "\n"
+        "> write description\n"
+        "\n"
+        " > 0000:\t78 78 78 78 78                                 \txxxxx\n"
+        "\n"
+        " < 0000:\t6b 6c 6d 6e 6f                                 \tklmno\n"
+        "\n"
+        " > 0000:\t78 78 78 78 78                                 \txxxxx\n"
+        "\n"
+        "< read/write combo for read\n"
+        "\n"
+        "> read/write combo for write\n"
+        "\n"
+        " < 0000:\t75 76 77 78 79                                 \tuvwxy\n"
+        "\n"
+        " > 0000:\t78 78 78 78 78                                 \txxxxx\n"
+        "\n"
+    )
+    assert out.getvalue() == expected
diff --git a/src/test/python/test_pq3.py b/src/test/python/test_pq3.py
new file mode 100644
index 00000000000..7c6817de31c
--- /dev/null
+++ b/src/test/python/test_pq3.py
@@ -0,0 +1,574 @@
+#
+# Copyright 2021 VMware, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import contextlib
+import getpass
+import io
+import os
+import platform
+import struct
+import sys
+
+import pytest
+from construct import Container, PaddingError, StreamError, TerminatedError
+
+import pq3
+
+
[email protected](
+    "raw,expected,extra",
+    [
+        pytest.param(
+            b"\x00\x00\x00\x10\x00\x04\x00\x00abcdefgh",
+            Container(len=16, proto=0x40000, payload=b"abcdefgh"),
+            b"",
+            id="8-byte payload",
+        ),
+        pytest.param(
+            b"\x00\x00\x00\x08\x00\x04\x00\x00",
+            Container(len=8, proto=0x40000, payload=b""),
+            b"",
+            id="no payload",
+        ),
+        pytest.param(
+            b"\x00\x00\x00\x09\x00\x04\x00\x00abcde",
+            Container(len=9, proto=0x40000, payload=b"a"),
+            b"bcde",
+            id="1-byte payload and extra padding",
+        ),
+        pytest.param(
+            b"\x00\x00\x00\x0B\x00\x03\x00\x00hi\x00",
+            Container(len=11, proto=pq3.protocol(3, 0), payload=[b"hi"]),
+            b"",
+            id="implied parameter list when using proto version 3.0",
+        ),
+    ],
+)
+def test_Startup_parse(raw, expected, extra):
+    with io.BytesIO(raw) as stream:
+        actual = pq3.Startup.parse_stream(stream)
+
+        assert actual == expected
+        assert stream.read() == extra
+
+
[email protected](
+    "packet,expected_bytes",
+    [
+        pytest.param(
+            dict(),
+            b"\x00\x00\x00\x08\x00\x00\x00\x00",
+            id="nothing set",
+        ),
+        pytest.param(
+            dict(len=10, proto=0x12345678),
+            b"\x00\x00\x00\x0A\x12\x34\x56\x78\x00\x00",
+            id="len and proto set explicitly",
+        ),
+        pytest.param(
+            dict(proto=0x12345678),
+            b"\x00\x00\x00\x08\x12\x34\x56\x78",
+            id="implied len with no payload",
+        ),
+        pytest.param(
+            dict(proto=0x12345678, payload=b"abcd"),
+            b"\x00\x00\x00\x0C\x12\x34\x56\x78abcd",
+            id="implied len with payload",
+        ),
+        pytest.param(
+            dict(payload=[b""]),
+            b"\x00\x00\x00\x09\x00\x03\x00\x00\x00",
+            id="implied proto version 3 when sending parameters",
+        ),
+        pytest.param(
+            dict(payload=[b"hi", b""]),
+            b"\x00\x00\x00\x0C\x00\x03\x00\x00hi\x00\x00",
+            id="implied proto version 3 and len when sending more than one parameter",
+        ),
+        pytest.param(
+            dict(payload=dict(user="jsmith", database="postgres")),
+            b"\x00\x00\x00\x27\x00\x03\x00\x00user\x00jsmith\x00database\x00postgres\x00\x00",
+            id="auto-serialization of dict parameters",
+        ),
+    ],
+)
+def test_Startup_build(packet, expected_bytes):
+    actual = pq3.Startup.build(packet)
+    assert actual == expected_bytes
+
+
[email protected](
+    "raw,expected,extra",
+    [
+        pytest.param(
+            b"*\x00\x00\x00\x08abcd",
+            dict(type=b"*", len=8, payload=b"abcd"),
+            b"",
+            id="4-byte payload",
+        ),
+        pytest.param(
+            b"*\x00\x00\x00\x04",
+            dict(type=b"*", len=4, payload=b""),
+            b"",
+            id="no payload",
+        ),
+        pytest.param(
+            b"*\x00\x00\x00\x05xabcd",
+            dict(type=b"*", len=5, payload=b"x"),
+            b"abcd",
+            id="1-byte payload with extra padding",
+        ),
+        pytest.param(
+            b"R\x00\x00\x00\x08\x00\x00\x00\x00",
+            dict(
+                type=pq3.types.AuthnRequest,
+                len=8,
+                payload=dict(type=pq3.authn.OK, body=None),
+            ),
+            b"",
+            id="AuthenticationOk",
+        ),
+        pytest.param(
+            b"R\x00\x00\x00\x12\x00\x00\x00\x0AEXTERNAL\x00\x00",
+            dict(
+                type=pq3.types.AuthnRequest,
+                len=18,
+                payload=dict(type=pq3.authn.SASL, body=[b"EXTERNAL", b""]),
+            ),
+            b"",
+            id="AuthenticationSASL",
+        ),
+        pytest.param(
+            b"R\x00\x00\x00\x0D\x00\x00\x00\x0B12345",
+            dict(
+                type=pq3.types.AuthnRequest,
+                len=13,
+                payload=dict(type=pq3.authn.SASLContinue, body=b"12345"),
+            ),
+            b"",
+            id="AuthenticationSASLContinue",
+        ),
+        pytest.param(
+            b"R\x00\x00\x00\x0D\x00\x00\x00\x0C12345",
+            dict(
+                type=pq3.types.AuthnRequest,
+                len=13,
+                payload=dict(type=pq3.authn.SASLFinal, body=b"12345"),
+            ),
+            b"",
+            id="AuthenticationSASLFinal",
+        ),
+        pytest.param(
+            b"p\x00\x00\x00\x0Bhunter2",
+            dict(
+                type=pq3.types.PasswordMessage,
+                len=11,
+                payload=b"hunter2",
+            ),
+            b"",
+            id="PasswordMessage",
+        ),
+        pytest.param(
+            b"K\x00\x00\x00\x0C\x00\x00\x00\x00\x12\x34\x56\x78",
+            dict(
+                type=pq3.types.BackendKeyData,
+                len=12,
+                payload=dict(pid=0, key=0x12345678),
+            ),
+            b"",
+            id="BackendKeyData",
+        ),
+        pytest.param(
+            b"C\x00\x00\x00\x08SET\x00",
+            dict(
+                type=pq3.types.CommandComplete,
+                len=8,
+                payload=dict(tag=b"SET"),
+            ),
+            b"",
+            id="CommandComplete",
+        ),
+        pytest.param(
+            b"E\x00\x00\x00\x11Mbad!\x00Mdog!\x00\x00",
+            dict(type=b"E", len=17, payload=dict(fields=[b"Mbad!", b"Mdog!", b""])),
+            b"",
+            id="ErrorResponse",
+        ),
+        pytest.param(
+            b"S\x00\x00\x00\x08a\x00b\x00",
+            dict(
+                type=pq3.types.ParameterStatus,
+                len=8,
+                payload=dict(name=b"a", value=b"b"),
+            ),
+            b"",
+            id="ParameterStatus",
+        ),
+        pytest.param(
+            b"Z\x00\x00\x00\x05x",
+            dict(type=b"Z", len=5, payload=dict(status=b"x")),
+            b"",
+            id="ReadyForQuery",
+        ),
+        pytest.param(
+            b"Q\x00\x00\x00\x06!\x00",
+            dict(type=pq3.types.Query, len=6, payload=dict(query=b"!")),
+            b"",
+            id="Query",
+        ),
+        pytest.param(
+            b"D\x00\x00\x00\x0B\x00\x01\x00\x00\x00\x01!",
+            dict(type=pq3.types.DataRow, len=11, payload=dict(columns=[b"!"])),
+            b"",
+            id="DataRow",
+        ),
+        pytest.param(
+            b"D\x00\x00\x00\x06\x00\x00extra",
+            dict(type=pq3.types.DataRow, len=6, payload=dict(columns=[])),
+            b"extra",
+            id="DataRow with extra data",
+        ),
+        pytest.param(
+            b"I\x00\x00\x00\x04",
+            dict(type=pq3.types.EmptyQueryResponse, len=4, payload=None),
+            b"",
+            id="EmptyQueryResponse",
+        ),
+        pytest.param(
+            b"I\x00\x00\x00\x04\xFF",
+            dict(type=b"I", len=4, payload=None),
+            b"\xFF",
+            id="EmptyQueryResponse with extra bytes",
+        ),
+        pytest.param(
+            b"X\x00\x00\x00\x04",
+            dict(type=pq3.types.Terminate, len=4, payload=None),
+            b"",
+            id="Terminate",
+        ),
+    ],
+)
+def test_Pq3_parse(raw, expected, extra):
+    with io.BytesIO(raw) as stream:
+        actual = pq3.Pq3.parse_stream(stream)
+
+        assert actual == expected
+        assert stream.read() == extra
+
+
[email protected](
+    "fields,expected",
+    [
+        pytest.param(
+            dict(type=b"*", len=5),
+            b"*\x00\x00\x00\x05",
+            id="type and len set explicitly",
+        ),
+        pytest.param(
+            dict(type=b"*"),
+            b"*\x00\x00\x00\x04",
+            id="implied len with no payload",
+        ),
+        pytest.param(
+            dict(type=b"*", payload=b"1234"),
+            b"*\x00\x00\x00\x081234",
+            id="implied len with payload",
+        ),
+        pytest.param(
+            dict(type=b"*", len=12, payload=b"1234"),
+            b"*\x00\x00\x00\x0C1234",
+            id="overridden len (payload underflow)",
+        ),
+        pytest.param(
+            dict(type=b"*", len=5, payload=b"1234"),
+            b"*\x00\x00\x00\x051234",
+            id="overridden len (payload overflow)",
+        ),
+        pytest.param(
+            dict(type=pq3.types.AuthnRequest, payload=dict(type=pq3.authn.OK)),
+            b"R\x00\x00\x00\x08\x00\x00\x00\x00",
+            id="implied len/type for AuthenticationOK",
+        ),
+        pytest.param(
+            dict(
+                type=pq3.types.AuthnRequest,
+                payload=dict(
+                    type=pq3.authn.SASL,
+                    body=[b"SCRAM-SHA-256-PLUS", b"SCRAM-SHA-256", b""],
+                ),
+            ),
+            b"R\x00\x00\x00\x2A\x00\x00\x00\x0ASCRAM-SHA-256-PLUS\x00SCRAM-SHA-256\x00\x00",
+            id="implied len/type for AuthenticationSASL",
+        ),
+        pytest.param(
+            dict(
+                type=pq3.types.AuthnRequest,
+                payload=dict(type=pq3.authn.SASLContinue, body=b"12345"),
+            ),
+            b"R\x00\x00\x00\x0D\x00\x00\x00\x0B12345",
+            id="implied len/type for AuthenticationSASLContinue",
+        ),
+        pytest.param(
+            dict(
+                type=pq3.types.AuthnRequest,
+                payload=dict(type=pq3.authn.SASLFinal, body=b"12345"),
+            ),
+            b"R\x00\x00\x00\x0D\x00\x00\x00\x0C12345",
+            id="implied len/type for AuthenticationSASLFinal",
+        ),
+        pytest.param(
+            dict(
+                type=pq3.types.PasswordMessage,
+                payload=b"hunter2",
+            ),
+            b"p\x00\x00\x00\x0Bhunter2",
+            id="implied len/type for PasswordMessage",
+        ),
+        pytest.param(
+            dict(type=pq3.types.BackendKeyData, payload=dict(pid=1, key=7)),
+            b"K\x00\x00\x00\x0C\x00\x00\x00\x01\x00\x00\x00\x07",
+            id="implied len/type for BackendKeyData",
+        ),
+        pytest.param(
+            dict(type=pq3.types.CommandComplete, payload=dict(tag=b"SET")),
+            b"C\x00\x00\x00\x08SET\x00",
+            id="implied len/type for CommandComplete",
+        ),
+        pytest.param(
+            dict(type=pq3.types.ErrorResponse, payload=dict(fields=[b"error", b""])),
+            b"E\x00\x00\x00\x0Berror\x00\x00",
+            id="implied len/type for ErrorResponse",
+        ),
+        pytest.param(
+            dict(type=pq3.types.ParameterStatus, payload=dict(name=b"a", value=b"b")),
+            b"S\x00\x00\x00\x08a\x00b\x00",
+            id="implied len/type for ParameterStatus",
+        ),
+        pytest.param(
+            dict(type=pq3.types.ReadyForQuery, payload=dict(status=b"I")),
+            b"Z\x00\x00\x00\x05I",
+            id="implied len/type for ReadyForQuery",
+        ),
+        pytest.param(
+            dict(type=pq3.types.Query, payload=dict(query=b"SELECT 1;")),
+            b"Q\x00\x00\x00\x0eSELECT 1;\x00",
+            id="implied len/type for Query",
+        ),
+        pytest.param(
+            dict(type=pq3.types.DataRow, payload=dict(columns=[b"abcd"])),
+            b"D\x00\x00\x00\x0E\x00\x01\x00\x00\x00\x04abcd",
+            id="implied len/type for DataRow",
+        ),
+        pytest.param(
+            dict(type=pq3.types.EmptyQueryResponse),
+            b"I\x00\x00\x00\x04",
+            id="implied len for EmptyQueryResponse",
+        ),
+        pytest.param(
+            dict(type=pq3.types.Terminate),
+            b"X\x00\x00\x00\x04",
+            id="implied len for Terminate",
+        ),
+    ],
+)
+def test_Pq3_build(fields, expected):
+    actual = pq3.Pq3.build(fields)
+    assert actual == expected
+
+
[email protected](
+    "raw,expected,extra",
+    [
+        pytest.param(
+            b"\x00\x00",
+            dict(columns=[]),
+            b"",
+            id="no columns",
+        ),
+        pytest.param(
+            b"\x00\x01\x00\x00\x00\x04abcd",
+            dict(columns=[b"abcd"]),
+            b"",
+            id="one column",
+        ),
+        pytest.param(
+            b"\x00\x02\x00\x00\x00\x04abcd\x00\x00\x00\x01x",
+            dict(columns=[b"abcd", b"x"]),
+            b"",
+            id="multiple columns",
+        ),
+        pytest.param(
+            b"\x00\x02\x00\x00\x00\x00\x00\x00\x00\x01x",
+            dict(columns=[b"", b"x"]),
+            b"",
+            id="empty column value",
+        ),
+        pytest.param(
+            b"\x00\x02\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF",
+            dict(columns=[None, None]),
+            b"",
+            id="null columns",
+        ),
+    ],
+)
+def test_DataRow_parse(raw, expected, extra):
+    pkt = b"D" + struct.pack("!i", len(raw) + 4) + raw
+    with io.BytesIO(pkt) as stream:
+        actual = pq3.Pq3.parse_stream(stream)
+
+        assert actual.type == pq3.types.DataRow
+        assert actual.payload == expected
+        assert stream.read() == extra
+
+
[email protected](
+    "fields,expected",
+    [
+        pytest.param(
+            dict(),
+            b"\x00\x00",
+            id="no columns",
+        ),
+        pytest.param(
+            dict(columns=[None, None]),
+            b"\x00\x02\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF",
+            id="null columns",
+        ),
+    ],
+)
+def test_DataRow_build(fields, expected):
+    actual = pq3.Pq3.build(dict(type=pq3.types.DataRow, payload=fields))
+
+    expected = b"D" + struct.pack("!i", len(expected) + 4) + expected
+    assert actual == expected
+
+
[email protected](
+    "raw,expected,exception",
+    [
+        pytest.param(
+            b"EXTERNAL\x00\xFF\xFF\xFF\xFF",
+            dict(name=b"EXTERNAL", len=-1, data=None),
+            None,
+            id="no initial response",
+        ),
+        pytest.param(
+            b"EXTERNAL\x00\x00\x00\x00\x02me",
+            dict(name=b"EXTERNAL", len=2, data=b"me"),
+            None,
+            id="initial response",
+        ),
+        pytest.param(
+            b"EXTERNAL\x00\x00\x00\x00\x02meextra",
+            None,
+            TerminatedError,
+            id="extra data",
+        ),
+        pytest.param(
+            b"EXTERNAL\x00\x00\x00\x00\xFFme",
+            None,
+            StreamError,
+            id="underflow",
+        ),
+    ],
+)
+def test_SASLInitialResponse_parse(raw, expected, exception):
+    ctx = contextlib.nullcontext()
+    if exception:
+        ctx = pytest.raises(exception)
+
+    with ctx:
+        actual = pq3.SASLInitialResponse.parse(raw)
+        assert actual == expected
+
+
[email protected](
+    "fields,expected",
+    [
+        pytest.param(
+            dict(name=b"EXTERNAL"),
+            b"EXTERNAL\x00\xFF\xFF\xFF\xFF",
+            id="no initial response",
+        ),
+        pytest.param(
+            dict(name=b"EXTERNAL", data=None),
+            b"EXTERNAL\x00\xFF\xFF\xFF\xFF",
+            id="no initial response (explicit None)",
+        ),
+        pytest.param(
+            dict(name=b"EXTERNAL", data=b""),
+            b"EXTERNAL\x00\x00\x00\x00\x00",
+            id="empty response",
+        ),
+        pytest.param(
+            dict(name=b"EXTERNAL", data=b"[email protected]"),
+            b"EXTERNAL\x00\x00\x00\x00\[email protected]",
+            id="initial response",
+        ),
+        pytest.param(
+            dict(name=b"EXTERNAL", len=2, data=b"[email protected]"),
+            b"EXTERNAL\x00\x00\x00\x00\[email protected]",
+            id="data overflow",
+        ),
+        pytest.param(
+            dict(name=b"EXTERNAL", len=14, data=b"me"),
+            b"EXTERNAL\x00\x00\x00\x00\x0Eme",
+            id="data underflow",
+        ),
+    ],
+)
+def test_SASLInitialResponse_build(fields, expected):
+    actual = pq3.SASLInitialResponse.build(fields)
+    assert actual == expected
+
+
[email protected](
+    "version,expected_bytes",
+    [
+        pytest.param((3, 0), b"\x00\x03\x00\x00", id="version 3"),
+        pytest.param((1234, 5679), b"\x04\xd2\x16\x2f", id="SSLRequest"),
+    ],
+)
+def test_protocol(version, expected_bytes):
+    # Make sure the integer returned by protocol is correctly serialized on the
+    # wire.
+    assert struct.pack("!i", pq3.protocol(*version)) == expected_bytes
+
+
[email protected](
+    "envvar,func,expected",
+    [
+        ("PGHOST", pq3.pghost, "localhost"),
+        ("PGPORT", pq3.pgport, 5432),
+        (
+            "PGUSER",
+            pq3.pguser,
+            os.getlogin() if platform.system() == "Windows" else getpass.getuser(),
+        ),
+        ("PGDATABASE", pq3.pgdatabase, "postgres"),
+    ],
+)
+def test_env_defaults(monkeypatch, envvar, func, expected):
+    monkeypatch.delenv(envvar, raising=False)
+
+    actual = func()
+    assert actual == expected
+
+
[email protected](
+    "envvars,func,expected",
+    [
+        (dict(PGHOST="otherhost"), pq3.pghost, "otherhost"),
+        (dict(PGPORT="6789"), pq3.pgport, 6789),
+        (dict(PGUSER="postgres"), pq3.pguser, "postgres"),
+        (dict(PGDATABASE="template1"), pq3.pgdatabase, "template1"),
+    ],
+)
+def test_env(monkeypatch, envvars, func, expected):
+    for k, v in envvars.items():
+        monkeypatch.setenv(k, v)
+
+    actual = func()
+    assert actual == expected
diff --git a/src/test/python/tls.py b/src/test/python/tls.py
new file mode 100644
index 00000000000..075c02c1ca6
--- /dev/null
+++ b/src/test/python/tls.py
@@ -0,0 +1,195 @@
+#
+# Copyright 2021 VMware, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+from construct import *
+
+#
+# TLS 1.3
+#
+# Most of the types below are transcribed from RFC 8446:
+#
+#     https://tools.ietf.org/html/rfc8446
+#
+
+
+def _Vector(size_field, element):
+    return Prefixed(size_field, GreedyRange(element))
+
+
+# Alerts
+
+AlertLevel = Enum(
+    Byte,
+    warning=1,
+    fatal=2,
+)
+
+AlertDescription = Enum(
+    Byte,
+    close_notify=0,
+    unexpected_message=10,
+    bad_record_mac=20,
+    decryption_failed_RESERVED=21,
+    record_overflow=22,
+    decompression_failure=30,
+    handshake_failure=40,
+    no_certificate_RESERVED=41,
+    bad_certificate=42,
+    unsupported_certificate=43,
+    certificate_revoked=44,
+    certificate_expired=45,
+    certificate_unknown=46,
+    illegal_parameter=47,
+    unknown_ca=48,
+    access_denied=49,
+    decode_error=50,
+    decrypt_error=51,
+    export_restriction_RESERVED=60,
+    protocol_version=70,
+    insufficient_security=71,
+    internal_error=80,
+    user_canceled=90,
+    no_renegotiation=100,
+    unsupported_extension=110,
+)
+
+Alert = Struct(
+    "level" / AlertLevel,
+    "description" / AlertDescription,
+)
+
+
+# Extensions
+
+ExtensionType = Enum(
+    Int16ub,
+    server_name=0,
+    max_fragment_length=1,
+    status_request=5,
+    supported_groups=10,
+    signature_algorithms=13,
+    use_srtp=14,
+    heartbeat=15,
+    application_layer_protocol_negotiation=16,
+    signed_certificate_timestamp=18,
+    client_certificate_type=19,
+    server_certificate_type=20,
+    padding=21,
+    pre_shared_key=41,
+    early_data=42,
+    supported_versions=43,
+    cookie=44,
+    psk_key_exchange_modes=45,
+    certificate_authorities=47,
+    oid_filters=48,
+    post_handshake_auth=49,
+    signature_algorithms_cert=50,
+    key_share=51,
+)
+
+Extension = Struct(
+    "extension_type" / ExtensionType,
+    "extension_data" / Prefixed(Int16ub, GreedyBytes),
+)
+
+
+# ClientHello
+
+
+class _CipherSuiteAdapter(Adapter):
+    class _hextuple(tuple):
+        def __repr__(self):
+            return f"(0x{self[0]:02X}, 0x{self[1]:02X})"
+
+    def _encode(self, obj, context, path):
+        return bytes(obj)
+
+    def _decode(self, obj, context, path):
+        assert len(obj) == 2
+        return self._hextuple(obj)
+
+
+ProtocolVersion = Hex(Int16ub)
+
+Random = Hex(Bytes(32))
+
+CipherSuite = _CipherSuiteAdapter(Byte[2])
+
+ClientHello = Struct(
+    "legacy_version" / ProtocolVersion,
+    "random" / Random,
+    "legacy_session_id" / Prefixed(Byte, Hex(GreedyBytes)),
+    "cipher_suites" / _Vector(Int16ub, CipherSuite),
+    "legacy_compression_methods" / Prefixed(Byte, GreedyBytes),
+    "extensions" / _Vector(Int16ub, Extension),
+)
+
+# ServerHello
+
+ServerHello = Struct(
+    "legacy_version" / ProtocolVersion,
+    "random" / Random,
+    "legacy_session_id_echo" / Prefixed(Byte, Hex(GreedyBytes)),
+    "cipher_suite" / CipherSuite,
+    "legacy_compression_method" / Hex(Byte),
+    "extensions" / _Vector(Int16ub, Extension),
+)
+
+# Handshake
+
+HandshakeType = Enum(
+    Byte,
+    client_hello=1,
+    server_hello=2,
+    new_session_ticket=4,
+    end_of_early_data=5,
+    encrypted_extensions=8,
+    certificate=11,
+    certificate_request=13,
+    certificate_verify=15,
+    finished=20,
+    key_update=24,
+    message_hash=254,
+)
+
+Handshake = Struct(
+    "msg_type" / HandshakeType,
+    "length" / Int24ub,
+    "payload"
+    / Switch(
+        this.msg_type,
+        {
+            HandshakeType.client_hello: ClientHello,
+            HandshakeType.server_hello: ServerHello,
+            # HandshakeType.end_of_early_data: EndOfEarlyData,
+            # HandshakeType.encrypted_extensions: EncryptedExtensions,
+            # HandshakeType.certificate_request: CertificateRequest,
+            # HandshakeType.certificate: Certificate,
+            # HandshakeType.certificate_verify: CertificateVerify,
+            # HandshakeType.finished: Finished,
+            # HandshakeType.new_session_ticket: NewSessionTicket,
+            # HandshakeType.key_update: KeyUpdate,
+        },
+        default=FixedSized(this.length, GreedyBytes),
+    ),
+)
+
+# Records
+
+ContentType = Enum(
+    Byte,
+    invalid=0,
+    change_cipher_spec=20,
+    alert=21,
+    handshake=22,
+    application_data=23,
+)
+
+Plaintext = Struct(
+    "type" / ContentType,
+    "legacy_record_version" / ProtocolVersion,
+    "length" / Int16ub,
+    "fragment" / FixedSized(this.length, GreedyBytes),
+)
diff --git a/src/tools/make_venv b/src/tools/make_venv
new file mode 100755
index 00000000000..804307ee120
--- /dev/null
+++ b/src/tools/make_venv
@@ -0,0 +1,56 @@
+#!/usr/bin/env python3
+
+import argparse
+import subprocess
+import os
+import platform
+import sys
+
+parser = argparse.ArgumentParser()
+
+parser.add_argument('--requirements', help='path to pip requirements file', type=str)
+parser.add_argument('--privatedir', help='private directory for target', type=str)
+parser.add_argument('venv_path', help='desired venv location')
+
+args = parser.parse_args()
+
+# Decide whether or not to capture stdout into a log file. We only do this if
+# we've been given our own private directory.
+#
+# FIXME Unfortunately this interferes with debugging on Cirrus, because the
+# private directory isn't uploaded in the sanity check's artifacts. When we
+# don't capture the log file, it gets spammed to stdout during build... Is there
+# a way to push this into the meson-log somehow? For now, the capture
+# implementation is commented out.
+logfile = None
+
+if args.privatedir:
+    if not os.path.isdir(args.privatedir):
+        os.mkdir(args.privatedir)
+
+    # FIXME see above comment
+    # logpath = os.path.join(args.privatedir, 'stdout.txt')
+    # logfile = open(logpath, 'w')
+
+def run(*args):
+    kwargs = dict(check=True)
+    if logfile:
+        kwargs.update(stdout=logfile)
+
+    subprocess.run(args, **kwargs)
+
+# Create the virtualenv first.
+run(sys.executable, '-m', 'venv', args.venv_path)
+
+# Update pip next. This helps avoid old pip bugs; the version inside system
+# Pythons tends to be pretty out of date.
+bindir = 'Scripts' if platform.system() == 'Windows' else 'bin'
+python = os.path.join(args.venv_path, bindir, 'python3')
+run(python, '-m', 'pip', 'install', '-U', 'pip')
+
+# Finally, install the test's requirements. We need pytest and pytest-tap, no
+# matter what the test needs.
+pip = os.path.join(args.venv_path, bindir, 'pip')
+run(pip, 'install', 'pytest', 'pytest-tap')
+if args.requirements:
+    run(pip, 'install', '-r', args.requirements)
diff --git a/src/tools/testwrap b/src/tools/testwrap
index 8ae8fb79ba7..ffdf760d79a 100755
--- a/src/tools/testwrap
+++ b/src/tools/testwrap
@@ -14,6 +14,7 @@ parser.add_argument('--testgroup', help='test group', type=str)
 parser.add_argument('--testname', help='test name', type=str)
 parser.add_argument('--skip', help='skip test (with reason)', type=str)
 parser.add_argument('--pg-test-extra', help='extra tests', type=str)
+parser.add_argument('--skip-without-extra', help='skip if PG_TEST_EXTRA is missing this arg', type=str)
 parser.add_argument('test_command', nargs='*')
 
 args = parser.parse_args()
@@ -29,6 +30,12 @@ if args.skip is not None:
     print('1..0 # Skipped: ' + args.skip)
     sys.exit(0)
 
+if args.skip_without_extra is not None:
+    extras = os.environ.get("PG_TEST_EXTRA", args.pg_test_extra)
+    if extras is None or args.skip_without_extra not in extras.split():
+        print(f'1..0 # Skipped: PG_TEST_EXTRA does not contain "{args.skip_without_extra}"')
+        sys.exit(0)
+
 if os.path.exists(testdir) and os.path.isdir(testdir):
     shutil.rmtree(testdir)
 os.makedirs(testdir)
-- 
2.34.1



^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-02-07 20:12  Daniel Gustafsson <[email protected]>
  parent: Jacob Champion <[email protected]>
  0 siblings, 1 reply; 101+ messages in thread

From: Daniel Gustafsson @ 2025-02-07 20:12 UTC (permalink / raw)
  To: Jacob Champion <[email protected]>; +Cc: Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

> On 7 Feb 2025, at 06:48, Jacob Champion <[email protected]> wrote:
> 
> On Thu, Feb 6, 2025 at 2:02 PM Daniel Gustafsson <[email protected]> wrote:
>> Attached is a v46 which is v45 minus the now committed patch.
> 
> Thank you! Attached is v47, which creeps ever closer to the finish line.

Great, thanks!  Below are a few quick comments after a first read-through and
compile/test cycle:

> - 0005 warns at configure time if libcurl doesn't have a nonblocking
> DNS implementation.

Is it really enough to do this at build time?  A very small percentage of users
running this will also be building their own libpq so the warning is lost on
them.  That being said, I'm not entirely sure what else we could do (bleeping a
warning every time is clearly not userfriendly) so maybe this is a TODO in the
code?

> - 0006 augments bare Asserts during client-side JSON parsing with code
> that will fail gracefully in production builds as well.

+  oauth_json_set_error(ctx,       /* don't bother translating */
With the project style format for translator comments this should be:

	+  /* translator: xxx */
	+  oauth_json_set_error(ctx,

> - 0007 escapes binary data during the printing of libcurl debug
> output. (If you're having a bad enough day to need the debug spray,
> you're probably not in the mood for the sound of a hundred BELs.)

Does it make sense to prefix the printing in debug_callback() with some header
stating that the following data is debug output from curl and not postgres?  I
have a feeling I'm stockholm syndromed by knowing the internals so I'm not sure
if that would be helpful to someone not knowing the implementation details.

> - 0008 parses and passes through the expires_in and optional
> verification_uri_complete fields from the device endpoint to any
> custom user prompt. (We do not use them ourselves, at the moment. But
> after seeing some nice demos of RHEL/PAM/sssd support for device flow
> QR codes at FOSDEM, I think we're definitely going to want to make
> those available to devs.)

Aha, cool!  I was a bit surprised to not find a definition of expires_in in RFC
8628, as in what happens if -1 is passed?  8628 seems to broadly speaking fall
into the category of "just dont do the wrong thing and all will be fine" =/.
Another question that comes to mind is how the reciever should interpret the
information since it doesn't know when the device_code/user_code was generated
so it doesn't know how much of expires_in which has already passed.  (Which is
not something for us to solve, just a general observation.)

+   even if they can't use the non-textual method. Review the RFC's
+   <ulink url="https://datatracker.ietf.org/doc/html/rfc8628#section-3.3.1">notes
+   on non-textual verification</ulink>.
To align more with the rest of the documentation I think something along these
lines is better: "For more information, see section 3.3.1 in <ulink ..>RFC
8628</ulink>.

      Assert(cnt == 1);
-     return 1;                       /* don't fall through in release builds */
+     return 0;
While not introduced in this fixup patch, reading it again now I sort of think
we should make that Assert(false) to clearly indicate that we don't think the
assertion will ever pass, we're just asking to error out since we already know
the failure condition holds.

> - 0009 is gold-plating for the OAUTH_STEP_WAIT_INTERVAL state:

+  actx_error(actx, "failed to create timer kqueue: %m");
Maybe we should add a translator note explaining that kqueue should not be
translated since it's very easy to mistake it for "queue".  Doing it on the
first string including kqueue should be enough I suppose.

--
Daniel Gustafsson







^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-02-08 01:56  Jacob Champion <[email protected]>
  parent: Daniel Gustafsson <[email protected]>
  0 siblings, 3 replies; 101+ messages in thread

From: Jacob Champion @ 2025-02-08 01:56 UTC (permalink / raw)
  To: Daniel Gustafsson <[email protected]>; +Cc: Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

On Fri, Feb 7, 2025 at 12:12 PM Daniel Gustafsson <[email protected]> wrote:
> Is it really enough to do this at build time?  A very small percentage of users
> running this will also be building their own libpq so the warning is lost on
> them.  That being said, I'm not entirely sure what else we could do (bleeping a
> warning every time is clearly not userfriendly) so maybe this is a TODO in the
> code?

I've added a TODO back. At the moment, I don't have any good ideas; if
the user isn't building libpq, they're not going to be able to take
action on the warning anyway, and for many use cases they're probably
not going to care.

> +  oauth_json_set_error(ctx,       /* don't bother translating */
> With the project style format for translator comments this should be:
>
>         +  /* translator: xxx */
>         +  oauth_json_set_error(ctx,

This comment was just meant to draw attention to the lack of
libpq_gettext(). Does it still need a translator note if we don't run
it through translation?

> Does it make sense to prefix the printing in debug_callback() with some header
> stating that the following data is debug output from curl and not postgres?  I
> have a feeling I'm stockholm syndromed by knowing the internals so I'm not sure
> if that would be helpful to someone not knowing the implementation details.

Seems reasonable; I've added "[libcurl]" to the front.

> Aha, cool!  I was a bit surprised to not find a definition of expires_in in RFC
> 8628, as in what happens if -1 is passed?  8628 seems to broadly speaking fall
> into the category of "just dont do the wrong thing and all will be fine" =/.

Yup. :(

> Another question that comes to mind is how the reciever should interpret the
> information since it doesn't know when the device_code/user_code was generated
> so it doesn't know how much of expires_in which has already passed.  (Which is
> not something for us to solve, just a general observation.)

And even if we passed the Date header value through from the server,
that'd do the wrong thing if the clocks are off. I think for now,
expires_in is likely to be a best-effort UX, in the vein of "hey,
maybe type faster if you don't want a timeout."

> To align more with the rest of the documentation I think something along these
> lines is better: "For more information, see section 3.3.1 in <ulink ..>RFC
> 8628</ulink>.

Done.

> While not introduced in this fixup patch, reading it again now I sort of think
> we should make that Assert(false) to clearly indicate that we don't think the
> assertion will ever pass, we're just asking to error out since we already know
> the failure condition holds.

Done.

> Maybe we should add a translator note explaining that kqueue should not be
> translated since it's very easy to mistake it for "queue".  Doing it on the
> first string including kqueue should be enough I suppose.

Done.

--

v48 is attached.

- 0001 contains all of the v47 fixups squashed into v47-0001.
- 0002 contains all the above feedback and rewrites two more commented TODOs.
- 0003 completes a couple of summary paragraphs in the documentation,
and makes it clear that the builtin flow is not currently supported on
Windows.
- 0004 gets a missed pgperltidy and explicitly skips unsupported tests
on Windows.
- 0005 cowardly pulls the MAX_OAUTH_RESPONSE_SIZE down to 256k.

- 0006 gives us additional levers to pull in the event that API or ABI
changes must be backported for security reasons:

Daniel and I talked at FOSDEM about wanting to have additional
guardrails on the server-side validator API. Ideally, we'd wait for
major version boundaries to change APIs, as per usual. But if any bugs
come to light that affect the security of the system, we may want to
have more control over the boundary between the server and the
validator. So I've added two features to the API.

The first is a magic number embedded in the OAuthValidatorCallbacks
struct. Should it ever be necessary to force a recompilation of
validator modules, that number can be bumped in an emergency to allow
the server to reject modules with an older ABI (or otherwise treat
them differently).

The second is state->sversion, added to the ValidatorModuleState
struct, which contains the PG_VERSION_NUM. This currently has no use,
but if there's ever a situation where the ValidatorModule* structs
need to gain new members within a stable release line, this would let
module developers make sense of the situation. It also provides an
easy way for modules to enforce a minimum minor version, for example
if there's a critical security bug in older versions that they'd
rather not deal with.

By adding these fields in addition to the existing module magic
machinery, we've probably doomed them to be unused cruft. But that
seems better than the reverse situation.

Thanks!

--Jacob

 1:  6747b7cc795 !  1:  cf86e3bfbbc Add OAUTHBEARER SASL mechanism
    @@ config/programs.m4: AC_DEFUN([PGAC_CHECK_STRIP],
     +    AC_DEFINE(HAVE_THREADSAFE_CURL_GLOBAL_INIT, 1,
     +              [Define to 1 if curl_global_init() is guaranteed to be threadsafe.])
     +  fi
    ++
    ++  # Warn if a thread-friendly DNS resolver isn't built.
    ++  AC_CACHE_CHECK([for curl support for asynchronous DNS], [pgac_cv__libcurl_async_dns],
    ++  [AC_RUN_IFELSE([AC_LANG_PROGRAM([
    ++#include <curl/curl.h>
    ++],[
    ++    curl_version_info_data *info;
    ++
    ++    if (curl_global_init(CURL_GLOBAL_ALL))
    ++        return -1;
    ++
    ++    info = curl_version_info(CURLVERSION_NOW);
    ++    return (info->features & CURL_VERSION_ASYNCHDNS) ? 0 : 1;
    ++])],
    ++  [pgac_cv__libcurl_async_dns=yes],
    ++  [pgac_cv__libcurl_async_dns=no],
    ++  [pgac_cv__libcurl_async_dns=unknown])])
    ++  if test x"$pgac_cv__libcurl_async_dns" != xyes ; then
    ++    AC_MSG_WARN([
    ++*** The installed version of libcurl does not support asynchronous DNS
    ++*** lookups. Connection timeouts will not be honored during DNS resolution,
    ++*** which may lead to hangs in client programs.])
    ++  fi
     +])# PGAC_CHECK_LIBCURL
     
      ## configure ##
    @@ configure: fi
     +
     +  fi
     +
    ++  # Warn if a thread-friendly DNS resolver isn't built.
    ++  { $as_echo "$as_me:${as_lineno-$LINENO}: checking for curl support for asynchronous DNS" >&5
    ++$as_echo_n "checking for curl support for asynchronous DNS... " >&6; }
    ++if ${pgac_cv__libcurl_async_dns+:} false; then :
    ++  $as_echo_n "(cached) " >&6
    ++else
    ++  if test "$cross_compiling" = yes; then :
    ++  pgac_cv__libcurl_async_dns=unknown
    ++else
    ++  cat confdefs.h - <<_ACEOF >conftest.$ac_ext
    ++/* end confdefs.h.  */
    ++
    ++#include <curl/curl.h>
    ++
    ++int
    ++main ()
    ++{
    ++
    ++    curl_version_info_data *info;
    ++
    ++    if (curl_global_init(CURL_GLOBAL_ALL))
    ++        return -1;
    ++
    ++    info = curl_version_info(CURLVERSION_NOW);
    ++    return (info->features & CURL_VERSION_ASYNCHDNS) ? 0 : 1;
    ++
    ++  ;
    ++  return 0;
    ++}
    ++_ACEOF
    ++if ac_fn_c_try_run "$LINENO"; then :
    ++  pgac_cv__libcurl_async_dns=yes
    ++else
    ++  pgac_cv__libcurl_async_dns=no
    ++fi
    ++rm -f core *.core core.conftest.* gmon.out bb.out conftest$ac_exeext \
    ++  conftest.$ac_objext conftest.beam conftest.$ac_ext
    ++fi
    ++
    ++fi
    ++{ $as_echo "$as_me:${as_lineno-$LINENO}: result: $pgac_cv__libcurl_async_dns" >&5
    ++$as_echo "$pgac_cv__libcurl_async_dns" >&6; }
    ++  if test x"$pgac_cv__libcurl_async_dns" != xyes ; then
    ++    { $as_echo "$as_me:${as_lineno-$LINENO}: WARNING:
    ++*** The installed version of libcurl does not support asynchronous DNS
    ++*** lookups. Connection timeouts will not be honored during DNS resolution,
    ++*** which may lead to hangs in client programs." >&5
    ++$as_echo "$as_me: WARNING:
    ++*** The installed version of libcurl does not support asynchronous DNS
    ++*** lookups. Connection timeouts will not be honored during DNS resolution,
    ++*** which may lead to hangs in client programs." >&2;}
    ++  fi
    ++
     +fi
     +
      if test "$with_gssapi" = yes ; then
    @@ doc/src/sgml/libpq.sgml: void PQinitSSL(int do_ssl);
     +{
     +    const char *verification_uri;   /* verification URI to visit */
     +    const char *user_code;          /* user code to enter */
    ++    const char *verification_uri_complete;  /* optional combination of URI and
    ++                                             * code, or NULL */
    ++    int         expires_in;         /* seconds until user code expires */
     +} PGpromptOAuthDevice;
     +</synopsis>
     +        </para>
    @@ doc/src/sgml/libpq.sgml: void PQinitSSL(int do_ssl);
     +         <link linkend="libpq-oauth-authdata-oauth-bearer-token">custom OAuth
     +         flow</link>, this authdata type will not be used.
     +        </para>
    ++        <para>
    ++         If a non-NULL <structfield>verification_uri_complete</structfield> is
    ++         provided, it may optionally be used for non-textual verification (for
    ++         example, by displaying a QR code). The URL and user code should still
    ++         be displayed to the end user in this case, because the code will be
    ++         manually confirmed by the provider, and the URL lets users continue
    ++         even if they can't use the non-textual method. Review the RFC's
    ++         <ulink url="https://datatracker.ietf.org/doc/html/rfc8628#section-3.3.1">notes
    ++         on non-textual verification</ulink>.
    ++        </para>
     +       </listitem>
     +      </varlistentry>
     +
    @@ meson.build: endif
     +    if libcurl_threadsafe_init
     +      cdata.set('HAVE_THREADSAFE_CURL_GLOBAL_INIT', 1)
     +    endif
    ++
    ++    # Warn if a thread-friendly DNS resolver isn't built.
    ++    libcurl_async_dns = false
    ++
    ++    if not meson.is_cross_build()
    ++      r = cc.run('''
    ++        #include <curl/curl.h>
    ++
    ++        int main(void)
    ++        {
    ++            curl_version_info_data *info;
    ++
    ++            if (curl_global_init(CURL_GLOBAL_ALL))
    ++                return -1;
    ++
    ++            info = curl_version_info(CURLVERSION_NOW);
    ++            return (info->features & CURL_VERSION_ASYNCHDNS) ? 0 : 1;
    ++        }''',
    ++        name: 'test for curl support for asynchronous DNS',
    ++        dependencies: libcurl,
    ++      )
    ++
    ++      assert(r.compiled())
    ++      if r.returncode() == 0
    ++        libcurl_async_dns = true
    ++      endif
    ++    endif
    ++
    ++    if not libcurl_async_dns
    ++      warning('''
    ++*** The installed version of libcurl does not support asynchronous DNS
    ++*** lookups. Connection timeouts will not be honored during DNS resolution,
    ++*** which may lead to hangs in client programs.''')
    ++    endif
     +  endif
     +
     +else
    @@ src/backend/libpq/auth-oauth.c (new)
     +						   char **output, int *outputlen, const char **logdetail);
     +
     +static void load_validator_library(const char *libname);
    -+static void shutdown_validator_library(int code, Datum arg);
    ++static void shutdown_validator_library(void *arg);
     +
     +static ValidatorModuleState *validator_module_state;
     +static const OAuthValidatorCallbacks *ValidatorCallbacks;
    @@ src/backend/libpq/auth-oauth.c (new)
     +load_validator_library(const char *libname)
     +{
     +	OAuthValidatorModuleInit validator_init;
    ++	MemoryContextCallback *mcb;
     +
     +	Assert(libname && *libname);
     +
    @@ src/backend/libpq/auth-oauth.c (new)
     +	if (ValidatorCallbacks->startup_cb != NULL)
     +		ValidatorCallbacks->startup_cb(validator_module_state);
     +
    -+	before_shmem_exit(shutdown_validator_library, 0);
    ++	/* Shut down the library before cleaning up its state. */
    ++	mcb = palloc0(sizeof(*mcb));
    ++	mcb->func = shutdown_validator_library;
    ++
    ++	MemoryContextRegisterResetCallback(CurrentMemoryContext, mcb);
     +}
     +
     +/*
     + * Call the validator module's shutdown callback, if one is provided. This is
    -+ * invoked via before_shmem_exit().
    ++ * invoked during memory context reset.
     + */
     +static void
    -+shutdown_validator_library(int code, Datum arg)
    ++shutdown_validator_library(void *arg)
     +{
     +	if (ValidatorCallbacks->shutdown_cb != NULL)
     +		ValidatorCallbacks->shutdown_cb(validator_module_state);
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +	char	   *device_code;
     +	char	   *user_code;
     +	char	   *verification_uri;
    ++	char	   *verification_uri_complete;
    ++	char	   *expires_in_str;
     +	char	   *interval_str;
     +
     +	/* Fields below are parsed from the corresponding string above. */
    ++	int			expires_in;
     +	int			interval;
     +};
     +
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +	free(authz->device_code);
     +	free(authz->user_code);
     +	free(authz->verification_uri);
    ++	free(authz->verification_uri_complete);
    ++	free(authz->expires_in_str);
     +	free(authz->interval_str);
     +}
     +
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +{
     +	enum OAuthStep step;		/* where are we in the flow? */
     +
    -+#ifdef HAVE_SYS_EPOLL_H
    -+	int			timerfd;		/* a timerfd for signaling async timeouts */
    -+#endif
    ++	int			timerfd;		/* descriptor for signaling async timeouts */
     +	pgsocket	mux;			/* the multiplexer socket containing all
     +								 * descriptors tracked by libcurl, plus the
     +								 * timerfd */
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +
     +	if (actx->mux != PGINVALID_SOCKET)
     +		close(actx->mux);
    -+#ifdef HAVE_SYS_EPOLL_H
     +	if (actx->timerfd >= 0)
     +		close(actx->timerfd);
    -+#endif
     +
     +	free(actx);
     +}
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +		/*
     +		 * We should never start parsing a new field while a previous one is
     +		 * still active.
    -+		 *
    -+		 * TODO: this code relies on assertions too much. We need to exit
    -+		 * sanely on internal logic errors, to avoid turning bugs into
    -+		 * vulnerabilities.
     +		 */
    -+		Assert(!ctx->active);
    ++		if (ctx->active)
    ++		{
    ++			Assert(false);
    ++			oauth_parse_set_error(ctx,
    ++								  "internal error: started field '%s' before field '%s' was finished",
    ++								  name, ctx->active->name);
    ++			return JSON_SEM_ACTION_FAILED;
    ++		}
     +
     +		while (field->name)
     +		{
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +	struct oauth_parse *ctx = state;
     +
     +	--ctx->nested;
    -+	if (!ctx->nested)
    -+		Assert(!ctx->active);	/* all fields should be fully processed */
    ++
    ++	/*
    ++	 * All fields should be fully processed by the end of the top-level
    ++	 * object.
    ++	 */
    ++	if (!ctx->nested && ctx->active)
    ++	{
    ++		Assert(false);
    ++		oauth_parse_set_error(ctx,
    ++							  "internal error: field '%s' still active at end of object",
    ++							  ctx->active->name);
    ++		return JSON_SEM_ACTION_FAILED;
    ++	}
     +
     +	return JSON_SUCCESS;
     +}
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +	if (ctx->active)
     +	{
     +		/*
    -+		 * This assumes that no target arrays can contain other arrays, which
    -+		 * we check in the array_start callback.
    ++		 * Clear the target (which should be an array inside the top-level
    ++		 * object). For this to be safe, no target arrays can contain other
    ++		 * arrays; we check for that in the array_start callback.
     +		 */
    -+		Assert(ctx->nested == 2);
    -+		Assert(ctx->active->type == JSON_TOKEN_ARRAY_START);
    ++		if (ctx->nested != 2 || ctx->active->type != JSON_TOKEN_ARRAY_START)
    ++		{
    ++			Assert(false);
    ++			oauth_parse_set_error(ctx,
    ++								  "internal error: found unexpected array end while parsing field '%s'",
    ++								  ctx->active->name);
    ++			return JSON_SEM_ACTION_FAILED;
    ++		}
     +
     +		ctx->active = NULL;
     +	}
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +
     +		if (field->type != JSON_TOKEN_ARRAY_START)
     +		{
    -+			Assert(ctx->nested == 1);
    -+			Assert(!*field->target.scalar);
    ++			/* Ensure that we're parsing the top-level keys... */
    ++			if (ctx->nested != 1)
    ++			{
    ++				Assert(false);
    ++				oauth_parse_set_error(ctx,
    ++									  "internal error: scalar target found at nesting level %d",
    ++									  ctx->nested);
    ++				return JSON_SEM_ACTION_FAILED;
    ++			}
    ++
    ++			/* ...and that a result has not already been set. */
    ++			if (*field->target.scalar)
    ++			{
    ++				Assert(false);
    ++				oauth_parse_set_error(ctx,
    ++									  "internal error: scalar field '%s' would be assigned twice",
    ++									  ctx->active->name);
    ++				return JSON_SEM_ACTION_FAILED;
    ++			}
     +
     +			*field->target.scalar = strdup(token);
     +			if (!*field->target.scalar)
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +		{
     +			struct curl_slist *temp;
     +
    -+			Assert(ctx->nested == 2);
    ++			/* The target array should be inside the top-level object. */
    ++			if (ctx->nested != 2)
    ++			{
    ++				Assert(false);
    ++				oauth_parse_set_error(ctx,
    ++									  "internal error: array member found at nesting level %d",
    ++									  ctx->nested);
    ++				return JSON_SEM_ACTION_FAILED;
    ++			}
     +
     +			/* Note that curl_slist_append() makes a copy of the token. */
     +			temp = curl_slist_append(*field->target.array, token);
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +}
     +
     +/*
    ++ * Parses a valid JSON number into a double. The input must have come from
    ++ * pg_parse_json(), so that we know the lexer has validated it; there's no
    ++ * in-band signal for invalid formats.
    ++ */
    ++static double
    ++parse_json_number(const char *s)
    ++{
    ++	double		parsed;
    ++	int			cnt;
    ++
    ++	/*
    ++	 * The JSON lexer has already validated the number, which is stricter than
    ++	 * the %f format, so we should be good to use sscanf().
    ++	 */
    ++	cnt = sscanf(s, "%lf", &parsed);
    ++
    ++	if (cnt != 1)
    ++	{
    ++		/*
    ++		 * Either the lexer screwed up or our assumption above isn't true, and
    ++		 * either way a developer needs to take a look.
    ++		 */
    ++		Assert(cnt == 1);
    ++		return 0;
    ++	}
    ++
    ++	return parsed;
    ++}
    ++
    ++/*
     + * Parses the "interval" JSON number, corresponding to the number of seconds to
     + * wait between token endpoint requests.
     + *
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     + * the result at a minimum of one. (Zero-second intervals would result in an
     + * expensive network polling loop.) Tests may remove the lower bound with
     + * PGOAUTHDEBUG, for improved performance.
    -+ *
    -+ * TODO: maybe clamp the upper bound too, based on the libpq timeout and/or the
    -+ * code expiration time?
     + */
     +static int
     +parse_interval(struct async_ctx *actx, const char *interval_str)
     +{
     +	double		parsed;
    -+	int			cnt;
    -+
    -+	/*
    -+	 * The JSON lexer has already validated the number, which is stricter than
    -+	 * the %f format, so we should be good to use sscanf().
    -+	 */
    -+	cnt = sscanf(interval_str, "%lf", &parsed);
    -+
    -+	if (cnt != 1)
    -+	{
    -+		/*
    -+		 * Either the lexer screwed up or our assumption above isn't true, and
    -+		 * either way a developer needs to take a look.
    -+		 */
    -+		Assert(cnt == 1);
    -+		return 1;				/* don't fall through in release builds */
    -+	}
     +
    ++	parsed = parse_json_number(interval_str);
     +	parsed = ceil(parsed);
     +
     +	if (parsed < 1)
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +}
     +
     +/*
    ++ * Parses the "expires_in" JSON number, corresponding to the number of seconds
    ++ * remaining in the lifetime of the device code request.
    ++ *
    ++ * Similar to parse_interval, but we have even fewer requirements for reasonable
    ++ * values since we don't use the expiration time directly (it's passed to the
    ++ * PQAUTHDATA_PROMPT_OAUTH_DEVICE hook, in case the application wants to do
    ++ * something with it). We simply round and clamp to int range.
    ++ */
    ++static int
    ++parse_expires_in(struct async_ctx *actx, const char *expires_in_str)
    ++{
    ++	double		parsed;
    ++
    ++	parsed = parse_json_number(expires_in_str);
    ++	parsed = round(parsed);
    ++
    ++	if (INT_MAX <= parsed)
    ++		return INT_MAX;
    ++	else if (parsed <= INT_MIN)
    ++		return INT_MIN;
    ++
    ++	return parsed;
    ++}
    ++
    ++/*
     + * Parses the Device Authorization Response (RFC 8628, Sec. 3.2).
     + */
     +static bool
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +		{"device_code", JSON_TOKEN_STRING, {&authz->device_code}, REQUIRED},
     +		{"user_code", JSON_TOKEN_STRING, {&authz->user_code}, REQUIRED},
     +		{"verification_uri", JSON_TOKEN_STRING, {&authz->verification_uri}, REQUIRED},
    ++		{"expires_in", JSON_TOKEN_NUMBER, {&authz->expires_in_str}, REQUIRED},
     +
     +		/*
     +		 * Some services (Google, Azure) spell verification_uri differently.
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +		 */
     +		{"verification_url", JSON_TOKEN_STRING, {&authz->verification_uri}, REQUIRED},
     +
    -+		/*
    -+		 * The following fields are technically REQUIRED, but we don't use
    -+		 * them anywhere yet:
    -+		 *
    -+		 * - expires_in
    -+		 */
    -+
    ++		{"verification_uri_complete", JSON_TOKEN_STRING, {&authz->verification_uri_complete}, OPTIONAL},
     +		{"interval", JSON_TOKEN_NUMBER, {&authz->interval_str}, OPTIONAL},
     +
     +		{0},
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +		authz->interval = 5;
     +	}
     +
    ++	Assert(authz->expires_in_str);	/* ensured by parse_oauth_json() */
    ++	authz->expires_in = parse_expires_in(actx, authz->expires_in_str);
    ++
     +	return true;
     +}
     +
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +		{"access_token", JSON_TOKEN_STRING, {&tok->access_token}, REQUIRED},
     +		{"token_type", JSON_TOKEN_STRING, {&tok->token_type}, REQUIRED},
     +
    -+		/*
    -+		 * The following fields are technically REQUIRED, but we don't use
    -+		 * them anywhere yet:
    ++		/*---
    ++		 * We currently have no use for the following OPTIONAL fields:
     +		 *
    -+		 * - scope (only required if different than requested -- TODO check)
    ++		 * - expires_in: This will be important for maintaining a token cache,
    ++		 *               but we do not yet implement one.
    ++		 *
    ++		 * - refresh_token: Ditto.
    ++		 *
    ++		 * - scope: This is only sent when the authorization server sees fit to
    ++		 *          change our scope request. It's not clear what we should do
    ++		 *          about this; either it's been done as a matter of policy, or
    ++		 *          the user has explicitly denied part of the authorization,
    ++		 *          and either way the server-side validator is in a better
    ++		 *          place to complain if the change isn't acceptable.
     +		 */
     +
     +		{0},
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     + * select() on instead of the Postgres socket during OAuth negotiation.
     + *
     + * This is just an epoll set or kqueue abstracting multiple other descriptors.
    -+ * A timerfd is always part of the set when using epoll; it's just disabled
    -+ * when we're not using it.
    ++ * For epoll, the timerfd is always part of the set; it's just disabled when
    ++ * we're not using it. For kqueue, the "timerfd" is actually a second kqueue
    ++ * instance which is only added to the set when needed.
     + */
     +static bool
     +setup_multiplexer(struct async_ctx *actx)
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +		return false;
     +	}
     +
    ++	/*
    ++	 * Originally, we set EVFILT_TIMER directly on the top-level multiplexer.
    ++	 * This makes it difficult to implement timer_expired(), though, so now we
    ++	 * set EVFILT_TIMER on a separate actx->timerfd, which is chained to
    ++	 * actx->mux while the timer is active.
    ++	 */
    ++	actx->timerfd = kqueue();
    ++	if (actx->timerfd < 0)
    ++	{
    ++		actx_error(actx, "failed to create timer kqueue: %m");
    ++		return false;
    ++	}
    ++
     +	return true;
     +#endif
     +
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +
     +/*
     + * Enables or disables the timer in the multiplexer set. The timeout value is
    -+ * in milliseconds (negative values disable the timer). Rather than continually
    -+ * adding and removing the timer, we keep it in the set at all times and just
    -+ * disarm it when it's not needed.
    ++ * in milliseconds (negative values disable the timer).
    ++ *
    ++ * For epoll, rather than continually adding and removing the timer, we keep it
    ++ * in the set at all times and just disarm it when it's not needed. For kqueue,
    ++ * the timer is removed completely when disabled to prevent stale timeouts from
    ++ * remaining in the queue.
     + */
     +static bool
     +set_timer(struct async_ctx *actx, long timeout)
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +		actx_error(actx, "setting timerfd to %ld: %m", timeout);
     +		return false;
     +	}
    ++
    ++	return true;
     +#endif
     +#ifdef HAVE_SYS_EVENT_H
     +	struct kevent ev;
     +
    -+	EV_SET(&ev, 1, EVFILT_TIMER, timeout < 0 ? EV_DELETE : EV_ADD,
    ++	/* Enable/disable the timer itself. */
    ++	EV_SET(&ev, 1, EVFILT_TIMER, timeout < 0 ? EV_DELETE : (EV_ADD | EV_ONESHOT),
     +		   0, timeout, 0);
    -+	if (kevent(actx->mux, &ev, 1, NULL, 0, NULL) < 0 && errno != ENOENT)
    ++	if (kevent(actx->timerfd, &ev, 1, NULL, 0, NULL) < 0 && errno != ENOENT)
     +	{
     +		actx_error(actx, "setting kqueue timer to %ld: %m", timeout);
     +		return false;
     +	}
    -+#endif
    ++
    ++	/*
    ++	 * Add/remove the timer to/from the mux. (In contrast with epoll, if we
    ++	 * allowed the timer to remain registered here after being disabled, the
    ++	 * mux queue would retain any previous stale timeout notifications and
    ++	 * remain readable.)
    ++	 */
    ++	EV_SET(&ev, actx->timerfd, EVFILT_READ, timeout < 0 ? EV_DELETE : EV_ADD,
    ++		   0, 0, 0);
    ++	if (kevent(actx->mux, &ev, 1, NULL, 0, NULL) < 0 && errno != ENOENT)
    ++	{
    ++		actx_error(actx, "could not update timer on kqueue: %m");
    ++		return false;
    ++	}
     +
     +	return true;
    ++#endif
    ++
    ++	actx_error(actx, "libpq does not support timers on this platform");
    ++	return false;
    ++}
    ++
    ++/*
    ++ * Returns 1 if the timeout in the multiplexer set has expired since the last
    ++ * call to set_timer(), 0 if the timer is still running, or -1 (with an
    ++ * actx_error() report) if the timer cannot be queried.
    ++ */
    ++static int
    ++timer_expired(struct async_ctx *actx)
    ++{
    ++#if HAVE_SYS_EPOLL_H
    ++	struct itimerspec spec = {0};
    ++
    ++	if (timerfd_gettime(actx->timerfd, &spec) < 0)
    ++	{
    ++		actx_error(actx, "getting timerfd value: %m");
    ++		return -1;
    ++	}
    ++
    ++	/*
    ++	 * This implementation assumes we're using single-shot timers. If you
    ++	 * change to using intervals, you'll need to reimplement this function
    ++	 * too, possibly with the read() or select() interfaces for timerfd.
    ++	 */
    ++	Assert(spec.it_interval.tv_sec == 0
    ++		   && spec.it_interval.tv_nsec == 0);
    ++
    ++	/* If the remaining time to expiration is zero, we're done. */
    ++	return (spec.it_value.tv_sec == 0
    ++			&& spec.it_value.tv_nsec == 0);
    ++#endif
    ++#ifdef HAVE_SYS_EVENT_H
    ++	int			res;
    ++
    ++	/* Is the timer queue ready? */
    ++	res = PQsocketPoll(actx->timerfd, 1 /* forRead */ , 0, 0);
    ++	if (res < 0)
    ++	{
    ++		actx_error(actx, "checking kqueue for timeout: %m");
    ++		return -1;
    ++	}
    ++
    ++	return (res > 0);
    ++#endif
    ++
    ++	actx_error(actx, "libpq does not support timers on this platform");
    ++	return -1;
     +}
     +
     +/*
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +	struct async_ctx *actx = ctx;
     +
     +	/*
    -+	 * TODO: maybe just signal drive_request() to immediately call back in the
    -+	 * (timeout == 0) case?
    ++	 * There might be an optimization opportunity here: if timeout == 0, we
    ++	 * could signal drive_request to immediately call
    ++	 * curl_multi_socket_action, rather than returning all the way up the
    ++	 * stack only to come right back. But it's not clear that the additional
    ++	 * code complexity is worth it.
     +	 */
     +	if (!set_timer(actx, timeout))
     +		return -1;				/* actx_error already called */
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +debug_callback(CURL *handle, curl_infotype type, char *data, size_t size,
     +			   void *clientp)
     +{
    -+	const char *const end = data + size;
     +	const char *prefix;
    ++	bool		printed_prefix = false;
    ++	PQExpBufferData buf;
     +
     +	/* Prefixes are modeled off of the default libcurl debug output. */
     +	switch (type)
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +			return 0;
     +	}
     +
    ++	initPQExpBuffer(&buf);
    ++
     +	/*
     +	 * Split the output into lines for readability; sometimes multiple headers
    -+	 * are included in a single call.
    ++	 * are included in a single call. We also don't allow unprintable ASCII
    ++	 * through without a basic <XX> escape.
     +	 */
    -+	while (data < end)
    ++	for (int i = 0; i < size; i++)
     +	{
    -+		size_t		len = end - data;
    -+		char	   *eol = memchr(data, '\n', len);
    ++		char		c = data[i];
     +
    -+		if (eol)
    -+			len = eol - data + 1;
    ++		if (!printed_prefix)
    ++		{
    ++			appendPQExpBuffer(&buf, "%s ", prefix);
    ++			printed_prefix = true;
    ++		}
     +
    -+		/* TODO: handle unprintables */
    -+		fprintf(stderr, "%s %.*s%s", prefix, (int) len, data,
    -+				eol ? "" : "\n");
    ++		if (c >= 0x20 && c <= 0x7E)
    ++			appendPQExpBufferChar(&buf, c);
    ++		else if ((type == CURLINFO_HEADER_IN
    ++				  || type == CURLINFO_HEADER_OUT
    ++				  || type == CURLINFO_TEXT)
    ++				 && (c == '\r' || c == '\n'))
    ++		{
    ++			/*
    ++			 * Don't bother emitting <0D><0A> for headers and text; it's not
    ++			 * helpful noise.
    ++			 */
    ++		}
    ++		else
    ++			appendPQExpBuffer(&buf, "<%02X>", c);
     +
    -+		data += len;
    ++		if (c == '\n')
    ++		{
    ++			appendPQExpBufferChar(&buf, c);
    ++			printed_prefix = false;
    ++		}
     +	}
     +
    ++	if (printed_prefix)
    ++		appendPQExpBufferChar(&buf, '\n');	/* finish the line */
    ++
    ++	fprintf(stderr, "%s", buf.data);
    ++	termPQExpBuffer(&buf);
     +	return 0;
     +}
     +
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +static bool
     +setup_curl_handles(struct async_ctx *actx)
     +{
    -+	curl_version_info_data *curl_info;
    -+
     +	/*
     +	 * Create our multi handle. This encapsulates the entire conversation with
     +	 * libcurl for this connection.
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +	}
     +
     +	/*
    -+	 * Extract information about the libcurl we are linked against.
    -+	 */
    -+	curl_info = curl_version_info(CURLVERSION_NOW);
    -+
    -+	/*
     +	 * The multi handle tells us what to wait on using two callbacks. These
     +	 * will manipulate actx->mux as needed.
     +	 */
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +	 * Multi-threaded applications must set CURLOPT_NOSIGNAL. This requires us
     +	 * to handle the possibility of SIGPIPE ourselves using pq_block_sigpipe;
     +	 * see pg_fe_run_oauth_flow().
    ++	 *
    ++	 * NB: If libcurl is not built against a friendly DNS resolver (c-ares or
    ++	 * threaded), setting this option prevents DNS lookups from timing out
    ++	 * correctly. We warn about this situation at configure time.
     +	 */
     +	CHECK_SETOPT(actx, CURLOPT_NOSIGNAL, 1L, return false);
    -+	if (!curl_info->ares_num)
    -+	{
    -+		/* No alternative resolver, TODO: warn about timeouts */
    -+	}
     +
     +	if (actx->debugging)
     +	{
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +		CHECK_SETOPT(actx, popt, protos, return false);
     +	}
     +
    -+	/* TODO: would anyone use this in "real" situations, or just testing? */
    ++	/*
    ++	 * If we're in debug mode, allow the developer to change the trusted CA
    ++	 * list. For now, this is not something we expose outside of the UNSAFE
    ++	 * mode, because it's not clear that it's useful in production: both libpq
    ++	 * and the user's browser must trust the same authorization servers for
    ++	 * the flow to work at all, so any changes to the roots are likely to be
    ++	 * done system-wide.
    ++	 */
     +	if (actx->debugging)
     +	{
     +		const char *env;
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +	 *    of the authorization server where the authorization request was
     +	 *    sent to. This comparison MUST use simple string comparison as defined
     +	 *    in Section 6.2.1 of [RFC3986].
    -+	 *
    -+	 * TODO: Encoding support?
     +	 */
     +	if (strcmp(conn->oauth_issuer_id, provider->issuer) != 0)
     +	{
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +	PGpromptOAuthDevice prompt = {
     +		.verification_uri = actx->authz.verification_uri,
     +		.user_code = actx->authz.user_code,
    -+		/* TODO: optional fields */
    ++		.verification_uri_complete = actx->authz.verification_uri_complete,
    ++		.expires_in = actx->authz.expires_in,
     +	};
     +
     +	res = PQauthDataHook(PQAUTHDATA_PROMPT_OAUTH_DEVICE, conn, &prompt);
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +		}
     +
     +		actx->mux = PGINVALID_SOCKET;
    -+#ifdef HAVE_SYS_EPOLL_H
     +		actx->timerfd = -1;
    -+#endif
     +
     +		/* Should we enable unsafe features? */
     +		actx->debugging = oauth_unsafe_debugging_enabled();
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +						/* not done yet */
     +						return status;
     +					}
    ++
    ++					break;
     +				}
     +
     +			case OAUTH_STEP_WAIT_INTERVAL:
    -+				/* TODO check that the timer has expired */
    ++
    ++				/*
    ++				 * The client application is supposed to wait until our timer
    ++				 * expires before calling PQconnectPoll() again, but that
    ++				 * might not happen. To avoid sending a token request early,
    ++				 * check the timer before continuing.
    ++				 */
    ++				if (!timer_expired(actx))
    ++				{
    ++					conn->altsock = actx->timerfd;
    ++					return PGRES_POLLING_READING;
    ++				}
    ++
    ++				/* Disable the expired timer. */
    ++				if (!set_timer(actx, -1))
    ++					goto error_return;
    ++
     +				break;
     +		}
     +
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +				if (!set_timer(actx, actx->authz.interval * 1000))
     +					goto error_return;
     +
    -+#ifdef HAVE_SYS_EPOLL_H
    -+
     +				/*
     +				 * No Curl requests are running, so we can simplify by having
     +				 * the client wait directly on the timerfd rather than the
    -+				 * multiplexer. (This isn't possible for kqueue.)
    ++				 * multiplexer.
     +				 */
     +				conn->altsock = actx->timerfd;
    -+#endif
     +
     +				actx->step = OAUTH_STEP_WAIT_INTERVAL;
     +				actx->running = 1;
    @@ src/interfaces/libpq/fe-auth-oauth.c (new)
     +{
     +	struct json_ctx *ctx = state;
     +
    ++	/* Only top-level keys are considered. */
     +	if (ctx->nested == 1)
     +	{
     +		if (strcmp(name, ERROR_STATUS_FIELD) == 0)
    @@ src/interfaces/libpq/fe-auth-oauth.c (new)
     +
     +	if (ctx->target_field)
     +	{
    -+		Assert(ctx->nested == 1);
    ++		if (ctx->nested != 1)
    ++		{
    ++			/*
    ++			 * ctx->target_field should not have been set for nested keys.
    ++			 * Assert and don't continue any further for production builds.
    ++			 */
    ++			Assert(false);
    ++			oauth_json_set_error(ctx,	/* don't bother translating */
    ++								 "internal error: target scalar found at nesting level %d during OAUTHBEARER parsing",
    ++								 ctx->nested);
    ++			return JSON_SEM_ACTION_FAILED;
    ++		}
     +
     +		/*
     +		 * We don't allow duplicate field names; error out if the target has
    @@ src/interfaces/libpq/libpq-fe.h: extern int	PQenv2encoding(void);
     +{
     +	const char *verification_uri;	/* verification URI to visit */
     +	const char *user_code;		/* user code to enter */
    ++	const char *verification_uri_complete;	/* optional combination of URI and
    ++											 * code, or NULL */
    ++	int			expires_in;		/* seconds until user code expires */
     +} PGpromptOAuthDevice;
     +
     +/* for PGoauthBearerRequest.async() */
    @@ src/test/modules/oauth_validator/oauth_hook_client.c (new)
     +	printf(" --expected-uri URI		fail if received configuration link does not match URI\n");
     +	printf(" --misbehave=MODE		have the hook fail required postconditions\n"
     +		   "						(MODEs: no-hook, fail-async, no-token, no-socket)\n");
    -+	printf(" --no-hook				don't install OAuth hooks (connection will fail)\n");
    ++	printf(" --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(" --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_scope = NULL;
     +static const char *misbehave_mode = NULL;
    @@ src/test/modules/oauth_validator/oauth_hook_client.c (new)
     +		{"token", required_argument, NULL, 1003},
     +		{"hang-forever", no_argument, NULL, 1004},
     +		{"misbehave", required_argument, NULL, 1005},
    ++		{"stress-async", no_argument, NULL, 1006},
     +		{0}
     +	};
     +
    @@ src/test/modules/oauth_validator/oauth_hook_client.c (new)
     +				misbehave_mode = optarg;
     +				break;
     +
    ++			case 1006:			/* --stress-async */
    ++				stress_async = true;
    ++				break;
    ++
     +			default:
     +				usage(argv);
     +				return 1;
    @@ src/test/modules/oauth_validator/oauth_hook_client.c (new)
     +	PQsetAuthDataHook(handle_auth_data);
     +
     +	/* Connect. (All the actual work is in the hook.) */
    -+	conn = PQconnectdb(conninfo);
    ++	if (stress_async)
    ++	{
    ++		/*
    ++		 * Perform an asynchronous connection, busy-looping on PQconnectPoll()
    ++		 * without actually waiting on socket events. This stresses code paths
    ++		 * that rely on asynchronous work to be done before continuing with
    ++		 * the next step in the flow.
    ++		 */
    ++		PostgresPollingStatusType res;
    ++
    ++		conn = PQconnectStart(conninfo);
    ++
    ++		do
    ++		{
    ++			res = PQconnectPoll(conn);
    ++		} while (res != PGRES_POLLING_FAILED && res != PGRES_POLLING_OK);
    ++	}
    ++	else
    ++	{
    ++		/* Perform a standard synchronous connection. */
    ++		conn = PQconnectdb(conninfo);
    ++	}
    ++
     +	if (PQstatus(conn) != CONNECTION_OK)
     +	{
    -+		fprintf(stderr, "Connection to database failed: %s\n",
    ++		fprintf(stderr, "connection to database failed: %s\n",
     +				PQerrorMessage(conn));
     +		PQfinish(conn);
     +		return 1;
    @@ src/test/modules/oauth_validator/t/001_server.pl (new)
     +	  qr/failed to obtain access token: mutual TLS required for client \(invalid_client\)/
     +);
     +
    ++# Stress test: make sure our builtin flow operates correctly even if the client
    ++# application isn't respecting PGRES_POLLING_READING/WRITING signals returned
    ++# from PQconnectPoll().
    ++$base_connstr =
    ++  "$common_connstr port=" . $node->port . " host=" . $node->host;
    ++my @cmd = (
    ++	"oauth_hook_client", "--no-hook", "--stress-async",
    ++	connstr(stage => 'all', retries => 1, interval => 1));
    ++
    ++note "running '" . join("' '", @cmd) . "'";
    ++my ($stdout, $stderr) = run_command(\@cmd);
    ++
    ++like($stdout, qr/connection succeeded/, "stress-async: stdout matches");
    ++unlike($stderr, qr/connection to database failed/, "stress-async: stderr matches");
    ++
     +#
     +# This section of tests reconfigures the validator module itself, rather than
     +# the OAuth server.
    @@ src/test/modules/oauth_validator/t/oauth_server.py (new)
     +            "device_code": "postgres",
     +            "user_code": "postgresuser",
     +            self._uri_spelling: uri,
    -+            "expires-in": 5,
    ++            "expires_in": 5,
     +            **self._response_padding,
     +        }
     +
    @@ src/test/modules/oauth_validator/validator.c (new)
     +{
     +	/* Check to make sure our private state still exists. */
     +	if (state->private_data != PRIVATE_COOKIE)
    -+		elog(ERROR, "oauth_validator: private state cookie changed to %p in shutdown",
    ++		elog(PANIC, "oauth_validator: private state cookie changed to %p in shutdown",
     +			 state->private_data);
     +}
     +
 2:  483129c1ca9 <  -:  ----------- fixup! Add OAUTHBEARER SASL mechanism
 3:  75d98784ded <  -:  ----------- fixup! Add OAUTHBEARER SASL mechanism
 4:  fd60ceb4c84 <  -:  ----------- fixup! Add OAUTHBEARER SASL mechanism
 5:  595362ef2c1 <  -:  ----------- fixup! Add OAUTHBEARER SASL mechanism
 6:  f73c042adc9 <  -:  ----------- fixup! Add OAUTHBEARER SASL mechanism
 7:  298839b69f0 <  -:  ----------- fixup! Add OAUTHBEARER SASL mechanism
 8:  1cf48a8f835 <  -:  ----------- fixup! Add OAUTHBEARER SASL mechanism
 9:  27135876559 <  -:  ----------- fixup! Add OAUTHBEARER SASL mechanism
 -:  ----------- >  2:  9171989a75e fixup! Add OAUTHBEARER SASL mechanism
 -:  ----------- >  3:  1bd03e1de10 fixup! Add OAUTHBEARER SASL mechanism
 -:  ----------- >  4:  0929bfbc5fc fixup! Add OAUTHBEARER SASL mechanism
 -:  ----------- >  5:  be882ef6eae fixup! Add OAUTHBEARER SASL mechanism
 -:  ----------- >  6:  954341052b4 fixup! Add OAUTHBEARER SASL mechanism
10:  d8c1f298080 =  7:  d88a2938e7e XXX fix libcurl link error
11:  dbf305d0489 !  8:  44e5cbc8ad1 DO NOT MERGE: Add pytest suite for OAuth
    @@ src/test/python/client/test_oauth.py (new)
     +            id="invalid request without description",
     +        ),
     +        pytest.param(
    -+            (400, {"error": "invalid_request", "padding": "x" * 1024 * 1024}),
    ++            (400, {"error": "invalid_request", "padding": "x" * 256 * 1024}),
     +            r"failed to obtain device authorization: response is too large",
     +            id="gigantic authz response",
     +        ),
    @@ src/test/python/client/test_oauth.py (new)
     +            id="access denied without description",
     +        ),
     +        pytest.param(
    -+            (400, {"error": "access_denied", "padding": "x" * 1024 * 1024}),
    ++            (400, {"error": "access_denied", "padding": "x" * 256 * 1024}),
     +            r"failed to obtain access token: response is too large",
     +            id="gigantic token response",
     +        ),
    @@ src/test/python/client/test_oauth.py (new)
     +                        "urn:ietf:params:oauth:grant-type:device_code"
     +                    ],
     +                    "device_authorization_endpoint": "https://256.256.256.256/dev";,
    -+                    "filler": "x" * 1024 * 1024,
    ++                    "filler": "x" * 256 * 1024,
     +                },
     +            ),
     +            r"failed to fetch OpenID discovery document: response is too large",
    @@ src/test/python/server/oauthtest.c (new)
     +
     +static void test_startup(ValidatorModuleState *state);
     +static void test_shutdown(ValidatorModuleState *state);
    -+static ValidatorModuleResult *test_validate(ValidatorModuleState *state,
    -+											const char *token,
    -+											const char *role);
    ++static bool test_validate(const ValidatorModuleState *state,
    ++						  const char *token,
    ++						  const char *role,
    ++						  ValidatorModuleResult *result);
     +
     +static const OAuthValidatorCallbacks callbacks = {
    ++	PG_OAUTH_VALIDATOR_MAGIC,
    ++
     +	.startup_cb = test_startup,
     +	.shutdown_cb = test_shutdown,
     +	.validate_cb = test_validate,
    @@ src/test/python/server/oauthtest.c (new)
     +{
     +}
     +
    -+static ValidatorModuleResult *
    -+test_validate(ValidatorModuleState *state, const char *token, const char *role)
    ++static bool
    ++test_validate(const ValidatorModuleState *state,
    ++			  const char *token, const char *role,
    ++			  ValidatorModuleResult *res)
     +{
    -+	ValidatorModuleResult *res;
    -+
    -+	res = palloc0(sizeof(ValidatorModuleResult));	/* TODO: palloc context? */
    -+
     +	if (reflect_role)
     +	{
     +		res->authorized = true;
    -+		res->authn_id = pstrdup(role);	/* TODO: constify? */
    ++		res->authn_id = pstrdup(role);
     +	}
     +	else
     +	{
     +		if (*expected_bearer && strcmp(token, expected_bearer) == 0)
     +			res->authorized = true;
     +		if (set_authn_id)
    -+			res->authn_id = authn_id;
    ++			res->authn_id = pstrdup(authn_id);
     +	}
     +
    -+	return res;
    ++	return true;
     +}
     
      ## src/test/python/server/test_oauth.py (new) ##


Attachments:

  [text/plain] since-v47.diff.txt (40.5K, ../../CAOYmi+mFZsExAYWy-bS9=yki_fWC+MC0MufpEZ_SycrbCAJrEg@mail.gmail.com/2-since-v47.diff.txt)
  download | inline:
 1:  6747b7cc795 !  1:  cf86e3bfbbc Add OAUTHBEARER SASL mechanism
    @@ config/programs.m4: AC_DEFUN([PGAC_CHECK_STRIP],
     +    AC_DEFINE(HAVE_THREADSAFE_CURL_GLOBAL_INIT, 1,
     +              [Define to 1 if curl_global_init() is guaranteed to be threadsafe.])
     +  fi
    ++
    ++  # Warn if a thread-friendly DNS resolver isn't built.
    ++  AC_CACHE_CHECK([for curl support for asynchronous DNS], [pgac_cv__libcurl_async_dns],
    ++  [AC_RUN_IFELSE([AC_LANG_PROGRAM([
    ++#include <curl/curl.h>
    ++],[
    ++    curl_version_info_data *info;
    ++
    ++    if (curl_global_init(CURL_GLOBAL_ALL))
    ++        return -1;
    ++
    ++    info = curl_version_info(CURLVERSION_NOW);
    ++    return (info->features & CURL_VERSION_ASYNCHDNS) ? 0 : 1;
    ++])],
    ++  [pgac_cv__libcurl_async_dns=yes],
    ++  [pgac_cv__libcurl_async_dns=no],
    ++  [pgac_cv__libcurl_async_dns=unknown])])
    ++  if test x"$pgac_cv__libcurl_async_dns" != xyes ; then
    ++    AC_MSG_WARN([
    ++*** The installed version of libcurl does not support asynchronous DNS
    ++*** lookups. Connection timeouts will not be honored during DNS resolution,
    ++*** which may lead to hangs in client programs.])
    ++  fi
     +])# PGAC_CHECK_LIBCURL
     
      ## configure ##
    @@ configure: fi
     +
     +  fi
     +
    ++  # Warn if a thread-friendly DNS resolver isn't built.
    ++  { $as_echo "$as_me:${as_lineno-$LINENO}: checking for curl support for asynchronous DNS" >&5
    ++$as_echo_n "checking for curl support for asynchronous DNS... " >&6; }
    ++if ${pgac_cv__libcurl_async_dns+:} false; then :
    ++  $as_echo_n "(cached) " >&6
    ++else
    ++  if test "$cross_compiling" = yes; then :
    ++  pgac_cv__libcurl_async_dns=unknown
    ++else
    ++  cat confdefs.h - <<_ACEOF >conftest.$ac_ext
    ++/* end confdefs.h.  */
    ++
    ++#include <curl/curl.h>
    ++
    ++int
    ++main ()
    ++{
    ++
    ++    curl_version_info_data *info;
    ++
    ++    if (curl_global_init(CURL_GLOBAL_ALL))
    ++        return -1;
    ++
    ++    info = curl_version_info(CURLVERSION_NOW);
    ++    return (info->features & CURL_VERSION_ASYNCHDNS) ? 0 : 1;
    ++
    ++  ;
    ++  return 0;
    ++}
    ++_ACEOF
    ++if ac_fn_c_try_run "$LINENO"; then :
    ++  pgac_cv__libcurl_async_dns=yes
    ++else
    ++  pgac_cv__libcurl_async_dns=no
    ++fi
    ++rm -f core *.core core.conftest.* gmon.out bb.out conftest$ac_exeext \
    ++  conftest.$ac_objext conftest.beam conftest.$ac_ext
    ++fi
    ++
    ++fi
    ++{ $as_echo "$as_me:${as_lineno-$LINENO}: result: $pgac_cv__libcurl_async_dns" >&5
    ++$as_echo "$pgac_cv__libcurl_async_dns" >&6; }
    ++  if test x"$pgac_cv__libcurl_async_dns" != xyes ; then
    ++    { $as_echo "$as_me:${as_lineno-$LINENO}: WARNING:
    ++*** The installed version of libcurl does not support asynchronous DNS
    ++*** lookups. Connection timeouts will not be honored during DNS resolution,
    ++*** which may lead to hangs in client programs." >&5
    ++$as_echo "$as_me: WARNING:
    ++*** The installed version of libcurl does not support asynchronous DNS
    ++*** lookups. Connection timeouts will not be honored during DNS resolution,
    ++*** which may lead to hangs in client programs." >&2;}
    ++  fi
    ++
     +fi
     +
      if test "$with_gssapi" = yes ; then
    @@ doc/src/sgml/libpq.sgml: void PQinitSSL(int do_ssl);
     +{
     +    const char *verification_uri;   /* verification URI to visit */
     +    const char *user_code;          /* user code to enter */
    ++    const char *verification_uri_complete;  /* optional combination of URI and
    ++                                             * code, or NULL */
    ++    int         expires_in;         /* seconds until user code expires */
     +} PGpromptOAuthDevice;
     +</synopsis>
     +        </para>
    @@ doc/src/sgml/libpq.sgml: void PQinitSSL(int do_ssl);
     +         <link linkend="libpq-oauth-authdata-oauth-bearer-token">custom OAuth
     +         flow</link>, this authdata type will not be used.
     +        </para>
    ++        <para>
    ++         If a non-NULL <structfield>verification_uri_complete</structfield> is
    ++         provided, it may optionally be used for non-textual verification (for
    ++         example, by displaying a QR code). The URL and user code should still
    ++         be displayed to the end user in this case, because the code will be
    ++         manually confirmed by the provider, and the URL lets users continue
    ++         even if they can't use the non-textual method. Review the RFC's
    ++         <ulink url="https://datatracker.ietf.org/doc/html/rfc8628#section-3.3.1">notes
    ++         on non-textual verification</ulink>.
    ++        </para>
     +       </listitem>
     +      </varlistentry>
     +
    @@ meson.build: endif
     +    if libcurl_threadsafe_init
     +      cdata.set('HAVE_THREADSAFE_CURL_GLOBAL_INIT', 1)
     +    endif
    ++
    ++    # Warn if a thread-friendly DNS resolver isn't built.
    ++    libcurl_async_dns = false
    ++
    ++    if not meson.is_cross_build()
    ++      r = cc.run('''
    ++        #include <curl/curl.h>
    ++
    ++        int main(void)
    ++        {
    ++            curl_version_info_data *info;
    ++
    ++            if (curl_global_init(CURL_GLOBAL_ALL))
    ++                return -1;
    ++
    ++            info = curl_version_info(CURLVERSION_NOW);
    ++            return (info->features & CURL_VERSION_ASYNCHDNS) ? 0 : 1;
    ++        }''',
    ++        name: 'test for curl support for asynchronous DNS',
    ++        dependencies: libcurl,
    ++      )
    ++
    ++      assert(r.compiled())
    ++      if r.returncode() == 0
    ++        libcurl_async_dns = true
    ++      endif
    ++    endif
    ++
    ++    if not libcurl_async_dns
    ++      warning('''
    ++*** The installed version of libcurl does not support asynchronous DNS
    ++*** lookups. Connection timeouts will not be honored during DNS resolution,
    ++*** which may lead to hangs in client programs.''')
    ++    endif
     +  endif
     +
     +else
    @@ src/backend/libpq/auth-oauth.c (new)
     +						   char **output, int *outputlen, const char **logdetail);
     +
     +static void load_validator_library(const char *libname);
    -+static void shutdown_validator_library(int code, Datum arg);
    ++static void shutdown_validator_library(void *arg);
     +
     +static ValidatorModuleState *validator_module_state;
     +static const OAuthValidatorCallbacks *ValidatorCallbacks;
    @@ src/backend/libpq/auth-oauth.c (new)
     +load_validator_library(const char *libname)
     +{
     +	OAuthValidatorModuleInit validator_init;
    ++	MemoryContextCallback *mcb;
     +
     +	Assert(libname && *libname);
     +
    @@ src/backend/libpq/auth-oauth.c (new)
     +	if (ValidatorCallbacks->startup_cb != NULL)
     +		ValidatorCallbacks->startup_cb(validator_module_state);
     +
    -+	before_shmem_exit(shutdown_validator_library, 0);
    ++	/* Shut down the library before cleaning up its state. */
    ++	mcb = palloc0(sizeof(*mcb));
    ++	mcb->func = shutdown_validator_library;
    ++
    ++	MemoryContextRegisterResetCallback(CurrentMemoryContext, mcb);
     +}
     +
     +/*
     + * Call the validator module's shutdown callback, if one is provided. This is
    -+ * invoked via before_shmem_exit().
    ++ * invoked during memory context reset.
     + */
     +static void
    -+shutdown_validator_library(int code, Datum arg)
    ++shutdown_validator_library(void *arg)
     +{
     +	if (ValidatorCallbacks->shutdown_cb != NULL)
     +		ValidatorCallbacks->shutdown_cb(validator_module_state);
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +	char	   *device_code;
     +	char	   *user_code;
     +	char	   *verification_uri;
    ++	char	   *verification_uri_complete;
    ++	char	   *expires_in_str;
     +	char	   *interval_str;
     +
     +	/* Fields below are parsed from the corresponding string above. */
    ++	int			expires_in;
     +	int			interval;
     +};
     +
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +	free(authz->device_code);
     +	free(authz->user_code);
     +	free(authz->verification_uri);
    ++	free(authz->verification_uri_complete);
    ++	free(authz->expires_in_str);
     +	free(authz->interval_str);
     +}
     +
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +{
     +	enum OAuthStep step;		/* where are we in the flow? */
     +
    -+#ifdef HAVE_SYS_EPOLL_H
    -+	int			timerfd;		/* a timerfd for signaling async timeouts */
    -+#endif
    ++	int			timerfd;		/* descriptor for signaling async timeouts */
     +	pgsocket	mux;			/* the multiplexer socket containing all
     +								 * descriptors tracked by libcurl, plus the
     +								 * timerfd */
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +
     +	if (actx->mux != PGINVALID_SOCKET)
     +		close(actx->mux);
    -+#ifdef HAVE_SYS_EPOLL_H
     +	if (actx->timerfd >= 0)
     +		close(actx->timerfd);
    -+#endif
     +
     +	free(actx);
     +}
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +		/*
     +		 * We should never start parsing a new field while a previous one is
     +		 * still active.
    -+		 *
    -+		 * TODO: this code relies on assertions too much. We need to exit
    -+		 * sanely on internal logic errors, to avoid turning bugs into
    -+		 * vulnerabilities.
     +		 */
    -+		Assert(!ctx->active);
    ++		if (ctx->active)
    ++		{
    ++			Assert(false);
    ++			oauth_parse_set_error(ctx,
    ++								  "internal error: started field '%s' before field '%s' was finished",
    ++								  name, ctx->active->name);
    ++			return JSON_SEM_ACTION_FAILED;
    ++		}
     +
     +		while (field->name)
     +		{
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +	struct oauth_parse *ctx = state;
     +
     +	--ctx->nested;
    -+	if (!ctx->nested)
    -+		Assert(!ctx->active);	/* all fields should be fully processed */
    ++
    ++	/*
    ++	 * All fields should be fully processed by the end of the top-level
    ++	 * object.
    ++	 */
    ++	if (!ctx->nested && ctx->active)
    ++	{
    ++		Assert(false);
    ++		oauth_parse_set_error(ctx,
    ++							  "internal error: field '%s' still active at end of object",
    ++							  ctx->active->name);
    ++		return JSON_SEM_ACTION_FAILED;
    ++	}
     +
     +	return JSON_SUCCESS;
     +}
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +	if (ctx->active)
     +	{
     +		/*
    -+		 * This assumes that no target arrays can contain other arrays, which
    -+		 * we check in the array_start callback.
    ++		 * Clear the target (which should be an array inside the top-level
    ++		 * object). For this to be safe, no target arrays can contain other
    ++		 * arrays; we check for that in the array_start callback.
     +		 */
    -+		Assert(ctx->nested == 2);
    -+		Assert(ctx->active->type == JSON_TOKEN_ARRAY_START);
    ++		if (ctx->nested != 2 || ctx->active->type != JSON_TOKEN_ARRAY_START)
    ++		{
    ++			Assert(false);
    ++			oauth_parse_set_error(ctx,
    ++								  "internal error: found unexpected array end while parsing field '%s'",
    ++								  ctx->active->name);
    ++			return JSON_SEM_ACTION_FAILED;
    ++		}
     +
     +		ctx->active = NULL;
     +	}
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +
     +		if (field->type != JSON_TOKEN_ARRAY_START)
     +		{
    -+			Assert(ctx->nested == 1);
    -+			Assert(!*field->target.scalar);
    ++			/* Ensure that we're parsing the top-level keys... */
    ++			if (ctx->nested != 1)
    ++			{
    ++				Assert(false);
    ++				oauth_parse_set_error(ctx,
    ++									  "internal error: scalar target found at nesting level %d",
    ++									  ctx->nested);
    ++				return JSON_SEM_ACTION_FAILED;
    ++			}
    ++
    ++			/* ...and that a result has not already been set. */
    ++			if (*field->target.scalar)
    ++			{
    ++				Assert(false);
    ++				oauth_parse_set_error(ctx,
    ++									  "internal error: scalar field '%s' would be assigned twice",
    ++									  ctx->active->name);
    ++				return JSON_SEM_ACTION_FAILED;
    ++			}
     +
     +			*field->target.scalar = strdup(token);
     +			if (!*field->target.scalar)
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +		{
     +			struct curl_slist *temp;
     +
    -+			Assert(ctx->nested == 2);
    ++			/* The target array should be inside the top-level object. */
    ++			if (ctx->nested != 2)
    ++			{
    ++				Assert(false);
    ++				oauth_parse_set_error(ctx,
    ++									  "internal error: array member found at nesting level %d",
    ++									  ctx->nested);
    ++				return JSON_SEM_ACTION_FAILED;
    ++			}
     +
     +			/* Note that curl_slist_append() makes a copy of the token. */
     +			temp = curl_slist_append(*field->target.array, token);
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +}
     +
     +/*
    ++ * Parses a valid JSON number into a double. The input must have come from
    ++ * pg_parse_json(), so that we know the lexer has validated it; there's no
    ++ * in-band signal for invalid formats.
    ++ */
    ++static double
    ++parse_json_number(const char *s)
    ++{
    ++	double		parsed;
    ++	int			cnt;
    ++
    ++	/*
    ++	 * The JSON lexer has already validated the number, which is stricter than
    ++	 * the %f format, so we should be good to use sscanf().
    ++	 */
    ++	cnt = sscanf(s, "%lf", &parsed);
    ++
    ++	if (cnt != 1)
    ++	{
    ++		/*
    ++		 * Either the lexer screwed up or our assumption above isn't true, and
    ++		 * either way a developer needs to take a look.
    ++		 */
    ++		Assert(cnt == 1);
    ++		return 0;
    ++	}
    ++
    ++	return parsed;
    ++}
    ++
    ++/*
     + * Parses the "interval" JSON number, corresponding to the number of seconds to
     + * wait between token endpoint requests.
     + *
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     + * the result at a minimum of one. (Zero-second intervals would result in an
     + * expensive network polling loop.) Tests may remove the lower bound with
     + * PGOAUTHDEBUG, for improved performance.
    -+ *
    -+ * TODO: maybe clamp the upper bound too, based on the libpq timeout and/or the
    -+ * code expiration time?
     + */
     +static int
     +parse_interval(struct async_ctx *actx, const char *interval_str)
     +{
     +	double		parsed;
    -+	int			cnt;
    -+
    -+	/*
    -+	 * The JSON lexer has already validated the number, which is stricter than
    -+	 * the %f format, so we should be good to use sscanf().
    -+	 */
    -+	cnt = sscanf(interval_str, "%lf", &parsed);
    -+
    -+	if (cnt != 1)
    -+	{
    -+		/*
    -+		 * Either the lexer screwed up or our assumption above isn't true, and
    -+		 * either way a developer needs to take a look.
    -+		 */
    -+		Assert(cnt == 1);
    -+		return 1;				/* don't fall through in release builds */
    -+	}
     +
    ++	parsed = parse_json_number(interval_str);
     +	parsed = ceil(parsed);
     +
     +	if (parsed < 1)
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +}
     +
     +/*
    ++ * Parses the "expires_in" JSON number, corresponding to the number of seconds
    ++ * remaining in the lifetime of the device code request.
    ++ *
    ++ * Similar to parse_interval, but we have even fewer requirements for reasonable
    ++ * values since we don't use the expiration time directly (it's passed to the
    ++ * PQAUTHDATA_PROMPT_OAUTH_DEVICE hook, in case the application wants to do
    ++ * something with it). We simply round and clamp to int range.
    ++ */
    ++static int
    ++parse_expires_in(struct async_ctx *actx, const char *expires_in_str)
    ++{
    ++	double		parsed;
    ++
    ++	parsed = parse_json_number(expires_in_str);
    ++	parsed = round(parsed);
    ++
    ++	if (INT_MAX <= parsed)
    ++		return INT_MAX;
    ++	else if (parsed <= INT_MIN)
    ++		return INT_MIN;
    ++
    ++	return parsed;
    ++}
    ++
    ++/*
     + * Parses the Device Authorization Response (RFC 8628, Sec. 3.2).
     + */
     +static bool
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +		{"device_code", JSON_TOKEN_STRING, {&authz->device_code}, REQUIRED},
     +		{"user_code", JSON_TOKEN_STRING, {&authz->user_code}, REQUIRED},
     +		{"verification_uri", JSON_TOKEN_STRING, {&authz->verification_uri}, REQUIRED},
    ++		{"expires_in", JSON_TOKEN_NUMBER, {&authz->expires_in_str}, REQUIRED},
     +
     +		/*
     +		 * Some services (Google, Azure) spell verification_uri differently.
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +		 */
     +		{"verification_url", JSON_TOKEN_STRING, {&authz->verification_uri}, REQUIRED},
     +
    -+		/*
    -+		 * The following fields are technically REQUIRED, but we don't use
    -+		 * them anywhere yet:
    -+		 *
    -+		 * - expires_in
    -+		 */
    -+
    ++		{"verification_uri_complete", JSON_TOKEN_STRING, {&authz->verification_uri_complete}, OPTIONAL},
     +		{"interval", JSON_TOKEN_NUMBER, {&authz->interval_str}, OPTIONAL},
     +
     +		{0},
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +		authz->interval = 5;
     +	}
     +
    ++	Assert(authz->expires_in_str);	/* ensured by parse_oauth_json() */
    ++	authz->expires_in = parse_expires_in(actx, authz->expires_in_str);
    ++
     +	return true;
     +}
     +
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +		{"access_token", JSON_TOKEN_STRING, {&tok->access_token}, REQUIRED},
     +		{"token_type", JSON_TOKEN_STRING, {&tok->token_type}, REQUIRED},
     +
    -+		/*
    -+		 * The following fields are technically REQUIRED, but we don't use
    -+		 * them anywhere yet:
    ++		/*---
    ++		 * We currently have no use for the following OPTIONAL fields:
     +		 *
    -+		 * - scope (only required if different than requested -- TODO check)
    ++		 * - expires_in: This will be important for maintaining a token cache,
    ++		 *               but we do not yet implement one.
    ++		 *
    ++		 * - refresh_token: Ditto.
    ++		 *
    ++		 * - scope: This is only sent when the authorization server sees fit to
    ++		 *          change our scope request. It's not clear what we should do
    ++		 *          about this; either it's been done as a matter of policy, or
    ++		 *          the user has explicitly denied part of the authorization,
    ++		 *          and either way the server-side validator is in a better
    ++		 *          place to complain if the change isn't acceptable.
     +		 */
     +
     +		{0},
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     + * select() on instead of the Postgres socket during OAuth negotiation.
     + *
     + * This is just an epoll set or kqueue abstracting multiple other descriptors.
    -+ * A timerfd is always part of the set when using epoll; it's just disabled
    -+ * when we're not using it.
    ++ * For epoll, the timerfd is always part of the set; it's just disabled when
    ++ * we're not using it. For kqueue, the "timerfd" is actually a second kqueue
    ++ * instance which is only added to the set when needed.
     + */
     +static bool
     +setup_multiplexer(struct async_ctx *actx)
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +		return false;
     +	}
     +
    ++	/*
    ++	 * Originally, we set EVFILT_TIMER directly on the top-level multiplexer.
    ++	 * This makes it difficult to implement timer_expired(), though, so now we
    ++	 * set EVFILT_TIMER on a separate actx->timerfd, which is chained to
    ++	 * actx->mux while the timer is active.
    ++	 */
    ++	actx->timerfd = kqueue();
    ++	if (actx->timerfd < 0)
    ++	{
    ++		actx_error(actx, "failed to create timer kqueue: %m");
    ++		return false;
    ++	}
    ++
     +	return true;
     +#endif
     +
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +
     +/*
     + * Enables or disables the timer in the multiplexer set. The timeout value is
    -+ * in milliseconds (negative values disable the timer). Rather than continually
    -+ * adding and removing the timer, we keep it in the set at all times and just
    -+ * disarm it when it's not needed.
    ++ * in milliseconds (negative values disable the timer).
    ++ *
    ++ * For epoll, rather than continually adding and removing the timer, we keep it
    ++ * in the set at all times and just disarm it when it's not needed. For kqueue,
    ++ * the timer is removed completely when disabled to prevent stale timeouts from
    ++ * remaining in the queue.
     + */
     +static bool
     +set_timer(struct async_ctx *actx, long timeout)
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +		actx_error(actx, "setting timerfd to %ld: %m", timeout);
     +		return false;
     +	}
    ++
    ++	return true;
     +#endif
     +#ifdef HAVE_SYS_EVENT_H
     +	struct kevent ev;
     +
    -+	EV_SET(&ev, 1, EVFILT_TIMER, timeout < 0 ? EV_DELETE : EV_ADD,
    ++	/* Enable/disable the timer itself. */
    ++	EV_SET(&ev, 1, EVFILT_TIMER, timeout < 0 ? EV_DELETE : (EV_ADD | EV_ONESHOT),
     +		   0, timeout, 0);
    -+	if (kevent(actx->mux, &ev, 1, NULL, 0, NULL) < 0 && errno != ENOENT)
    ++	if (kevent(actx->timerfd, &ev, 1, NULL, 0, NULL) < 0 && errno != ENOENT)
     +	{
     +		actx_error(actx, "setting kqueue timer to %ld: %m", timeout);
     +		return false;
     +	}
    -+#endif
    ++
    ++	/*
    ++	 * Add/remove the timer to/from the mux. (In contrast with epoll, if we
    ++	 * allowed the timer to remain registered here after being disabled, the
    ++	 * mux queue would retain any previous stale timeout notifications and
    ++	 * remain readable.)
    ++	 */
    ++	EV_SET(&ev, actx->timerfd, EVFILT_READ, timeout < 0 ? EV_DELETE : EV_ADD,
    ++		   0, 0, 0);
    ++	if (kevent(actx->mux, &ev, 1, NULL, 0, NULL) < 0 && errno != ENOENT)
    ++	{
    ++		actx_error(actx, "could not update timer on kqueue: %m");
    ++		return false;
    ++	}
     +
     +	return true;
    ++#endif
    ++
    ++	actx_error(actx, "libpq does not support timers on this platform");
    ++	return false;
    ++}
    ++
    ++/*
    ++ * Returns 1 if the timeout in the multiplexer set has expired since the last
    ++ * call to set_timer(), 0 if the timer is still running, or -1 (with an
    ++ * actx_error() report) if the timer cannot be queried.
    ++ */
    ++static int
    ++timer_expired(struct async_ctx *actx)
    ++{
    ++#if HAVE_SYS_EPOLL_H
    ++	struct itimerspec spec = {0};
    ++
    ++	if (timerfd_gettime(actx->timerfd, &spec) < 0)
    ++	{
    ++		actx_error(actx, "getting timerfd value: %m");
    ++		return -1;
    ++	}
    ++
    ++	/*
    ++	 * This implementation assumes we're using single-shot timers. If you
    ++	 * change to using intervals, you'll need to reimplement this function
    ++	 * too, possibly with the read() or select() interfaces for timerfd.
    ++	 */
    ++	Assert(spec.it_interval.tv_sec == 0
    ++		   && spec.it_interval.tv_nsec == 0);
    ++
    ++	/* If the remaining time to expiration is zero, we're done. */
    ++	return (spec.it_value.tv_sec == 0
    ++			&& spec.it_value.tv_nsec == 0);
    ++#endif
    ++#ifdef HAVE_SYS_EVENT_H
    ++	int			res;
    ++
    ++	/* Is the timer queue ready? */
    ++	res = PQsocketPoll(actx->timerfd, 1 /* forRead */ , 0, 0);
    ++	if (res < 0)
    ++	{
    ++		actx_error(actx, "checking kqueue for timeout: %m");
    ++		return -1;
    ++	}
    ++
    ++	return (res > 0);
    ++#endif
    ++
    ++	actx_error(actx, "libpq does not support timers on this platform");
    ++	return -1;
     +}
     +
     +/*
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +	struct async_ctx *actx = ctx;
     +
     +	/*
    -+	 * TODO: maybe just signal drive_request() to immediately call back in the
    -+	 * (timeout == 0) case?
    ++	 * There might be an optimization opportunity here: if timeout == 0, we
    ++	 * could signal drive_request to immediately call
    ++	 * curl_multi_socket_action, rather than returning all the way up the
    ++	 * stack only to come right back. But it's not clear that the additional
    ++	 * code complexity is worth it.
     +	 */
     +	if (!set_timer(actx, timeout))
     +		return -1;				/* actx_error already called */
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +debug_callback(CURL *handle, curl_infotype type, char *data, size_t size,
     +			   void *clientp)
     +{
    -+	const char *const end = data + size;
     +	const char *prefix;
    ++	bool		printed_prefix = false;
    ++	PQExpBufferData buf;
     +
     +	/* Prefixes are modeled off of the default libcurl debug output. */
     +	switch (type)
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +			return 0;
     +	}
     +
    ++	initPQExpBuffer(&buf);
    ++
     +	/*
     +	 * Split the output into lines for readability; sometimes multiple headers
    -+	 * are included in a single call.
    ++	 * are included in a single call. We also don't allow unprintable ASCII
    ++	 * through without a basic <XX> escape.
     +	 */
    -+	while (data < end)
    ++	for (int i = 0; i < size; i++)
     +	{
    -+		size_t		len = end - data;
    -+		char	   *eol = memchr(data, '\n', len);
    ++		char		c = data[i];
     +
    -+		if (eol)
    -+			len = eol - data + 1;
    ++		if (!printed_prefix)
    ++		{
    ++			appendPQExpBuffer(&buf, "%s ", prefix);
    ++			printed_prefix = true;
    ++		}
     +
    -+		/* TODO: handle unprintables */
    -+		fprintf(stderr, "%s %.*s%s", prefix, (int) len, data,
    -+				eol ? "" : "\n");
    ++		if (c >= 0x20 && c <= 0x7E)
    ++			appendPQExpBufferChar(&buf, c);
    ++		else if ((type == CURLINFO_HEADER_IN
    ++				  || type == CURLINFO_HEADER_OUT
    ++				  || type == CURLINFO_TEXT)
    ++				 && (c == '\r' || c == '\n'))
    ++		{
    ++			/*
    ++			 * Don't bother emitting <0D><0A> for headers and text; it's not
    ++			 * helpful noise.
    ++			 */
    ++		}
    ++		else
    ++			appendPQExpBuffer(&buf, "<%02X>", c);
     +
    -+		data += len;
    ++		if (c == '\n')
    ++		{
    ++			appendPQExpBufferChar(&buf, c);
    ++			printed_prefix = false;
    ++		}
     +	}
     +
    ++	if (printed_prefix)
    ++		appendPQExpBufferChar(&buf, '\n');	/* finish the line */
    ++
    ++	fprintf(stderr, "%s", buf.data);
    ++	termPQExpBuffer(&buf);
     +	return 0;
     +}
     +
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +static bool
     +setup_curl_handles(struct async_ctx *actx)
     +{
    -+	curl_version_info_data *curl_info;
    -+
     +	/*
     +	 * Create our multi handle. This encapsulates the entire conversation with
     +	 * libcurl for this connection.
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +	}
     +
     +	/*
    -+	 * Extract information about the libcurl we are linked against.
    -+	 */
    -+	curl_info = curl_version_info(CURLVERSION_NOW);
    -+
    -+	/*
     +	 * The multi handle tells us what to wait on using two callbacks. These
     +	 * will manipulate actx->mux as needed.
     +	 */
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +	 * Multi-threaded applications must set CURLOPT_NOSIGNAL. This requires us
     +	 * to handle the possibility of SIGPIPE ourselves using pq_block_sigpipe;
     +	 * see pg_fe_run_oauth_flow().
    ++	 *
    ++	 * NB: If libcurl is not built against a friendly DNS resolver (c-ares or
    ++	 * threaded), setting this option prevents DNS lookups from timing out
    ++	 * correctly. We warn about this situation at configure time.
     +	 */
     +	CHECK_SETOPT(actx, CURLOPT_NOSIGNAL, 1L, return false);
    -+	if (!curl_info->ares_num)
    -+	{
    -+		/* No alternative resolver, TODO: warn about timeouts */
    -+	}
     +
     +	if (actx->debugging)
     +	{
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +		CHECK_SETOPT(actx, popt, protos, return false);
     +	}
     +
    -+	/* TODO: would anyone use this in "real" situations, or just testing? */
    ++	/*
    ++	 * If we're in debug mode, allow the developer to change the trusted CA
    ++	 * list. For now, this is not something we expose outside of the UNSAFE
    ++	 * mode, because it's not clear that it's useful in production: both libpq
    ++	 * and the user's browser must trust the same authorization servers for
    ++	 * the flow to work at all, so any changes to the roots are likely to be
    ++	 * done system-wide.
    ++	 */
     +	if (actx->debugging)
     +	{
     +		const char *env;
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +	 *    of the authorization server where the authorization request was
     +	 *    sent to. This comparison MUST use simple string comparison as defined
     +	 *    in Section 6.2.1 of [RFC3986].
    -+	 *
    -+	 * TODO: Encoding support?
     +	 */
     +	if (strcmp(conn->oauth_issuer_id, provider->issuer) != 0)
     +	{
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +	PGpromptOAuthDevice prompt = {
     +		.verification_uri = actx->authz.verification_uri,
     +		.user_code = actx->authz.user_code,
    -+		/* TODO: optional fields */
    ++		.verification_uri_complete = actx->authz.verification_uri_complete,
    ++		.expires_in = actx->authz.expires_in,
     +	};
     +
     +	res = PQauthDataHook(PQAUTHDATA_PROMPT_OAUTH_DEVICE, conn, &prompt);
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +		}
     +
     +		actx->mux = PGINVALID_SOCKET;
    -+#ifdef HAVE_SYS_EPOLL_H
     +		actx->timerfd = -1;
    -+#endif
     +
     +		/* Should we enable unsafe features? */
     +		actx->debugging = oauth_unsafe_debugging_enabled();
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +						/* not done yet */
     +						return status;
     +					}
    ++
    ++					break;
     +				}
     +
     +			case OAUTH_STEP_WAIT_INTERVAL:
    -+				/* TODO check that the timer has expired */
    ++
    ++				/*
    ++				 * The client application is supposed to wait until our timer
    ++				 * expires before calling PQconnectPoll() again, but that
    ++				 * might not happen. To avoid sending a token request early,
    ++				 * check the timer before continuing.
    ++				 */
    ++				if (!timer_expired(actx))
    ++				{
    ++					conn->altsock = actx->timerfd;
    ++					return PGRES_POLLING_READING;
    ++				}
    ++
    ++				/* Disable the expired timer. */
    ++				if (!set_timer(actx, -1))
    ++					goto error_return;
    ++
     +				break;
     +		}
     +
    @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
     +				if (!set_timer(actx, actx->authz.interval * 1000))
     +					goto error_return;
     +
    -+#ifdef HAVE_SYS_EPOLL_H
    -+
     +				/*
     +				 * No Curl requests are running, so we can simplify by having
     +				 * the client wait directly on the timerfd rather than the
    -+				 * multiplexer. (This isn't possible for kqueue.)
    ++				 * multiplexer.
     +				 */
     +				conn->altsock = actx->timerfd;
    -+#endif
     +
     +				actx->step = OAUTH_STEP_WAIT_INTERVAL;
     +				actx->running = 1;
    @@ src/interfaces/libpq/fe-auth-oauth.c (new)
     +{
     +	struct json_ctx *ctx = state;
     +
    ++	/* Only top-level keys are considered. */
     +	if (ctx->nested == 1)
     +	{
     +		if (strcmp(name, ERROR_STATUS_FIELD) == 0)
    @@ src/interfaces/libpq/fe-auth-oauth.c (new)
     +
     +	if (ctx->target_field)
     +	{
    -+		Assert(ctx->nested == 1);
    ++		if (ctx->nested != 1)
    ++		{
    ++			/*
    ++			 * ctx->target_field should not have been set for nested keys.
    ++			 * Assert and don't continue any further for production builds.
    ++			 */
    ++			Assert(false);
    ++			oauth_json_set_error(ctx,	/* don't bother translating */
    ++								 "internal error: target scalar found at nesting level %d during OAUTHBEARER parsing",
    ++								 ctx->nested);
    ++			return JSON_SEM_ACTION_FAILED;
    ++		}
     +
     +		/*
     +		 * We don't allow duplicate field names; error out if the target has
    @@ src/interfaces/libpq/libpq-fe.h: extern int	PQenv2encoding(void);
     +{
     +	const char *verification_uri;	/* verification URI to visit */
     +	const char *user_code;		/* user code to enter */
    ++	const char *verification_uri_complete;	/* optional combination of URI and
    ++											 * code, or NULL */
    ++	int			expires_in;		/* seconds until user code expires */
     +} PGpromptOAuthDevice;
     +
     +/* for PGoauthBearerRequest.async() */
    @@ src/test/modules/oauth_validator/oauth_hook_client.c (new)
     +	printf(" --expected-uri URI		fail if received configuration link does not match URI\n");
     +	printf(" --misbehave=MODE		have the hook fail required postconditions\n"
     +		   "						(MODEs: no-hook, fail-async, no-token, no-socket)\n");
    -+	printf(" --no-hook				don't install OAuth hooks (connection will fail)\n");
    ++	printf(" --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(" --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_scope = NULL;
     +static const char *misbehave_mode = NULL;
    @@ src/test/modules/oauth_validator/oauth_hook_client.c (new)
     +		{"token", required_argument, NULL, 1003},
     +		{"hang-forever", no_argument, NULL, 1004},
     +		{"misbehave", required_argument, NULL, 1005},
    ++		{"stress-async", no_argument, NULL, 1006},
     +		{0}
     +	};
     +
    @@ src/test/modules/oauth_validator/oauth_hook_client.c (new)
     +				misbehave_mode = optarg;
     +				break;
     +
    ++			case 1006:			/* --stress-async */
    ++				stress_async = true;
    ++				break;
    ++
     +			default:
     +				usage(argv);
     +				return 1;
    @@ src/test/modules/oauth_validator/oauth_hook_client.c (new)
     +	PQsetAuthDataHook(handle_auth_data);
     +
     +	/* Connect. (All the actual work is in the hook.) */
    -+	conn = PQconnectdb(conninfo);
    ++	if (stress_async)
    ++	{
    ++		/*
    ++		 * Perform an asynchronous connection, busy-looping on PQconnectPoll()
    ++		 * without actually waiting on socket events. This stresses code paths
    ++		 * that rely on asynchronous work to be done before continuing with
    ++		 * the next step in the flow.
    ++		 */
    ++		PostgresPollingStatusType res;
    ++
    ++		conn = PQconnectStart(conninfo);
    ++
    ++		do
    ++		{
    ++			res = PQconnectPoll(conn);
    ++		} while (res != PGRES_POLLING_FAILED && res != PGRES_POLLING_OK);
    ++	}
    ++	else
    ++	{
    ++		/* Perform a standard synchronous connection. */
    ++		conn = PQconnectdb(conninfo);
    ++	}
    ++
     +	if (PQstatus(conn) != CONNECTION_OK)
     +	{
    -+		fprintf(stderr, "Connection to database failed: %s\n",
    ++		fprintf(stderr, "connection to database failed: %s\n",
     +				PQerrorMessage(conn));
     +		PQfinish(conn);
     +		return 1;
    @@ src/test/modules/oauth_validator/t/001_server.pl (new)
     +	  qr/failed to obtain access token: mutual TLS required for client \(invalid_client\)/
     +);
     +
    ++# Stress test: make sure our builtin flow operates correctly even if the client
    ++# application isn't respecting PGRES_POLLING_READING/WRITING signals returned
    ++# from PQconnectPoll().
    ++$base_connstr =
    ++  "$common_connstr port=" . $node->port . " host=" . $node->host;
    ++my @cmd = (
    ++	"oauth_hook_client", "--no-hook", "--stress-async",
    ++	connstr(stage => 'all', retries => 1, interval => 1));
    ++
    ++note "running '" . join("' '", @cmd) . "'";
    ++my ($stdout, $stderr) = run_command(\@cmd);
    ++
    ++like($stdout, qr/connection succeeded/, "stress-async: stdout matches");
    ++unlike($stderr, qr/connection to database failed/, "stress-async: stderr matches");
    ++
     +#
     +# This section of tests reconfigures the validator module itself, rather than
     +# the OAuth server.
    @@ src/test/modules/oauth_validator/t/oauth_server.py (new)
     +            "device_code": "postgres",
     +            "user_code": "postgresuser",
     +            self._uri_spelling: uri,
    -+            "expires-in": 5,
    ++            "expires_in": 5,
     +            **self._response_padding,
     +        }
     +
    @@ src/test/modules/oauth_validator/validator.c (new)
     +{
     +	/* Check to make sure our private state still exists. */
     +	if (state->private_data != PRIVATE_COOKIE)
    -+		elog(ERROR, "oauth_validator: private state cookie changed to %p in shutdown",
    ++		elog(PANIC, "oauth_validator: private state cookie changed to %p in shutdown",
     +			 state->private_data);
     +}
     +
 2:  483129c1ca9 <  -:  ----------- fixup! Add OAUTHBEARER SASL mechanism
 3:  75d98784ded <  -:  ----------- fixup! Add OAUTHBEARER SASL mechanism
 4:  fd60ceb4c84 <  -:  ----------- fixup! Add OAUTHBEARER SASL mechanism
 5:  595362ef2c1 <  -:  ----------- fixup! Add OAUTHBEARER SASL mechanism
 6:  f73c042adc9 <  -:  ----------- fixup! Add OAUTHBEARER SASL mechanism
 7:  298839b69f0 <  -:  ----------- fixup! Add OAUTHBEARER SASL mechanism
 8:  1cf48a8f835 <  -:  ----------- fixup! Add OAUTHBEARER SASL mechanism
 9:  27135876559 <  -:  ----------- fixup! Add OAUTHBEARER SASL mechanism
 -:  ----------- >  2:  9171989a75e fixup! Add OAUTHBEARER SASL mechanism
 -:  ----------- >  3:  1bd03e1de10 fixup! Add OAUTHBEARER SASL mechanism
 -:  ----------- >  4:  0929bfbc5fc fixup! Add OAUTHBEARER SASL mechanism
 -:  ----------- >  5:  be882ef6eae fixup! Add OAUTHBEARER SASL mechanism
 -:  ----------- >  6:  954341052b4 fixup! Add OAUTHBEARER SASL mechanism
10:  d8c1f298080 =  7:  d88a2938e7e XXX fix libcurl link error
11:  dbf305d0489 !  8:  44e5cbc8ad1 DO NOT MERGE: Add pytest suite for OAuth
    @@ src/test/python/client/test_oauth.py (new)
     +            id="invalid request without description",
     +        ),
     +        pytest.param(
    -+            (400, {"error": "invalid_request", "padding": "x" * 1024 * 1024}),
    ++            (400, {"error": "invalid_request", "padding": "x" * 256 * 1024}),
     +            r"failed to obtain device authorization: response is too large",
     +            id="gigantic authz response",
     +        ),
    @@ src/test/python/client/test_oauth.py (new)
     +            id="access denied without description",
     +        ),
     +        pytest.param(
    -+            (400, {"error": "access_denied", "padding": "x" * 1024 * 1024}),
    ++            (400, {"error": "access_denied", "padding": "x" * 256 * 1024}),
     +            r"failed to obtain access token: response is too large",
     +            id="gigantic token response",
     +        ),
    @@ src/test/python/client/test_oauth.py (new)
     +                        "urn:ietf:params:oauth:grant-type:device_code"
     +                    ],
     +                    "device_authorization_endpoint": "https://256.256.256.256/dev",
    -+                    "filler": "x" * 1024 * 1024,
    ++                    "filler": "x" * 256 * 1024,
     +                },
     +            ),
     +            r"failed to fetch OpenID discovery document: response is too large",
    @@ src/test/python/server/oauthtest.c (new)
     +
     +static void test_startup(ValidatorModuleState *state);
     +static void test_shutdown(ValidatorModuleState *state);
    -+static ValidatorModuleResult *test_validate(ValidatorModuleState *state,
    -+											const char *token,
    -+											const char *role);
    ++static bool test_validate(const ValidatorModuleState *state,
    ++						  const char *token,
    ++						  const char *role,
    ++						  ValidatorModuleResult *result);
     +
     +static const OAuthValidatorCallbacks callbacks = {
    ++	PG_OAUTH_VALIDATOR_MAGIC,
    ++
     +	.startup_cb = test_startup,
     +	.shutdown_cb = test_shutdown,
     +	.validate_cb = test_validate,
    @@ src/test/python/server/oauthtest.c (new)
     +{
     +}
     +
    -+static ValidatorModuleResult *
    -+test_validate(ValidatorModuleState *state, const char *token, const char *role)
    ++static bool
    ++test_validate(const ValidatorModuleState *state,
    ++			  const char *token, const char *role,
    ++			  ValidatorModuleResult *res)
     +{
    -+	ValidatorModuleResult *res;
    -+
    -+	res = palloc0(sizeof(ValidatorModuleResult));	/* TODO: palloc context? */
    -+
     +	if (reflect_role)
     +	{
     +		res->authorized = true;
    -+		res->authn_id = pstrdup(role);	/* TODO: constify? */
    ++		res->authn_id = pstrdup(role);
     +	}
     +	else
     +	{
     +		if (*expected_bearer && strcmp(token, expected_bearer) == 0)
     +			res->authorized = true;
     +		if (set_authn_id)
    -+			res->authn_id = authn_id;
    ++			res->authn_id = pstrdup(authn_id);
     +	}
     +
    -+	return res;
    ++	return true;
     +}
     
      ## src/test/python/server/test_oauth.py (new) ##

  [application/x-patch] v48-0001-Add-OAUTHBEARER-SASL-mechanism.patch (312.7K, ../../CAOYmi+mFZsExAYWy-bS9=yki_fWC+MC0MufpEZ_SycrbCAJrEg@mail.gmail.com/3-v48-0001-Add-OAUTHBEARER-SASL-mechanism.patch)
  download | inline diff:
From cf86e3bfbbc790147073a75fda5ff19aaed03b88 Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Wed, 23 Oct 2024 09:37:33 -0700
Subject: [PATCH v48 1/8] Add OAUTHBEARER SASL mechanism

DO NOT USE THIS PROOF OF CONCEPT IN PRODUCTION.

Implement OAUTHBEARER (RFC 7628) and OAuth 2.0 Device Authorization
Grants (RFC 8628). This adds a new auth method, oauth, to pg_hba. When
speaking to a OAuth-enabled server, it looks a bit like this:

    $ psql 'host=example.org oauth_issuer=... oauth_client_id=...'
    Visit https://oauth.example.org/login and enter the code: FPQ2-M4BG

The OAuth issuer must support device authorization. No other OAuth flows
are currently implemented (but clients may provide their own flows).

The client implementation requires libcurl and its development headers.
Pass --with-libcurl/-Dlibcurl=enabled during configuration. The server
implementation does not require additional build-time dependencies, but
an external validator module must be supplied.

Thomas Munro wrote the kqueue() implementation for oauth-curl; thanks!

Several TODOs:
- perform several sanity checks on the OAuth issuer's responses
- improve error debuggability during the OAuth handshake
- fix libcurl initialization thread-safety
- harden the libcurl flow implementation
- fill in documentation stubs
- support protocol "variants" implemented by major providers
- implement more helpful handling of HBA misconfigurations
- use logdetail during auth failures
- ...and more.

Co-authored-by: Daniel Gustafsson <[email protected]>
---
 .cirrus.tasks.yml                             |   15 +-
 config/programs.m4                            |   65 +
 configure                                     |  332 ++
 configure.ac                                  |   41 +
 doc/src/sgml/client-auth.sgml                 |  252 ++
 doc/src/sgml/config.sgml                      |   26 +
 doc/src/sgml/filelist.sgml                    |    1 +
 doc/src/sgml/installation.sgml                |   27 +
 doc/src/sgml/libpq.sgml                       |  406 +++
 doc/src/sgml/oauth-validators.sgml            |  402 +++
 doc/src/sgml/postgres.sgml                    |    1 +
 doc/src/sgml/protocol.sgml                    |  133 +-
 doc/src/sgml/regress.sgml                     |   10 +
 meson.build                                   |  100 +
 meson_options.txt                             |    3 +
 src/Makefile.global.in                        |    1 +
 src/backend/libpq/Makefile                    |    1 +
 src/backend/libpq/auth-oauth.c                |  865 +++++
 src/backend/libpq/auth.c                      |   10 +-
 src/backend/libpq/hba.c                       |   64 +-
 src/backend/libpq/meson.build                 |    1 +
 src/backend/libpq/pg_hba.conf.sample          |    4 +-
 src/backend/utils/adt/hbafuncs.c              |   19 +
 src/backend/utils/misc/guc_tables.c           |   12 +
 src/backend/utils/misc/postgresql.conf.sample |    3 +
 src/include/common/oauth-common.h             |   19 +
 src/include/libpq/auth.h                      |    1 +
 src/include/libpq/hba.h                       |    7 +-
 src/include/libpq/oauth.h                     |   54 +
 src/include/pg_config.h.in                    |    9 +
 src/interfaces/libpq/Makefile                 |   11 +-
 src/interfaces/libpq/exports.txt              |    3 +
 src/interfaces/libpq/fe-auth-oauth-curl.c     | 2850 +++++++++++++++++
 src/interfaces/libpq/fe-auth-oauth.c          | 1153 +++++++
 src/interfaces/libpq/fe-auth-oauth.h          |   45 +
 src/interfaces/libpq/fe-auth.c                |   36 +-
 src/interfaces/libpq/fe-auth.h                |    3 +
 src/interfaces/libpq/fe-connect.c             |   48 +-
 src/interfaces/libpq/libpq-fe.h               |   85 +
 src/interfaces/libpq/libpq-int.h              |   13 +-
 src/interfaces/libpq/meson.build              |    5 +
 src/makefiles/meson.build                     |    1 +
 src/test/authentication/t/001_password.pl     |    8 +-
 src/test/modules/Makefile                     |    1 +
 src/test/modules/meson.build                  |    1 +
 src/test/modules/oauth_validator/.gitignore   |    4 +
 src/test/modules/oauth_validator/Makefile     |   40 +
 src/test/modules/oauth_validator/README       |   13 +
 .../modules/oauth_validator/fail_validator.c  |   42 +
 src/test/modules/oauth_validator/meson.build  |   69 +
 .../oauth_validator/oauth_hook_client.c       |  293 ++
 .../modules/oauth_validator/t/001_server.pl   |  566 ++++
 .../modules/oauth_validator/t/002_client.pl   |  154 +
 .../modules/oauth_validator/t/OAuth/Server.pm |  140 +
 .../modules/oauth_validator/t/oauth_server.py |  391 +++
 src/test/modules/oauth_validator/validator.c  |  135 +
 src/test/perl/PostgreSQL/Test/Cluster.pm      |   20 +-
 src/tools/pgindent/pgindent                   |   14 +
 src/tools/pgindent/typedefs.list              |   11 +
 59 files changed, 9000 insertions(+), 39 deletions(-)
 create mode 100644 doc/src/sgml/oauth-validators.sgml
 create mode 100644 src/backend/libpq/auth-oauth.c
 create mode 100644 src/include/common/oauth-common.h
 create mode 100644 src/include/libpq/oauth.h
 create mode 100644 src/interfaces/libpq/fe-auth-oauth-curl.c
 create mode 100644 src/interfaces/libpq/fe-auth-oauth.c
 create mode 100644 src/interfaces/libpq/fe-auth-oauth.h
 create mode 100644 src/test/modules/oauth_validator/.gitignore
 create mode 100644 src/test/modules/oauth_validator/Makefile
 create mode 100644 src/test/modules/oauth_validator/README
 create mode 100644 src/test/modules/oauth_validator/fail_validator.c
 create mode 100644 src/test/modules/oauth_validator/meson.build
 create mode 100644 src/test/modules/oauth_validator/oauth_hook_client.c
 create mode 100644 src/test/modules/oauth_validator/t/001_server.pl
 create mode 100644 src/test/modules/oauth_validator/t/002_client.pl
 create mode 100644 src/test/modules/oauth_validator/t/OAuth/Server.pm
 create mode 100755 src/test/modules/oauth_validator/t/oauth_server.py
 create mode 100644 src/test/modules/oauth_validator/validator.c

diff --git a/.cirrus.tasks.yml b/.cirrus.tasks.yml
index cfe2117e02e..c192a077701 100644
--- a/.cirrus.tasks.yml
+++ b/.cirrus.tasks.yml
@@ -23,7 +23,7 @@ env:
   MTEST_ARGS: --print-errorlogs --no-rebuild -C build
   PGCTLTIMEOUT: 120 # avoids spurious failures during parallel tests
   TEMP_CONFIG: ${CIRRUS_WORKING_DIR}/src/tools/ci/pg_ci_base.conf
-  PG_TEST_EXTRA: kerberos ldap ssl libpq_encryption load_balance
+  PG_TEST_EXTRA: kerberos ldap ssl libpq_encryption load_balance oauth
 
 
 # What files to preserve in case tests fail
@@ -167,7 +167,7 @@ task:
     chown root:postgres /tmp/cores
     sysctl kern.corefile='/tmp/cores/%N.%P.core'
   setup_additional_packages_script: |
-    #pkg install -y ...
+    pkg install -y curl
 
   # NB: Intentionally build without -Dllvm. The freebsd image size is already
   # large enough to make VM startup slow, and even without llvm freebsd
@@ -222,6 +222,7 @@ LINUX_CONFIGURE_FEATURES: &LINUX_CONFIGURE_FEATURES >-
   --with-gssapi
   --with-icu
   --with-ldap
+  --with-libcurl
   --with-libxml
   --with-libxslt
   --with-llvm
@@ -315,8 +316,10 @@ task:
     EOF
 
   setup_additional_packages_script: |
-    #apt-get update
-    #DEBIAN_FRONTEND=noninteractive apt-get -y install ...
+    apt-get update
+    DEBIAN_FRONTEND=noninteractive apt-get -y install \
+      libcurl4-openssl-dev \
+      libcurl4-openssl-dev:i386 \
 
   matrix:
     - name: Linux - Debian Bookworm - Autoconf
@@ -692,8 +695,8 @@ task:
     folder: $CCACHE_DIR
 
   setup_additional_packages_script: |
-    #apt-get update
-    #DEBIAN_FRONTEND=noninteractive apt-get -y install ...
+    apt-get update
+    DEBIAN_FRONTEND=noninteractive apt-get -y install libcurl4-openssl-dev
 
   ###
   # Test that code can be built with gcc/clang without warnings
diff --git a/config/programs.m4 b/config/programs.m4
index 7b55c2664a6..ead427046f5 100644
--- a/config/programs.m4
+++ b/config/programs.m4
@@ -274,3 +274,68 @@ AC_DEFUN([PGAC_CHECK_STRIP],
   AC_SUBST(STRIP_STATIC_LIB)
   AC_SUBST(STRIP_SHARED_LIB)
 ])# PGAC_CHECK_STRIP
+
+
+
+# PGAC_CHECK_LIBCURL
+# ------------------
+# Check for required libraries and headers, and test to see whether the current
+# installation of libcurl is threadsafe.
+
+AC_DEFUN([PGAC_CHECK_LIBCURL],
+[
+  AC_CHECK_HEADER(curl/curl.h, [],
+				  [AC_MSG_ERROR([header file <curl/curl.h> is required for --with-libcurl])])
+  AC_CHECK_LIB(curl, curl_multi_init, [],
+			   [AC_MSG_ERROR([library 'curl' does not provide curl_multi_init])])
+
+  # Check to see whether the current platform supports threadsafe Curl
+  # initialization.
+  AC_CACHE_CHECK([for curl_global_init thread safety], [pgac_cv__libcurl_threadsafe_init],
+  [AC_RUN_IFELSE([AC_LANG_PROGRAM([
+#include <curl/curl.h>
+],[
+    curl_version_info_data *info;
+
+    if (curl_global_init(CURL_GLOBAL_ALL))
+        return -1;
+
+    info = curl_version_info(CURLVERSION_NOW);
+#ifdef CURL_VERSION_THREADSAFE
+    if (info->features & CURL_VERSION_THREADSAFE)
+        return 0;
+#endif
+
+    return 1;
+])],
+  [pgac_cv__libcurl_threadsafe_init=yes],
+  [pgac_cv__libcurl_threadsafe_init=no],
+  [pgac_cv__libcurl_threadsafe_init=unknown])])
+  if test x"$pgac_cv__libcurl_threadsafe_init" = xyes ; then
+    AC_DEFINE(HAVE_THREADSAFE_CURL_GLOBAL_INIT, 1,
+              [Define to 1 if curl_global_init() is guaranteed to be threadsafe.])
+  fi
+
+  # Warn if a thread-friendly DNS resolver isn't built.
+  AC_CACHE_CHECK([for curl support for asynchronous DNS], [pgac_cv__libcurl_async_dns],
+  [AC_RUN_IFELSE([AC_LANG_PROGRAM([
+#include <curl/curl.h>
+],[
+    curl_version_info_data *info;
+
+    if (curl_global_init(CURL_GLOBAL_ALL))
+        return -1;
+
+    info = curl_version_info(CURLVERSION_NOW);
+    return (info->features & CURL_VERSION_ASYNCHDNS) ? 0 : 1;
+])],
+  [pgac_cv__libcurl_async_dns=yes],
+  [pgac_cv__libcurl_async_dns=no],
+  [pgac_cv__libcurl_async_dns=unknown])])
+  if test x"$pgac_cv__libcurl_async_dns" != xyes ; then
+    AC_MSG_WARN([
+*** The installed version of libcurl does not support asynchronous DNS
+*** lookups. Connection timeouts will not be honored during DNS resolution,
+*** which may lead to hangs in client programs.])
+  fi
+])# PGAC_CHECK_LIBCURL
diff --git a/configure b/configure
index 0ffcaeb4367..93fddd69981 100755
--- a/configure
+++ b/configure
@@ -708,6 +708,9 @@ XML2_LIBS
 XML2_CFLAGS
 XML2_CONFIG
 with_libxml
+LIBCURL_LIBS
+LIBCURL_CFLAGS
+with_libcurl
 with_uuid
 with_readline
 with_systemd
@@ -864,6 +867,7 @@ with_readline
 with_libedit_preferred
 with_uuid
 with_ossp_uuid
+with_libcurl
 with_libxml
 with_libxslt
 with_system_tzdata
@@ -894,6 +898,8 @@ PKG_CONFIG_PATH
 PKG_CONFIG_LIBDIR
 ICU_CFLAGS
 ICU_LIBS
+LIBCURL_CFLAGS
+LIBCURL_LIBS
 XML2_CONFIG
 XML2_CFLAGS
 XML2_LIBS
@@ -1574,6 +1580,7 @@ Optional Packages:
                           prefer BSD Libedit over GNU Readline
   --with-uuid=LIB         build contrib/uuid-ossp using LIB (bsd,e2fs,ossp)
   --with-ossp-uuid        obsolete spelling of --with-uuid=ossp
+  --with-libcurl          build with libcurl support
   --with-libxml           build with XML support
   --with-libxslt          use XSLT support when building contrib/xml2
   --with-system-tzdata=DIR
@@ -1607,6 +1614,10 @@ Some influential environment variables:
               path overriding pkg-config's built-in search path
   ICU_CFLAGS  C compiler flags for ICU, overriding pkg-config
   ICU_LIBS    linker flags for ICU, overriding pkg-config
+  LIBCURL_CFLAGS
+              C compiler flags for LIBCURL, overriding pkg-config
+  LIBCURL_LIBS
+              linker flags for LIBCURL, overriding pkg-config
   XML2_CONFIG path to xml2-config utility
   XML2_CFLAGS C compiler flags for XML2, overriding pkg-config
   XML2_LIBS   linker flags for XML2, overriding pkg-config
@@ -8762,6 +8773,157 @@ fi
 
 
 
+#
+# libcurl
+#
+{ $as_echo "$as_me:${as_lineno-$LINENO}: checking whether to build with libcurl support" >&5
+$as_echo_n "checking whether to build with libcurl support... " >&6; }
+
+
+
+# Check whether --with-libcurl was given.
+if test "${with_libcurl+set}" = set; then :
+  withval=$with_libcurl;
+  case $withval in
+    yes)
+
+$as_echo "#define USE_LIBCURL 1" >>confdefs.h
+
+      ;;
+    no)
+      :
+      ;;
+    *)
+      as_fn_error $? "no argument expected for --with-libcurl option" "$LINENO" 5
+      ;;
+  esac
+
+else
+  with_libcurl=no
+
+fi
+
+
+{ $as_echo "$as_me:${as_lineno-$LINENO}: result: $with_libcurl" >&5
+$as_echo "$with_libcurl" >&6; }
+
+
+if test "$with_libcurl" = yes ; then
+  # Check for libcurl 7.61.0 or higher (corresponding to RHEL8 and the ability
+  # to explicitly set TLS 1.3 ciphersuites).
+
+pkg_failed=no
+{ $as_echo "$as_me:${as_lineno-$LINENO}: checking for libcurl >= 7.61.0" >&5
+$as_echo_n "checking for libcurl >= 7.61.0... " >&6; }
+
+if test -n "$LIBCURL_CFLAGS"; then
+    pkg_cv_LIBCURL_CFLAGS="$LIBCURL_CFLAGS"
+ elif test -n "$PKG_CONFIG"; then
+    if test -n "$PKG_CONFIG" && \
+    { { $as_echo "$as_me:${as_lineno-$LINENO}: \$PKG_CONFIG --exists --print-errors \"libcurl >= 7.61.0\""; } >&5
+  ($PKG_CONFIG --exists --print-errors "libcurl >= 7.61.0") 2>&5
+  ac_status=$?
+  $as_echo "$as_me:${as_lineno-$LINENO}: \$? = $ac_status" >&5
+  test $ac_status = 0; }; then
+  pkg_cv_LIBCURL_CFLAGS=`$PKG_CONFIG --cflags "libcurl >= 7.61.0" 2>/dev/null`
+		      test "x$?" != "x0" && pkg_failed=yes
+else
+  pkg_failed=yes
+fi
+ else
+    pkg_failed=untried
+fi
+if test -n "$LIBCURL_LIBS"; then
+    pkg_cv_LIBCURL_LIBS="$LIBCURL_LIBS"
+ elif test -n "$PKG_CONFIG"; then
+    if test -n "$PKG_CONFIG" && \
+    { { $as_echo "$as_me:${as_lineno-$LINENO}: \$PKG_CONFIG --exists --print-errors \"libcurl >= 7.61.0\""; } >&5
+  ($PKG_CONFIG --exists --print-errors "libcurl >= 7.61.0") 2>&5
+  ac_status=$?
+  $as_echo "$as_me:${as_lineno-$LINENO}: \$? = $ac_status" >&5
+  test $ac_status = 0; }; then
+  pkg_cv_LIBCURL_LIBS=`$PKG_CONFIG --libs "libcurl >= 7.61.0" 2>/dev/null`
+		      test "x$?" != "x0" && pkg_failed=yes
+else
+  pkg_failed=yes
+fi
+ else
+    pkg_failed=untried
+fi
+
+
+
+if test $pkg_failed = yes; then
+        { $as_echo "$as_me:${as_lineno-$LINENO}: result: no" >&5
+$as_echo "no" >&6; }
+
+if $PKG_CONFIG --atleast-pkgconfig-version 0.20; then
+        _pkg_short_errors_supported=yes
+else
+        _pkg_short_errors_supported=no
+fi
+        if test $_pkg_short_errors_supported = yes; then
+	        LIBCURL_PKG_ERRORS=`$PKG_CONFIG --short-errors --print-errors --cflags --libs "libcurl >= 7.61.0" 2>&1`
+        else
+	        LIBCURL_PKG_ERRORS=`$PKG_CONFIG --print-errors --cflags --libs "libcurl >= 7.61.0" 2>&1`
+        fi
+	# Put the nasty error message in config.log where it belongs
+	echo "$LIBCURL_PKG_ERRORS" >&5
+
+	as_fn_error $? "Package requirements (libcurl >= 7.61.0) were not met:
+
+$LIBCURL_PKG_ERRORS
+
+Consider adjusting the PKG_CONFIG_PATH environment variable if you
+installed software in a non-standard prefix.
+
+Alternatively, you may set the environment variables LIBCURL_CFLAGS
+and LIBCURL_LIBS to avoid the need to call pkg-config.
+See the pkg-config man page for more details." "$LINENO" 5
+elif test $pkg_failed = untried; then
+        { $as_echo "$as_me:${as_lineno-$LINENO}: result: no" >&5
+$as_echo "no" >&6; }
+	{ { $as_echo "$as_me:${as_lineno-$LINENO}: error: in \`$ac_pwd':" >&5
+$as_echo "$as_me: error: in \`$ac_pwd':" >&2;}
+as_fn_error $? "The pkg-config script could not be found or is too old.  Make sure it
+is in your PATH or set the PKG_CONFIG environment variable to the full
+path to pkg-config.
+
+Alternatively, you may set the environment variables LIBCURL_CFLAGS
+and LIBCURL_LIBS to avoid the need to call pkg-config.
+See the pkg-config man page for more details.
+
+To get pkg-config, see <http://pkg-config.freedesktop.org/>.
+See \`config.log' for more details" "$LINENO" 5; }
+else
+	LIBCURL_CFLAGS=$pkg_cv_LIBCURL_CFLAGS
+	LIBCURL_LIBS=$pkg_cv_LIBCURL_LIBS
+        { $as_echo "$as_me:${as_lineno-$LINENO}: result: yes" >&5
+$as_echo "yes" >&6; }
+
+fi
+
+  # We only care about -I, -D, and -L switches;
+  # note that -lcurl will be added by PGAC_CHECK_LIBCURL below.
+  for pgac_option in $LIBCURL_CFLAGS; do
+    case $pgac_option in
+      -I*|-D*) CPPFLAGS="$CPPFLAGS $pgac_option";;
+    esac
+  done
+  for pgac_option in $LIBCURL_LIBS; do
+    case $pgac_option in
+      -L*) LDFLAGS="$LDFLAGS $pgac_option";;
+    esac
+  done
+
+  # OAuth requires python for testing
+  if test "$with_python" != yes; then
+    { $as_echo "$as_me:${as_lineno-$LINENO}: WARNING: *** OAuth support tests require --with-python to run" >&5
+$as_echo "$as_me: WARNING: *** OAuth support tests require --with-python to run" >&2;}
+  fi
+fi
+
+
 #
 # XML
 #
@@ -12216,6 +12378,176 @@ fi
 
 fi
 
+# XXX libcurl must link after libgssapi_krb5 on FreeBSD to avoid segfaults
+# during gss_acquire_cred(). This is possibly related to Curl's Heimdal
+# dependency on that platform?
+if test "$with_libcurl" = yes ; then
+
+  ac_fn_c_check_header_mongrel "$LINENO" "curl/curl.h" "ac_cv_header_curl_curl_h" "$ac_includes_default"
+if test "x$ac_cv_header_curl_curl_h" = xyes; then :
+
+else
+  as_fn_error $? "header file <curl/curl.h> is required for --with-libcurl" "$LINENO" 5
+fi
+
+
+  { $as_echo "$as_me:${as_lineno-$LINENO}: checking for curl_multi_init in -lcurl" >&5
+$as_echo_n "checking for curl_multi_init in -lcurl... " >&6; }
+if ${ac_cv_lib_curl_curl_multi_init+:} false; then :
+  $as_echo_n "(cached) " >&6
+else
+  ac_check_lib_save_LIBS=$LIBS
+LIBS="-lcurl  $LIBS"
+cat confdefs.h - <<_ACEOF >conftest.$ac_ext
+/* end confdefs.h.  */
+
+/* Override any GCC internal prototype to avoid an error.
+   Use char because int might match the return type of a GCC
+   builtin and then its argument prototype would still apply.  */
+#ifdef __cplusplus
+extern "C"
+#endif
+char curl_multi_init ();
+int
+main ()
+{
+return curl_multi_init ();
+  ;
+  return 0;
+}
+_ACEOF
+if ac_fn_c_try_link "$LINENO"; then :
+  ac_cv_lib_curl_curl_multi_init=yes
+else
+  ac_cv_lib_curl_curl_multi_init=no
+fi
+rm -f core conftest.err conftest.$ac_objext \
+    conftest$ac_exeext conftest.$ac_ext
+LIBS=$ac_check_lib_save_LIBS
+fi
+{ $as_echo "$as_me:${as_lineno-$LINENO}: result: $ac_cv_lib_curl_curl_multi_init" >&5
+$as_echo "$ac_cv_lib_curl_curl_multi_init" >&6; }
+if test "x$ac_cv_lib_curl_curl_multi_init" = xyes; then :
+  cat >>confdefs.h <<_ACEOF
+#define HAVE_LIBCURL 1
+_ACEOF
+
+  LIBS="-lcurl $LIBS"
+
+else
+  as_fn_error $? "library 'curl' does not provide curl_multi_init" "$LINENO" 5
+fi
+
+
+  # Check to see whether the current platform supports threadsafe Curl
+  # initialization.
+  { $as_echo "$as_me:${as_lineno-$LINENO}: checking for curl_global_init thread safety" >&5
+$as_echo_n "checking for curl_global_init thread safety... " >&6; }
+if ${pgac_cv__libcurl_threadsafe_init+:} false; then :
+  $as_echo_n "(cached) " >&6
+else
+  if test "$cross_compiling" = yes; then :
+  pgac_cv__libcurl_threadsafe_init=unknown
+else
+  cat confdefs.h - <<_ACEOF >conftest.$ac_ext
+/* end confdefs.h.  */
+
+#include <curl/curl.h>
+
+int
+main ()
+{
+
+    curl_version_info_data *info;
+
+    if (curl_global_init(CURL_GLOBAL_ALL))
+        return -1;
+
+    info = curl_version_info(CURLVERSION_NOW);
+#ifdef CURL_VERSION_THREADSAFE
+    if (info->features & CURL_VERSION_THREADSAFE)
+        return 0;
+#endif
+
+    return 1;
+
+  ;
+  return 0;
+}
+_ACEOF
+if ac_fn_c_try_run "$LINENO"; then :
+  pgac_cv__libcurl_threadsafe_init=yes
+else
+  pgac_cv__libcurl_threadsafe_init=no
+fi
+rm -f core *.core core.conftest.* gmon.out bb.out conftest$ac_exeext \
+  conftest.$ac_objext conftest.beam conftest.$ac_ext
+fi
+
+fi
+{ $as_echo "$as_me:${as_lineno-$LINENO}: result: $pgac_cv__libcurl_threadsafe_init" >&5
+$as_echo "$pgac_cv__libcurl_threadsafe_init" >&6; }
+  if test x"$pgac_cv__libcurl_threadsafe_init" = xyes ; then
+
+$as_echo "#define HAVE_THREADSAFE_CURL_GLOBAL_INIT 1" >>confdefs.h
+
+  fi
+
+  # Warn if a thread-friendly DNS resolver isn't built.
+  { $as_echo "$as_me:${as_lineno-$LINENO}: checking for curl support for asynchronous DNS" >&5
+$as_echo_n "checking for curl support for asynchronous DNS... " >&6; }
+if ${pgac_cv__libcurl_async_dns+:} false; then :
+  $as_echo_n "(cached) " >&6
+else
+  if test "$cross_compiling" = yes; then :
+  pgac_cv__libcurl_async_dns=unknown
+else
+  cat confdefs.h - <<_ACEOF >conftest.$ac_ext
+/* end confdefs.h.  */
+
+#include <curl/curl.h>
+
+int
+main ()
+{
+
+    curl_version_info_data *info;
+
+    if (curl_global_init(CURL_GLOBAL_ALL))
+        return -1;
+
+    info = curl_version_info(CURLVERSION_NOW);
+    return (info->features & CURL_VERSION_ASYNCHDNS) ? 0 : 1;
+
+  ;
+  return 0;
+}
+_ACEOF
+if ac_fn_c_try_run "$LINENO"; then :
+  pgac_cv__libcurl_async_dns=yes
+else
+  pgac_cv__libcurl_async_dns=no
+fi
+rm -f core *.core core.conftest.* gmon.out bb.out conftest$ac_exeext \
+  conftest.$ac_objext conftest.beam conftest.$ac_ext
+fi
+
+fi
+{ $as_echo "$as_me:${as_lineno-$LINENO}: result: $pgac_cv__libcurl_async_dns" >&5
+$as_echo "$pgac_cv__libcurl_async_dns" >&6; }
+  if test x"$pgac_cv__libcurl_async_dns" != xyes ; then
+    { $as_echo "$as_me:${as_lineno-$LINENO}: WARNING:
+*** The installed version of libcurl does not support asynchronous DNS
+*** lookups. Connection timeouts will not be honored during DNS resolution,
+*** which may lead to hangs in client programs." >&5
+$as_echo "$as_me: WARNING:
+*** The installed version of libcurl does not support asynchronous DNS
+*** lookups. Connection timeouts will not be honored during DNS resolution,
+*** which may lead to hangs in client programs." >&2;}
+  fi
+
+fi
+
 if test "$with_gssapi" = yes ; then
   if test "$PORTNAME" != "win32"; then
     { $as_echo "$as_me:${as_lineno-$LINENO}: checking for library containing gss_store_cred_into" >&5
diff --git a/configure.ac b/configure.ac
index f56681e0d91..b6d02f5ecc7 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1007,6 +1007,40 @@ fi
 AC_SUBST(with_uuid)
 
 
+#
+# libcurl
+#
+AC_MSG_CHECKING([whether to build with libcurl support])
+PGAC_ARG_BOOL(with, libcurl, no, [build with libcurl support],
+              [AC_DEFINE([USE_LIBCURL], 1, [Define to 1 to build with libcurl support. (--with-libcurl)])])
+AC_MSG_RESULT([$with_libcurl])
+AC_SUBST(with_libcurl)
+
+if test "$with_libcurl" = yes ; then
+  # Check for libcurl 7.61.0 or higher (corresponding to RHEL8 and the ability
+  # to explicitly set TLS 1.3 ciphersuites).
+  PKG_CHECK_MODULES(LIBCURL, [libcurl >= 7.61.0])
+
+  # We only care about -I, -D, and -L switches;
+  # note that -lcurl will be added by PGAC_CHECK_LIBCURL below.
+  for pgac_option in $LIBCURL_CFLAGS; do
+    case $pgac_option in
+      -I*|-D*) CPPFLAGS="$CPPFLAGS $pgac_option";;
+    esac
+  done
+  for pgac_option in $LIBCURL_LIBS; do
+    case $pgac_option in
+      -L*) LDFLAGS="$LDFLAGS $pgac_option";;
+    esac
+  done
+
+  # OAuth requires python for testing
+  if test "$with_python" != yes; then
+    AC_MSG_WARN([*** OAuth support tests require --with-python to run])
+  fi
+fi
+
+
 #
 # XML
 #
@@ -1294,6 +1328,13 @@ failure.  It is possible the compiler isn't looking in the proper directory.
 Use --without-zlib to disable zlib support.])])
 fi
 
+# XXX libcurl must link after libgssapi_krb5 on FreeBSD to avoid segfaults
+# during gss_acquire_cred(). This is possibly related to Curl's Heimdal
+# dependency on that platform?
+if test "$with_libcurl" = yes ; then
+  PGAC_CHECK_LIBCURL
+fi
+
 if test "$with_gssapi" = yes ; then
   if test "$PORTNAME" != "win32"; then
     AC_SEARCH_LIBS(gss_store_cred_into, [gssapi_krb5 gss 'gssapi -lkrb5 -lcrypto'], [],
diff --git a/doc/src/sgml/client-auth.sgml b/doc/src/sgml/client-auth.sgml
index 782b49c85ac..f84085dbac4 100644
--- a/doc/src/sgml/client-auth.sgml
+++ b/doc/src/sgml/client-auth.sgml
@@ -656,6 +656,16 @@ include_dir         <replaceable>directory</replaceable>
          </para>
         </listitem>
        </varlistentry>
+
+       <varlistentry>
+        <term><literal>oauth</literal></term>
+        <listitem>
+         <para>
+          Authorize and optionally authenticate using a third-party OAuth 2.0
+          identity provider. See <xref linkend="auth-oauth"/> for details.
+         </para>
+        </listitem>
+       </varlistentry>
       </variablelist>
 
       </para>
@@ -1143,6 +1153,12 @@ omicron         bryanh                  guest1
       only on OpenBSD).
      </para>
     </listitem>
+    <listitem>
+     <para>
+      <link linkend="auth-oauth">OAuth authorization/authentication</link>,
+      which relies on an external OAuth 2.0 identity provider.
+     </para>
+    </listitem>
    </itemizedlist>
   </para>
 
@@ -2329,6 +2345,242 @@ host ... radius radiusservers="server1,server2" radiussecrets="""secret one"",""
    </note>
   </sect1>
 
+  <sect1 id="auth-oauth">
+   <title>OAuth Authorization/Authentication</title>
+
+   <indexterm zone="auth-oauth">
+    <primary>OAuth Authorization/Authentication</primary>
+   </indexterm>
+
+   <para>
+    OAuth 2.0 is an industry-standard framework, defined in
+    <ulink url="https://datatracker.ietf.org/doc/html/rfc6749">RFC 6749</ulink>,
+    to enable third-party applications to obtain limited access to a protected
+    resource.
+
+    OAuth client support has to be enabled when <productname>PostgreSQL</productname>
+    is built, see <xref linkend="installation"/> for more information.
+   </para>
+
+   <para>
+    This documentation uses the following terminology when discussing the OAuth
+    ecosystem:
+
+    <variablelist>
+
+     <varlistentry>
+      <term>Resource Owner (or End User)</term>
+      <listitem>
+       <para>
+        The user or system who owns protected resources and can grant access to
+        them. This documentation also uses the term <emphasis>end user</emphasis>
+        when the resource owner is a person. When you use
+        <application>psql</application> to connect to the database using OAuth,
+        you are the resource owner/end user.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term>Client</term>
+      <listitem>
+       <para>
+        The system which accesses the protected resources using access
+        tokens. Applications using libpq, such as <application>psql</application>,
+        are the OAuth clients when connecting to a
+        <productname>PostgreSQL</productname> cluster.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term>Resource Server</term>
+      <listitem>
+       <para>
+        The system which hosts the protected resources which are
+        accessed by the client. The <productname>PostgreSQL</productname>
+        cluster being connected to is the resource server.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term>Provider</term>
+      <listitem>
+       <para>
+        The organization, product vendor, or other entity which develops and/or
+        administers the OAuth servers and clients for a given application.
+        Different providers typically choose different implementation details
+        for their OAuth systems; a client of one provider is not generally
+        guaranteed to have access to the servers of another.
+       </para>
+       <para>
+        This use of the term "provider" is not standard, but it seems to be in
+        wide use colloquially. (It should not be confused with OpenID's similar
+        term "Identity Provider". While the implementation of OAuth in
+        <productname>PostgreSQL</productname> is intended to be interoperable
+        and compatible with OpenID Connect/OIDC, it is not itself an OIDC client
+        and does not require its use.)
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term>Authorization Server</term>
+      <listitem>
+       <para>
+        The system which receives requests from, and issues access tokens to,
+        the client after the authenticated resource owner has given approval.
+        <productname>PostgreSQL</productname> does not provide an authorization
+        server; it's obtained from the OAuth provider.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term id="auth-oauth-issuer">Issuer</term>
+      <listitem>
+       <para>
+        An identifier for an authorization server, printed as an
+        <literal>https://</literal> URL, which provides a trusted "namespace"
+        for OAuth clients and applications. The issuer identifier allows a
+        single authorization server to talk to the clients of mutually
+        untrusting entities, as long as they maintain separate issuers.
+       </para>
+      </listitem>
+     </varlistentry>
+
+    </variablelist>
+
+    <note>
+     <para>
+      For small deployments, there may not be a meaningful distinction between
+      the "provider", "authorization server", and "issuer". However, for more
+      complicated setups, there may be a one-to-many (or many-to-many)
+      relationship: a provider may rent out multiple issuer identifiers to
+      separate tenants, then provide multiple authorization servers, possibly
+      with different supported feature sets, to interact with their clients.
+     </para>
+    </note>
+   </para>
+
+   <para>
+    <productname>PostgreSQL</productname> supports bearer tokens, defined in
+    <ulink url="https://datatracker.ietf.org/doc/html/rfc6750">RFC 6750</ulink>,
+    which are a type of access token used with OAuth 2.0 where the token is an
+    opaque string.  The format of the access token is implementation specific
+    and is chosen by each authorization server.
+   </para>
+
+   <para>
+    The following configuration options are supported for OAuth:
+    <variablelist>
+     <varlistentry>
+      <term><literal>issuer</literal></term>
+      <listitem>
+       <para>
+        An HTTPS URL which is either the exact
+        <link linkend="auth-oauth-issuer">issuer identifier</link> of the
+        authorization server, as defined by its discovery document, or a
+        well-known URI that points directly to that discovery document. This
+        parameter is required.
+       </para>
+       <para>
+        When an OAuth client connects to the server, a URL for the discovery
+        document will be constructed using the issuer identifier. By default,
+        this URL uses the conventions of OpenID Connect Discovery: the path
+        <literal>/.well-known/openid-configuration</literal> will be appended
+        to the end of the issuer identifier. Alternatively, if the
+        <literal>issuer</literal> contains a <literal>/.well-known/</literal>
+        path segment, that URL will be provided to the client as-is.
+       </para>
+       <warning>
+        <para>
+         The OAuth client in libpq requires the server's issuer setting to
+         exactly match the issuer identifier which is provided in the discovery
+         document, which must in turn match the client's
+         <xref linkend="libpq-connect-oauth-issuer"/> setting. No variations in
+         case or format are permitted.
+        </para>
+       </warning>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term><literal>scope</literal></term>
+      <listitem>
+       <para>
+        A space-separated list of the OAuth scopes needed for the server to
+        both authorize the client and authenticate the user.  Appropriate values
+        are determined by the authorization server and the OAuth validation
+        module used (see <xref linkend="oauth-validators" /> for more
+        information on validators).  This parameter is required.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term><literal>validator</literal></term>
+      <listitem>
+       <para>
+        The library to use for validating bearer tokens. If given, the name must
+        exactly match one of the libraries listed in
+        <xref linkend="guc-oauth-validator-libraries" />.  This parameter is
+        optional unless <literal>oauth_validator_libraries</literal> contains
+        more than one library, in which case it is required.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term><literal>map</literal></term>
+      <listitem>
+       <para>
+        Allows for mapping between OAuth identity provider and database user
+        names.  See <xref linkend="auth-username-maps"/> for details.  If a
+        map is not specified, the user name associated with the token (as
+        determined by the OAuth validator) must exactly match the role name
+        being requested.  This parameter is optional.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term id="auth-oauth-delegate-ident-mapping" xreflabel="delegate_ident_mapping">
+       <literal>delegate_ident_mapping</literal>
+      </term>
+      <listitem>
+       <para>
+        An advanced option which is not intended for common use.
+       </para>
+       <para>
+        When set to <literal>1</literal>, standard user mapping with
+        <filename>pg_ident.conf</filename> is skipped, and the OAuth validator
+        takes full responsibility for mapping end user identities to database
+        roles.  If the validator authorizes the token, the server trusts that
+        the user is allowed to connect under the requested role, and the
+        connection is allowed to proceed regardless of the authentication
+        status of the user.
+       </para>
+       <para>
+        This parameter is incompatible with <literal>map</literal>.
+       </para>
+       <warning>
+        <para>
+         <literal>delegate_ident_mapping</literal> provides additional
+         flexibility in the design of the authentication system, but it also
+         requires careful implementation of the OAuth validator, which must
+         determine whether the provided token carries sufficient end-user
+         privileges in addition to the <link linkend="oauth-validators">standard
+         checks</link> required of all validators.  Use with caution.
+        </para>
+       </warning>
+      </listitem>
+     </varlistentry>
+    </variablelist>
+   </para>
+  </sect1>
+
   <sect1 id="client-authentication-problems">
    <title>Authentication Problems</title>
 
diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml
index 38244409e3c..d53595f8951 100644
--- a/doc/src/sgml/config.sgml
+++ b/doc/src/sgml/config.sgml
@@ -1209,6 +1209,32 @@ include_dir 'conf.d'
        </para>
       </listitem>
      </varlistentry>
+
+     <varlistentry id="guc-oauth-validator-libraries" xreflabel="oauth_validator_libraries">
+      <term><varname>oauth_validator_libraries</varname> (<type>string</type>)
+      <indexterm>
+       <primary><varname>oauth_validator_libraries</varname> configuration parameter</primary>
+      </indexterm>
+      </term>
+      <listitem>
+       <para>
+        The library/libraries to use for validating OAuth connection tokens. If
+        only one validator library is provided, it will be used by default for
+        any OAuth connections; otherwise, all
+        <link linkend="auth-oauth"><literal>oauth</literal> HBA entries</link>
+        must explicitly set a <literal>validator</literal> chosen from this
+        list. If set to an empty string (the default), OAuth connections will be
+        refused. This parameter can only be set in the
+        <filename>postgresql.conf</filename> file.
+       </para>
+       <para>
+        Validator modules must be implemented/obtained separately;
+        <productname>PostgreSQL</productname> does not ship with any default
+        implementations. For more information on implementing OAuth validators,
+        see <xref linkend="oauth-validators" />.
+       </para>
+      </listitem>
+     </varlistentry>
      </variablelist>
      </sect2>
 
diff --git a/doc/src/sgml/filelist.sgml b/doc/src/sgml/filelist.sgml
index 66e6dccd4c9..25fb99cee69 100644
--- a/doc/src/sgml/filelist.sgml
+++ b/doc/src/sgml/filelist.sgml
@@ -111,6 +111,7 @@
 <!ENTITY generic-wal SYSTEM "generic-wal.sgml">
 <!ENTITY custom-rmgr SYSTEM "custom-rmgr.sgml">
 <!ENTITY backup-manifest SYSTEM "backup-manifest.sgml">
+<!ENTITY oauth-validators SYSTEM "oauth-validators.sgml">
 
 <!-- contrib information -->
 <!ENTITY contrib         SYSTEM "contrib.sgml">
diff --git a/doc/src/sgml/installation.sgml b/doc/src/sgml/installation.sgml
index 3f0a7e9c069..96e433179b9 100644
--- a/doc/src/sgml/installation.sgml
+++ b/doc/src/sgml/installation.sgml
@@ -1143,6 +1143,19 @@ build-postgresql:
        </listitem>
       </varlistentry>
 
+      <varlistentry id="configure-option-with-libcurl">
+       <term><option>--with-libcurl</option></term>
+       <listitem>
+        <para>
+         Build with libcurl support for OAuth 2.0 client flows.
+         This requires the <productname>curl</productname> package to be
+         installed.  Building with this will check for the required header files
+         and libraries to make sure that your <productname>curl</productname>
+         installation is sufficient before proceeding.
+        </para>
+       </listitem>
+      </varlistentry>
+
       <varlistentry id="configure-option-with-libxml">
        <term><option>--with-libxml</option></term>
        <listitem>
@@ -2584,6 +2597,20 @@ ninja install
       </listitem>
      </varlistentry>
 
+     <varlistentry id="configure-with-libcurl-meson">
+      <term><option>-Dlibcurl={ auto | enabled | disabled }</option></term>
+      <listitem>
+       <para>
+        Build with libcurl support for OAuth 2.0 client flows.
+        This requires the <productname>curl</productname> package to be
+        installed.  Building with this will check for the required header files
+        and libraries to make sure that your <productname>curl</productname>
+        installation is sufficient before proceeding. The default for this
+        option is auto.
+       </para>
+      </listitem>
+     </varlistentry>
+
      <varlistentry id="configure-with-libxml-meson">
       <term><option>-Dlibxml={ auto | enabled | disabled }</option></term>
       <listitem>
diff --git a/doc/src/sgml/libpq.sgml b/doc/src/sgml/libpq.sgml
index e04acf1c208..ddfc2a27c50 100644
--- a/doc/src/sgml/libpq.sgml
+++ b/doc/src/sgml/libpq.sgml
@@ -1385,6 +1385,15 @@ postgresql://%2Fvar%2Flib%2Fpostgresql/dbname
           </listitem>
          </varlistentry>
 
+         <varlistentry>
+          <term><literal>oauth</literal></term>
+          <listitem>
+           <para>
+            The server must request an OAuth bearer token from the client.
+           </para>
+          </listitem>
+         </varlistentry>
+
          <varlistentry>
           <term><literal>none</literal></term>
           <listitem>
@@ -2373,6 +2382,106 @@ postgresql://%2Fvar%2Flib%2Fpostgresql/dbname
        </para>
       </listitem>
      </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-issuer" xreflabel="oauth_issuer">
+      <term><literal>oauth_issuer</literal></term>
+      <listitem>
+       <para>
+        The HTTPS URL of a trusted issuer to contact if the server requests an
+        OAuth token for the connection. This parameter is required for all OAuth
+        connections; it should exactly match the <literal>issuer</literal>
+        setting in <link linkend="auth-oauth">the server's HBA configuration.</link>
+       </para>
+       <para>
+        As part of the standard authentication handshake, <application>libpq</application>
+        will ask the server for a <emphasis>discovery document:</emphasis> a URL
+        providing a set of OAuth configuration parameters. The server must
+        provide a URL that is directly constructed from the components of the
+        <literal>oauth_issuer</literal>, and this value must exactly match the
+        issuer identifier that is declared in the discovery document itself, or
+        the connection will fail. This is required to prevent a class of "mix-up
+        attacks" on OAuth clients.
+       </para>
+       <para>
+        You may also explicitly set <literal>oauth_issuer</literal> to the
+        <literal>/.well-known/</literal> URI used for OAuth discovery. In this
+        case, if the server asks for a different URL, the connection will fail,
+        but a <link linkend="libpq-oauth-authdata-hooks">custom OAuth flow</link>
+        may be able to speed up the standard handshake by using previously
+        cached tokens. (In this case, it is recommended that
+        <xref linkend="libpq-connect-oauth-scope"/> be set as well, since the
+        client will not have a chance to ask the server for a correct scope
+        setting, and the default scopes for a token may not be sufficient to
+        connect.) <application>libpq</application> currently supports the
+        following well-known endpoints:
+        <itemizedlist spacing="compact">
+         <listitem><para><literal>/.well-known/openid-configuration</literal></para></listitem>
+         <listitem><para><literal>/.well-known/oauth-authorization-server</literal></para></listitem>
+        </itemizedlist>
+       </para>
+       <warning>
+        <para>
+         Issuers are highly privileged during the OAuth connection handshake. As
+         a rule of thumb, if you would not trust the operator of a URL to handle
+         access to your servers, or to impersonate you directly, that URL should
+         not be trusted as an <literal>oauth_issuer</literal>.
+        </para>
+       </warning>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-client-id" xreflabel="oauth_client_id">
+      <term><literal>oauth_client_id</literal></term>
+      <listitem>
+       <para>
+        An OAuth 2.0 client identifier, as issued by the authorization server.
+        If the <productname>PostgreSQL</productname> server
+        <link linkend="auth-oauth">requests an OAuth token</link> for the
+        connection (and if no <link linkend="libpq-oauth-authdata-hooks">custom
+        OAuth hook</link> is installed to provide one), then this parameter must
+        be set; otherwise, the connection will fail.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-client-secret" xreflabel="oauth_client_secret">
+      <term><literal>oauth_client_secret</literal></term>
+      <listitem>
+       <para>
+        The client password, if any, to use when contacting the OAuth
+        authorization server. Whether this parameter is required or not is
+        determined by the OAuth provider; "public" clients generally do not use
+        a secret, whereas "confidential" clients generally do.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-scope" xreflabel="oauth_scope">
+      <term><literal>oauth_scope</literal></term>
+      <listitem>
+       <para>
+        The scope of the access request sent to the authorization server,
+        specified as a (possibly empty) space-separated list of OAuth scope
+        identifiers. This parameter is optional and intended for advanced usage.
+       </para>
+       <para>
+        Usually the client will obtain appropriate scope settings from the
+        <productname>PostgreSQL</productname> server. If this parameter is used,
+        the server's requested scope list will be ignored. This can prevent a
+        less-trusted server from requesting inappropriate access scopes from the
+        end user. However, if the client's scope setting does not contain the
+        server's required scopes, the server is likely to reject the issued
+        token, and the connection will fail.
+       </para>
+       <para>
+        The meaning of an empty scope list is provider-dependent. An OAuth
+        authorization server may choose to issue a token with "default scope",
+        whatever that happens to be, or it may reject the token request
+        entirely.
+       </para>
+      </listitem>
+     </varlistentry>
+
     </variablelist>
    </para>
   </sect2>
@@ -10020,6 +10129,291 @@ void PQinitSSL(int do_ssl);
 
  </sect1>
 
+ <sect1 id="libpq-oauth">
+  <title>OAuth Support</title>
+
+  <para>
+   TODO
+  </para>
+
+  <sect2 id="libpq-oauth-authdata-hooks">
+   <title>Authdata Hooks</title>
+
+   <para>
+    The behavior of the OAuth flow may be modified or replaced by a client using
+    the following hook API:
+
+    <variablelist>
+     <varlistentry id="libpq-PQsetAuthDataHook">
+      <term><function>PQsetAuthDataHook</function><indexterm><primary>PQsetAuthDataHook</primary></indexterm></term>
+
+      <listitem>
+       <para>
+        Sets the <symbol>PGauthDataHook</symbol>, overriding
+        <application>libpq</application>'s handling of one or more aspects of
+        its OAuth client flow.
+<synopsis>
+void PQsetAuthDataHook(PQauthDataHook_type hook);
+</synopsis>
+        If <replaceable>hook</replaceable> is <literal>NULL</literal>, the
+        default handler will be reinstalled. Otherwise, the application passes
+        a pointer to a callback function with the signature:
+<programlisting>
+int hook_fn(PGauthData type, PGconn *conn, void *data);
+</programlisting>
+        which <application>libpq</application> will call when when action is
+        required of the application. <replaceable>type</replaceable> describes
+        the request being made, <replaceable>conn</replaceable> is the
+        connection handle being authenticated, and <replaceable>data</replaceable>
+        points to request-specific metadata. The contents of this pointer are
+        determined by <replaceable>type</replaceable>; see
+        <xref linkend="libpq-oauth-authdata-hooks-types"/> for the supported
+        list.
+       </para>
+       <para>
+        Hooks can be chained together to allow cooperative and/or fallback
+        behavior. In general, a hook implementation should examine the incoming
+        <replaceable>type</replaceable> (and, potentially, the request metadata
+        and/or the settings for the particular <replaceable>conn</replaceable>
+        in use) to decide whether or not to handle a specific piece of authdata.
+        If not, it should delegate to the previous hook in the chain
+        (retrievable via <function>PQgetAuthDataHook</function>).
+       </para>
+       <para>
+        Success is indicated by returning an integer greater than zero.
+        Returning a negative integer signals an error condition and abandons the
+        connection attempt. (A zero value is reserved for the default
+        implementation.)
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="libpq-PQgetAuthDataHook">
+      <term><function>PQgetAuthDataHook</function><indexterm><primary>PQgetAuthDataHook</primary></indexterm></term>
+
+      <listitem>
+       <para>
+        Retrieves the current value of <symbol>PGauthDataHook</symbol>.
+<synopsis>
+PQauthDataHook_type PQgetAuthDataHook(void);
+</synopsis>
+        At initialization time (before the first call to
+        <function>PQsetAuthDataHook</function>), this function will return
+        <symbol>PQdefaultAuthDataHook</symbol>.
+       </para>
+      </listitem>
+     </varlistentry>
+    </variablelist>
+   </para>
+
+   <sect3 id="libpq-oauth-authdata-hooks-types">
+    <title>Hook Types</title>
+    <para>
+     The following <symbol>PGauthData</symbol> types and their corresponding
+     <replaceable>data</replaceable> structures are defined:
+
+     <variablelist>
+      <varlistentry id="libpq-oauth-authdata-prompt-oauth-device">
+       <term>
+        <symbol>PQAUTHDATA_PROMPT_OAUTH_DEVICE</symbol>
+        <indexterm><primary>PQAUTHDATA_PROMPT_OAUTH_DEVICE</primary></indexterm>
+       </term>
+       <listitem>
+        <para>
+         Replaces the default user prompt during the builtin device
+         authorization client flow. <replaceable>data</replaceable> points to
+         an instance of <symbol>PGpromptOAuthDevice</symbol>:
+<synopsis>
+typedef struct _PGpromptOAuthDevice
+{
+    const char *verification_uri;   /* verification URI to visit */
+    const char *user_code;          /* user code to enter */
+    const char *verification_uri_complete;  /* optional combination of URI and
+                                             * code, or NULL */
+    int         expires_in;         /* seconds until user code expires */
+} PGpromptOAuthDevice;
+</synopsis>
+        </para>
+        <para>
+         The OAuth Device Authorization flow included in <application>libpq</application>
+         requires the end user to visit a URL with a browser, then enter a code
+         which permits <application>libpq</application> to connect to the server
+         on their behalf. The default prompt simply prints the
+         <literal>verification_uri</literal> and <literal>user_code</literal>
+         on standard error. Replacement implementations may display this
+         information using any preferred method, for example with a GUI.
+        </para>
+        <para>
+         This callback is only invoked during the builtin device
+         authorization flow. If the application installs a
+         <link linkend="libpq-oauth-authdata-oauth-bearer-token">custom OAuth
+         flow</link>, this authdata type will not be used.
+        </para>
+        <para>
+         If a non-NULL <structfield>verification_uri_complete</structfield> is
+         provided, it may optionally be used for non-textual verification (for
+         example, by displaying a QR code). The URL and user code should still
+         be displayed to the end user in this case, because the code will be
+         manually confirmed by the provider, and the URL lets users continue
+         even if they can't use the non-textual method. Review the RFC's
+         <ulink url="https://datatracker.ietf.org/doc/html/rfc8628#section-3.3.1">notes
+         on non-textual verification</ulink>.
+        </para>
+       </listitem>
+      </varlistentry>
+
+      <varlistentry id="libpq-oauth-authdata-oauth-bearer-token">
+       <term>
+        <symbol>PQAUTHDATA_OAUTH_BEARER_TOKEN</symbol>
+        <indexterm><primary>PQAUTHDATA_OAUTH_BEARER_TOKEN</primary></indexterm>
+       </term>
+       <listitem>
+        <para>
+         Replaces the entire OAuth flow with a custom implementation. The hook
+         should either directly return a Bearer token for the current
+         user/issuer/scope combination, if one is available without blocking, or
+         else set up an asynchronous callback to retrieve one.
+        </para>
+        <para>
+         <replaceable>data</replaceable> points to an instance
+         of <symbol>PGoauthBearerRequest</symbol>, which should be filled in
+         by the implementation:
+<synopsis>
+typedef struct _PGoauthBearerRequest
+{
+    /* Hook inputs (constant across all calls) */
+    const char *const openid_configuration; /* OIDC discovery URL */
+    const char *const scope;                /* required scope(s), or NULL */
+
+    /* Hook outputs */
+
+    /* Callback implementing a custom asynchronous OAuth flow. */
+    PostgresPollingStatusType (*async) (PGconn *conn,
+                                        struct _PGoauthBearerRequest *request,
+                                        SOCKTYPE *altsock);
+
+    /* Callback to clean up custom allocations. */
+    void        (*cleanup) (PGconn *conn, struct _PGoauthBearerRequest *request);
+
+    char       *token;   /* acquired Bearer token */
+    void       *user;    /* hook-defined allocated data */
+} PGoauthBearerRequest;
+</synopsis>
+        </para>
+        <para>
+         Two pieces of information are provided to the hook by
+         <application>libpq</application>:
+         <replaceable>openid_configuration</replaceable> contains the URL of an
+         OAuth discovery document describing the authorization server's
+         supported flows, and <replaceable>scope</replaceable> contains a
+         (possibly empty) space-separated list of OAuth scopes which are
+         required to access the server. Either or both may be
+         <literal>NULL</literal> to indicate that the information was not
+         discoverable. (In this case, implementations may be able to establish
+         the requirements using some other preconfigured knowledge, or they may
+         choose to fail.)
+        </para>
+        <para>
+         The final output of the hook is <replaceable>token</replaceable>, which
+         must point to a valid Bearer token for use on the connection. (This
+         token should be issued by the
+         <xref linkend="libpq-connect-oauth-issuer"/> and hold the requested
+         scopes, or the connection will be rejected by the server's validator
+         module.) The allocated token string must remain valid until
+         <application>libpq</application> is finished connecting; the hook
+         should set a <replaceable>cleanup</replaceable> callback which will be
+         called when <application>libpq</application> no longer requires it.
+        </para>
+        <para>
+         If an implementation cannot immediately produce a
+         <replaceable>token</replaceable> during the initial call to the hook,
+         it should set the <replaceable>async</replaceable> callback to handle
+         nonblocking communication with the authorization server.
+         <footnote>
+          <para>
+           Performing blocking operations during the
+           <symbol>PQAUTHDATA_OAUTH_BEARER_TOKEN</symbol> hook callback will
+           interfere with nonblocking connection APIs such as
+           <function>PQconnectPoll</function> and prevent concurrent connections
+           from making progress. Applications which only ever use the
+           synchronous connection primitives, such as
+           <function>PQconnectdb</function>, may synchronously retrieve a token
+           during the hook instead of implementing the
+           <replaceable>async</replaceable> callback, but they will necessarily
+           be limited to one connection at a time.
+          </para>
+         </footnote>
+         This will be called to begin the flow immediately upon return from the
+         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
+         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>
+         after setting <replaceable>token</replaceable> when the flow is
+         complete, or <symbol>PGRES_POLLING_FAILED</symbol> to indicate failure.
+        </para>
+        <para>
+         Implementations may wish to store additional data for bookkeeping
+         across calls to the <replaceable>async</replaceable> and
+         <replaceable>cleanup</replaceable> callbacks. The
+         <replaceable>user</replaceable> pointer is provided for this purpose;
+         <application>libpq</application> will not touch its contents and the
+         application may use it at its convenience. (Remember to free any
+         allocations during token cleanup.)
+        </para>
+       </listitem>
+      </varlistentry>
+     </variablelist>
+    </para>
+   </sect3>
+  </sect2>
+
+  <sect2 id="libpq-oauth-debugging">
+   <title>Debugging and Developer Settings</title>
+
+   <para>
+    A "dangerous debugging mode" may be enabled by setting the environment
+    variable <envar>PGOAUTHDEBUG=UNSAFE</envar>. This functionality is provided
+    for ease of local development and testing only. It does several things that
+    you will not want a production system to do:
+
+    <itemizedlist spacing="compact">
+     <listitem>
+      <para>
+       permits the use of unencrypted HTTP during the OAuth provider exchange
+      </para>
+     </listitem>
+     <listitem>
+      <para>
+       allows the system's trusted CA list to be completely replaced using the
+       <envar>PGOAUTHCAFILE</envar> environment variable
+      </para>
+     </listitem>
+     <listitem>
+      <para>
+       sprays HTTP traffic (containing several critical secrets) to standard
+       error during the OAuth flow
+      </para>
+     </listitem>
+     <listitem>
+      <para>
+       permits the use of zero-second retry intervals, which can cause the
+       client to busy-loop and pointlessly consume CPU
+      </para>
+     </listitem>
+    </itemizedlist>
+   </para>
+   <warning>
+    <para>
+     Do not share the output of the OAuth flow traffic with third parties. It
+     contains secrets that can be used to attack your clients and servers.
+    </para>
+   </warning>
+  </sect2>
+ </sect1>
+
 
  <sect1 id="libpq-threading">
   <title>Behavior in Threaded Programs</title>
@@ -10092,6 +10486,18 @@ int PQisthreadsafe();
    <application>libpq</application> source code for a way to do cooperative
    locking between <application>libpq</application> and your application.
   </para>
+
+  <para>
+   Similarly, if you are using Curl inside your application,
+   <emphasis>and</emphasis> you do not already
+   <ulink url="https://curl.se/libcurl/c/curl_global_init.html">initialize
+   libcurl globally</ulink> before starting new threads, you will need to
+   cooperatively lock (again via <function>PQregisterThreadLock</function>)
+   around any code that may initialize libcurl. This restriction is lifted for
+   more recent versions of Curl that are built to support threadsafe
+   initialization; those builds can be identified by the advertisement of a
+   <literal>threadsafe</literal> feature in their version metadata.
+  </para>
  </sect1>
 
 
diff --git a/doc/src/sgml/oauth-validators.sgml b/doc/src/sgml/oauth-validators.sgml
new file mode 100644
index 00000000000..d0bca9196d9
--- /dev/null
+++ b/doc/src/sgml/oauth-validators.sgml
@@ -0,0 +1,402 @@
+<!-- doc/src/sgml/oauth-validators.sgml -->
+
+<chapter id="oauth-validators">
+ <title>OAuth Validator Modules</title>
+ <indexterm zone="oauth-validators">
+  <primary>OAuth Validators</primary>
+ </indexterm>
+ <para>
+  <productname>PostgreSQL</productname> provides infrastructure for creating
+  custom modules to perform server-side validation of OAuth bearer tokens.
+  Because OAuth implementations vary so wildly, and bearer token validation is
+  heavily dependent on the issuing party, the server cannot check the token
+  itself; validator modules provide the glue between the server and the OAuth
+  provider in use.
+ </para>
+ <para>
+  OAuth validator modules must at least consist of an initialization function
+  (see <xref linkend="oauth-validator-init"/>) and the required callback for
+  performing validation (see <xref linkend="oauth-validator-callback-validate"/>).
+ </para>
+ <warning>
+  <para>
+   Since a misbehaving validator might let unauthorized users into the database,
+   correct implementation is critical. See
+   <xref linkend="oauth-validator-design"/> for design considerations.
+  </para>
+ </warning>
+
+ <sect1 id="oauth-validator-design">
+  <title>Safely Designing a Validator Module</title>
+  <warning>
+   <para>
+    Read and understand the entirety of this section before implementing a
+    validator module. A malfunctioning validator is potentially worse than no
+    authentication at all, both because of the false sense of security it
+    provides, and because it may contribute to attacks against other pieces of
+    an OAuth ecosystem.
+   </para>
+  </warning>
+
+  <sect2 id="oauth-validator-design-responsibilities">
+   <title>Validator Responsibilities</title>
+   <para>
+    TODO
+   </para>
+   <variablelist>
+    <varlistentry>
+     <term>Validate the Token</term>
+     <listitem>
+      <para>
+       The validator must first ensure that the presented token is in fact a
+       valid Bearer token for use in client authentication. The correct way to
+       do this depends on the provider, but it generally involves either
+       cryptographic operations to prove that the token was created by a trusted
+       party (offline validation), or the presentation of the token to that
+       trusted party so that it can perform validation for you (online
+       validation).
+      </para>
+      <para>
+       Online validation, usually implemented via
+       <ulink url="https://datatracker.ietf.org/doc/html/rfc7662">OAuth Token
+       Introspection</ulink>, requires fewer steps of a validator module and
+       allows central revocation of a token in the event that it is stolen
+       or misissued. However, it does require the module to make at least one
+       network call per authentication attempt (all of which must complete
+       within the configured <xref linkend="guc-authentication-timeout"/>).
+       Additionally, your provider may not provide introspection endpoints for
+       use by external resource servers.
+      </para>
+      <para>
+       Offline validation is much more involved, typically requiring a validator
+       to maintain a list of trusted signing keys for a provider and then
+       check the token's cryptographic signature along with its contents.
+       Implementations must follow the provider's instructions to the letter,
+       including any verification of issuer ("where is this token from?"),
+       audience ("who is this token for?"), and validity period ("when can this
+       token be used?"). Since there is no communication between the module and
+       the provider, tokens cannot be centrally revoked using this method;
+       offline validator implementations may wish to place restrictions on the
+       maximum length of a token's validity period.
+      </para>
+      <para>
+       If the token cannot be validated, the module should immediately fail.
+       Further authentication/authorization is pointless if the bearer token
+       wasn't issued by a trusted party.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Authorize the Client</term>
+     <listitem>
+      <para>
+       Next the validator must ensure that the end user has given the client
+       permission to access the server on their behalf. This generally involves
+       checking the scopes that have been assigned to the token, to make sure
+       that they cover database access for the current HBA parameters.
+      </para>
+      <para>
+       The purpose of this step is to prevent an OAuth client from obtaining a
+       token under false pretenses. If the validator requires all tokens to
+       carry scopes that cover database access, the provider should then loudly
+       prompt the user to grant that access during the flow. This gives them the
+       opportunity to reject the request if the client isn't supposed to be
+       using their credentials to connect to databases.
+      </para>
+      <para>
+       While it is possible to establish client authorization without explicit
+       scopes by using out-of-band knowledge of the deployed architecture, doing
+       so removes the user from the loop, which prevents them from catching
+       deployment mistakes and allows any such mistakes to be exploited
+       silently. Access to the database must be tightly restricted to only
+       trusted clients
+       <footnote>
+        <para>
+         That is, "trusted" in the sense that the OAuth client and the
+         <productname>PostgreSQL</productname> server are controlled by the same
+         entity. Notably, the Device Authorization client flow supported by
+         libpq does not usually meet this bar, since it's designed for use by
+         public/untrusted clients.
+        </para>
+       </footnote>
+       if users are not prompted for additional scopes.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Authenticate the End User</term>
+     <listitem>
+      <para>
+       Finally, the validator should determine a user identifier for the token,
+       either by asking the provider for this information or by extracting it
+       from the token itself, and return that identifier to the server (which
+       will then make a final authorization decision using the HBA
+       configuration). This identifier will be available within the session via
+       <link linkend="functions-info-session-table"><function>system_user</function></link>
+       and recorded in the server logs if <xref linkend="guc-log-connections"/>
+       is enabled.
+      </para>
+      <para>
+       Different providers may record a variety of different authentication
+       information for an end user, typically referred to as
+       <emphasis>claims</emphasis>. Providers usually document which of these
+       claims are trustworthy enough to use for authorization decisions and
+       which are not. (For instance, it would probably not be wise to use an
+       end user's full name as the identifier for authentication, since many
+       providers allow users to change their display names arbitrarily.)
+       Ultimately, the choice of which claim (or combination of claims) to use
+       comes down to the provider implementation and application requirements.
+      </para>
+      <para>
+       Note that anonymous/pseudonymous login is possible as well, by enabling
+       usermap delegation; see
+       <xref linkend="oauth-validator-design-usermap-delegation"/>.
+      </para>
+     </listitem>
+    </varlistentry>
+   </variablelist>
+  </sect2>
+
+  <sect2 id="oauth-validator-design-guidelines">
+   <title>General Coding Guidelines</title>
+   <para>
+    Developers should keep the following in mind when implementing token
+    validation:
+   </para>
+   <variablelist>
+    <varlistentry>
+     <term>Token Confidentiality</term>
+     <listitem>
+      <para>
+       Modules should not write tokens, or pieces of tokens, into the server
+       log. This is true even if the module considers the token invalid; an
+       attacker who confuses a client into communicating with the wrong provider
+       should not be able to retrieve that (otherwise valid) token from the
+       disk.
+      </para>
+      <para>
+       Implementations that send tokens over the network (for example, to
+       perform online token validation with a provider) must authenticate the
+       peer and ensure that strong transport security is in use.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Logging</term>
+     <listitem>
+      <para>
+       Modules may use the same <link linkend="error-message-reporting">logging
+       facilities</link> as standard extensions; however, the rules for emitting
+       log entries to the client are subtly different during the authentication
+       phase of the connection. Generally speaking, modules should log
+       verification problems at the <symbol>COMMERROR</symbol> level and return
+       normally, instead of using <symbol>ERROR</symbol>/<symbol>FATAL</symbol>
+       to unwind the stack, to avoid leaking information to unauthenticated
+       clients.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Interruptibility</term>
+     <listitem>
+      <para>
+       Modules must remain interruptible by signals so that the server can
+       correctly handle authentication timeouts and shutdown signals from
+       <application>pg_ctl</application>. For example, a module receiving
+       <symbol>EINTR</symbol>/<symbol>EAGAIN</symbol> from a blocking call
+       should call <function>CHECK_FOR_INTERRUPTS()</function> before retrying.
+       The same should be done during any long-running loops. Failure to follow
+       this guidance may result in unresponsive backend sessions.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Testing</term>
+     <listitem>
+      <para>
+       The breadth of testing an OAuth system is well beyond the scope of this
+       documentation, but at minimum, negative testing should be considered
+       mandatory. It's trivial to design a module that lets authorized users in;
+       the whole point of the system is to keep unauthorized users out.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Documentation</term>
+     <listitem>
+      <para>
+       Validator implementations should document the contents and format of the
+       authenticated ID that is reported to the server for each end user, since
+       DBAs may need to use this information to construct pg_ident maps. (For
+       instance, is it an email address? an organizational ID number? a UUID?)
+       They should also document whether or not it is safe to use the module in
+       <symbol>delegate_ident_mapping=1</symbol> mode, and what additional
+       configuration is required in order to do so.
+      </para>
+     </listitem>
+    </varlistentry>
+   </variablelist>
+  </sect2>
+
+  <sect2 id="oauth-validator-design-usermap-delegation">
+   <title>Authorizing Users (Usermap Delegation)</title>
+   <para>
+    The standard deliverable of a validation module is the user identifier,
+    which the server will then compare to any configured
+    <link linkend="auth-username-maps"><filename>pg_ident.conf</filename>
+    mappings</link> and determine whether the end user is authorized to connect.
+    However, OAuth is itself an authorization framework, and tokens may carry
+    information about user privileges. For example, a token may be associated
+    with the organizational groups that a user belongs to, or list the roles
+    that a user may assume, and duplicating that knowledge into local usermaps
+    for every server may not be desirable.
+   </para>
+   <para>
+    To bypass username mapping entirely, and have the validator module assume
+    the additional responsibility of authorizing user connections, the HBA may
+    be configured with <xref linkend="auth-oauth-delegate-ident-mapping"/>.
+    The module may then use token scopes or an equivalent method to decide
+    whether the user is allowed to connect under their desired role. The user
+    identifier will still be recorded by the server, but it plays no part in
+    determining whether to continue the connection.
+   </para>
+   <para>
+    Using this scheme, authentication itself is optional. As long as the module
+    reports that the connection is authorized, login will continue even if there
+    is no recorded user identifier at all. This makes it possible to implement
+    anonymous or pseudonymous access to the database, where the third-party
+    provider performs all necessary authentication but does not provide any
+    user-identifying information to the server. (Some providers may create an
+    anonymized ID number that can be recorded instead, for later auditing.)
+   </para>
+   <para>
+    Usermap delegation provides the most architectural flexibility, but it turns
+    the validator module into a single point of failure for connection
+    authorization. Use with caution.
+   </para>
+  </sect2>
+ </sect1>
+
+ <sect1 id="oauth-validator-init">
+  <title>Initialization Functions</title>
+  <indexterm zone="oauth-validator-init">
+   <primary>_PG_oauth_validator_module_init</primary>
+  </indexterm>
+  <para>
+   An OAuth validator module is loaded by dynamically loading one of the shared
+   libraries listed in <xref linkend="guc-oauth-validator-libraries"/>.
+   The normal library search path is used to locate the library. To
+   provide the validator callbacks and to indicate that the library is an OAuth
+   validator module a function named
+   <function>_PG_oauth_validator_module_init</function> must be provided. The
+   return value of the function must be a pointer to a struct of type
+   <structname>OAuthValidatorCallbacks</structname>, which contains pointers to
+   the module's token validation functions. The returned
+   pointer must be of server lifetime, which is typically achieved by defining
+   it as a <literal>static const</literal> variable in global scope.
+<programlisting>
+typedef struct OAuthValidatorCallbacks
+{
+    ValidatorStartupCB startup_cb;
+    ValidatorShutdownCB shutdown_cb;
+    ValidatorValidateCB validate_cb;
+} OAuthValidatorCallbacks;
+
+typedef const OAuthValidatorCallbacks *(*OAuthValidatorModuleInit) (void);
+</programlisting>
+
+   Only the <function>validate_cb</function> callback is required, the others
+   are optional.
+  </para>
+ </sect1>
+
+ <sect1 id="oauth-validator-callbacks">
+  <title>OAuth Validator Callbacks</title>
+  <para>
+   OAuth validator modules implement their functionality by defining a set of
+   callbacks. The server will call them as required to process the
+   authentication request from the user.
+  </para>
+
+  <sect2 id="oauth-validator-callback-startup">
+   <title>Startup Callback</title>
+   <para>
+    The <function>startup_cb</function> callback is executed directly after
+    loading the module. This callback can be used to set up local state and
+    perform additional initialization if required. If the validator module
+    has state it can use <structfield>state->private_data</structfield> to
+    store it.
+
+<programlisting>
+typedef void (*ValidatorStartupCB) (ValidatorModuleState *state);
+</programlisting>
+   </para>
+  </sect2>
+
+  <sect2 id="oauth-validator-callback-validate">
+   <title>Validate Callback</title>
+   <para>
+    The <function>validate_cb</function> callback is executed during the OAuth
+    exchange when a user attempts to authenticate using OAuth.  Any state set in
+    previous calls will be available in <structfield>state->private_data</structfield>.
+
+<programlisting>
+typedef ValidatorModuleResult *(*ValidatorValidateCB) (ValidatorModuleState *state, const char *token, const char *role);
+</programlisting>
+
+    <replaceable>token</replaceable> will contain the bearer token to validate.
+    The server has ensured that the token is well-formed syntactically, but no
+    other validation has been performed.  <replaceable>role</replaceable> will
+    contain the role the user has requested to log in as.  The callback must
+    return a palloc'd <literal>ValidatorModuleResult</literal> struct, which is
+    defined as below:
+
+<programlisting>
+typedef struct ValidatorModuleResult
+{
+    bool        authorized;
+    char       *authn_id;
+} ValidatorModuleResult;
+</programlisting>
+
+    The connection will only proceed if the module sets
+    <structfield>authorized</structfield> to <literal>true</literal>.  To
+    authenticate the user, the authenticated user name (as determined using the
+    token) shall be palloc'd and returned in the <structfield>authn_id</structfield>
+    field.  Alternatively, <structfield>authn_id</structfield> may be set to
+    NULL if the token is valid but the associated user identity cannot be
+    determined.
+   </para>
+   <para>
+    The caller assumes ownership of the returned memory allocation, the
+    validator module should not in any way access the memory after it has been
+    returned.  A validator may instead return NULL to signal an internal
+    error.
+   </para>
+   <para>
+    The behavior after <function>validate_cb</function> returns depends on the
+    specific HBA setup.  Normally, the <structfield>authn_id</structfield> user
+    name must exactly match the role that the user is logging in as.  (This
+    behavior may be modified with a usermap.)  But when authenticating against
+    an HBA rule with <literal>trust_validator_authz</literal> turned on, the
+    server will not perform any checks on the value of
+    <structfield>authn_id</structfield> at all; in this case it is up to the
+    validator to ensure that the token carries enough privileges for the user to
+    log in under the indicated <replaceable>role</replaceable>.
+   </para>
+  </sect2>
+
+  <sect2 id="oauth-validator-callback-shutdown">
+   <title>Shutdown Callback</title>
+   <para>
+    The <function>shutdown_cb</function> callback is executed when the backend
+    process associated with the connection exits. If the validator module has
+    any state, this callback should free it to avoid resource leaks.
+<programlisting>
+typedef void (*ValidatorShutdownCB) (ValidatorModuleState *state);
+</programlisting>
+   </para>
+  </sect2>
+
+ </sect1>
+</chapter>
diff --git a/doc/src/sgml/postgres.sgml b/doc/src/sgml/postgres.sgml
index 7be25c58507..af476c82fcc 100644
--- a/doc/src/sgml/postgres.sgml
+++ b/doc/src/sgml/postgres.sgml
@@ -229,6 +229,7 @@ break is not needed in a wider output rendering.
   &logicaldecoding;
   &replication-origins;
   &archive-modules;
+  &oauth-validators;
 
  </part>
 
diff --git a/doc/src/sgml/protocol.sgml b/doc/src/sgml/protocol.sgml
index fb5dec1172e..3bd9e68e6ce 100644
--- a/doc/src/sgml/protocol.sgml
+++ b/doc/src/sgml/protocol.sgml
@@ -1688,11 +1688,11 @@ SELCT 1/0;<!-- this typo is intentional -->
 
   <para>
    <firstterm>SASL</firstterm> is a framework for authentication in connection-oriented
-   protocols. At the moment, <productname>PostgreSQL</productname> implements two SASL
-   authentication mechanisms, SCRAM-SHA-256 and SCRAM-SHA-256-PLUS. More
-   might be added in the future. The below steps illustrate how SASL
-   authentication is performed in general, while the next subsection gives
-   more details on SCRAM-SHA-256 and SCRAM-SHA-256-PLUS.
+   protocols. At the moment, <productname>PostgreSQL</productname> implements three
+   SASL authentication mechanisms: SCRAM-SHA-256, SCRAM-SHA-256-PLUS, and
+   OAUTHBEARER. More might be added in the future. The below steps illustrate how SASL
+   authentication is performed in general, while the next subsections give
+   more details on particular mechanisms.
   </para>
 
   <procedure>
@@ -1727,7 +1727,7 @@ SELCT 1/0;<!-- this typo is intentional -->
    <step id="sasl-auth-end">
     <para>
      Finally, when the authentication exchange is completed successfully, the
-     server sends an AuthenticationSASLFinal message, followed
+     server sends an optional AuthenticationSASLFinal message, followed
      immediately by an AuthenticationOk message. The AuthenticationSASLFinal
      contains additional server-to-client data, whose content is particular to the
      selected authentication mechanism. If the authentication mechanism doesn't
@@ -1746,9 +1746,9 @@ SELCT 1/0;<!-- this typo is intentional -->
    <title>SCRAM-SHA-256 Authentication</title>
 
    <para>
-    The implemented SASL mechanisms at the moment
-    are <literal>SCRAM-SHA-256</literal> and its variant with channel
-    binding <literal>SCRAM-SHA-256-PLUS</literal>. They are described in
+    <literal>SCRAM-SHA-256</literal>, and its variant with channel
+    binding <literal>SCRAM-SHA-256-PLUS</literal>, are password-based
+    authentication mechanisms. They are described in
     detail in <ulink url="https://datatracker.ietf.org/doc/html/rfc7677">RFC 7677</ulink>
     and <ulink url="https://datatracker.ietf.org/doc/html/rfc5802">RFC 5802</ulink>.
    </para>
@@ -1850,6 +1850,121 @@ SELCT 1/0;<!-- this typo is intentional -->
     </step>
    </procedure>
   </sect2>
+
+  <sect2 id="sasl-oauthbearer">
+   <title>OAUTHBEARER Authentication</title>
+
+   <para>
+    <literal>OAUTHBEARER</literal> is a token-based mechanism for federated
+    authentication. It is described in detail in
+    <ulink url="https://datatracker.ietf.org/doc/html/rfc7628">RFC 7628</ulink>.
+   </para>
+
+   <para>
+    A typical exchange differs depending on whether or not the client already
+    has a bearer token cached for the current user. If it does not, the exchange
+    will take place over two connections: the first "discovery" connection to
+    obtain OAuth metadata from the server, and the second connection to send
+    the token after the client has obtained it. (libpq does not currently
+    implement a caching method as part of its builtin flow, so it uses the
+    two-connection exchange.)
+   </para>
+
+   <para>
+    This mechanism is client-initiated, like SCRAM. The client initial response
+    consists of the standard "GS2" header used by SCRAM, followed by a list of
+    <literal>key=value</literal> pairs. The only key currently supported by
+    the server is <literal>auth</literal>, which contains the bearer token.
+    <literal>OAUTHBEARER</literal> additionally specifies three optional
+    components of the client initial response (the <literal>authzid</literal> of
+    the GS2 header, and the <structfield>host</structfield> and
+    <structfield>port</structfield> keys) which are currently ignored by the
+    server.
+   </para>
+
+   <para>
+    <literal>OAUTHBEARER</literal> does not support channel binding, and there
+    is no "OAUTHBEARER-PLUS" mechanism. This mechanism does not make use of
+    server data during a successful authentication, so the
+    AuthenticationSASLFinal message is not used in the exchange.
+   </para>
+
+   <procedure>
+    <title>Example</title>
+    <step>
+     <para>
+      During the first exchange, the server sends an AuthenticationSASL message
+      with the <literal>OAUTHBEARER</literal> mechanism advertised.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      The client responds by sending a SASLInitialResponse message which
+      indicates the <literal>OAUTHBEARER</literal> mechanism. Assuming the
+      client does not already have a valid bearer token for the current user,
+      the <structfield>auth</structfield> field is empty, indicating a discovery
+      connection.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      Server sends an AuthenticationSASLContinue message containing an error
+      <literal>status</literal> alongside a well-known URI and scopes that the
+      client should use to conduct an OAuth flow.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      Client sends a SASLResponse message containing the empty set (a single
+      <literal>0x01</literal> byte) to finish its half of the discovery
+      exchange.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      Server sends an ErrorMessage to fail the first exchange.
+     </para>
+     <para>
+      At this point, the client conducts one of many possible OAuth flows to
+      obtain a bearer token, using any metadata that it has been configured with
+      in addition to that provided by the server. (This description is left
+      deliberately vague; <literal>OAUTHBEARER</literal> does not specify or
+      mandate any particular method for obtaining a token.)
+     </para>
+     <para>
+      Once it has a token, the client reconnects to the server for the final
+      exchange:
+     </para>
+    </step>
+
+    <step>
+     <para>
+      The server once again sends an AuthenticationSASL message with the
+      <literal>OAUTHBEARER</literal> mechanism advertised.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      The client responds by sending a SASLInitialResponse message, but this
+      time the <structfield>auth</structfield> field in the message contains the
+      bearer token that was obtained during the client flow.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      The server validates the token according to the instructions of the
+      token provider. If the client is authorized to connect, it sends an
+      AuthenticationOk message to end the SASL exchange.
+     </para>
+    </step>
+   </procedure>
+  </sect2>
  </sect1>
 
  <sect1 id="protocol-replication">
diff --git a/doc/src/sgml/regress.sgml b/doc/src/sgml/regress.sgml
index 7c474559bdf..0e5e8e8f309 100644
--- a/doc/src/sgml/regress.sgml
+++ b/doc/src/sgml/regress.sgml
@@ -347,6 +347,16 @@ make check-world PG_TEST_EXTRA='kerberos ldap ssl load_balance libpq_encryption'
       </para>
      </listitem>
     </varlistentry>
+
+    <varlistentry>
+     <term><literal>oauth</literal></term>
+     <listitem>
+      <para>
+       Runs the test suite under <filename>src/test/modules/oauth_validator</filename>.
+       This opens TCP/IP listen sockets for a test-server running HTTPS.
+      </para>
+     </listitem>
+    </varlistentry>
    </variablelist>
 
    Tests for features that are not supported by the current build
diff --git a/meson.build b/meson.build
index 1ceadb9a830..96e5f0f6434 100644
--- a/meson.build
+++ b/meson.build
@@ -854,6 +854,101 @@ endif
 
 
 
+###############################################################
+# Library: libcurl
+###############################################################
+
+libcurlopt = get_option('libcurl')
+if not libcurlopt.disabled()
+  # Check for libcurl 7.61.0 or higher (corresponding to RHEL8 and the ability
+  # to explicitly set TLS 1.3 ciphersuites).
+  libcurl = dependency('libcurl', version: '>= 7.61.0', required: libcurlopt)
+  if libcurl.found()
+    cdata.set('USE_LIBCURL', 1)
+
+    # Check to see whether the current platform supports threadsafe Curl
+    # initialization.
+    libcurl_threadsafe_init = false
+
+    if not meson.is_cross_build()
+      r = cc.run('''
+        #include <curl/curl.h>
+
+        int main(void)
+        {
+            curl_version_info_data *info;
+
+            if (curl_global_init(CURL_GLOBAL_ALL))
+                return -1;
+
+            info = curl_version_info(CURLVERSION_NOW);
+        #ifdef CURL_VERSION_THREADSAFE
+            if (info->features & CURL_VERSION_THREADSAFE)
+                return 0;
+        #endif
+
+            return 1;
+        }''',
+        name: 'test for curl_global_init thread safety',
+        dependencies: libcurl,
+      )
+
+      assert(r.compiled())
+      if r.returncode() == 0
+        libcurl_threadsafe_init = true
+        message('curl_global_init is threadsafe')
+      elif r.returncode() == 1
+        message('curl_global_init is not threadsafe')
+      else
+        message('curl_global_init failed; assuming not threadsafe')
+      endif
+    endif
+
+    if libcurl_threadsafe_init
+      cdata.set('HAVE_THREADSAFE_CURL_GLOBAL_INIT', 1)
+    endif
+
+    # Warn if a thread-friendly DNS resolver isn't built.
+    libcurl_async_dns = false
+
+    if not meson.is_cross_build()
+      r = cc.run('''
+        #include <curl/curl.h>
+
+        int main(void)
+        {
+            curl_version_info_data *info;
+
+            if (curl_global_init(CURL_GLOBAL_ALL))
+                return -1;
+
+            info = curl_version_info(CURLVERSION_NOW);
+            return (info->features & CURL_VERSION_ASYNCHDNS) ? 0 : 1;
+        }''',
+        name: 'test for curl support for asynchronous DNS',
+        dependencies: libcurl,
+      )
+
+      assert(r.compiled())
+      if r.returncode() == 0
+        libcurl_async_dns = true
+      endif
+    endif
+
+    if not libcurl_async_dns
+      warning('''
+*** The installed version of libcurl does not support asynchronous DNS
+*** lookups. Connection timeouts will not be honored during DNS resolution,
+*** which may lead to hangs in client programs.''')
+    endif
+  endif
+
+else
+  libcurl = not_found_dep
+endif
+
+
+
 ###############################################################
 # Library: libxml
 ###############################################################
@@ -3044,6 +3139,10 @@ libpq_deps += [
 
   gssapi,
   ldap_r,
+  # XXX libcurl must link after libgssapi_krb5 on FreeBSD to avoid segfaults
+  # during gss_acquire_cred(). This is possibly related to Curl's Heimdal
+  # dependency on that platform?
+  libcurl,
   libintl,
   ssl,
 ]
@@ -3720,6 +3819,7 @@ if meson.version().version_compare('>=0.57')
       'gss': gssapi,
       'icu': icu,
       'ldap': ldap,
+      'libcurl': libcurl,
       'libxml': libxml,
       'libxslt': libxslt,
       'llvm': llvm,
diff --git a/meson_options.txt b/meson_options.txt
index d9c7ddccbc4..702c4517145 100644
--- a/meson_options.txt
+++ b/meson_options.txt
@@ -100,6 +100,9 @@ option('icu', type: 'feature', value: 'auto',
 option('ldap', type: 'feature', value: 'auto',
   description: 'LDAP support')
 
+option('libcurl', type : 'feature', value: 'auto',
+  description: 'libcurl support')
+
 option('libedit_preferred', type: 'boolean', value: false,
   description: 'Prefer BSD Libedit over GNU Readline')
 
diff --git a/src/Makefile.global.in b/src/Makefile.global.in
index bbe11e75bf0..3b620bac5ac 100644
--- a/src/Makefile.global.in
+++ b/src/Makefile.global.in
@@ -190,6 +190,7 @@ with_systemd	= @with_systemd@
 with_gssapi	= @with_gssapi@
 with_krb_srvnam	= @with_krb_srvnam@
 with_ldap	= @with_ldap@
+with_libcurl	= @with_libcurl@
 with_libxml	= @with_libxml@
 with_libxslt	= @with_libxslt@
 with_llvm	= @with_llvm@
diff --git a/src/backend/libpq/Makefile b/src/backend/libpq/Makefile
index 6d385fd6a45..98eb2a8242d 100644
--- a/src/backend/libpq/Makefile
+++ b/src/backend/libpq/Makefile
@@ -15,6 +15,7 @@ include $(top_builddir)/src/Makefile.global
 # be-fsstubs is here for historical reasons, probably belongs elsewhere
 
 OBJS = \
+	auth-oauth.o \
 	auth-sasl.o \
 	auth-scram.o \
 	auth.o \
diff --git a/src/backend/libpq/auth-oauth.c b/src/backend/libpq/auth-oauth.c
new file mode 100644
index 00000000000..d910cbcb161
--- /dev/null
+++ b/src/backend/libpq/auth-oauth.c
@@ -0,0 +1,865 @@
+/*-------------------------------------------------------------------------
+ *
+ * auth-oauth.c
+ *	  Server-side implementation of the SASL OAUTHBEARER mechanism.
+ *
+ * See the following RFC for more details:
+ * - RFC 7628: https://tools.ietf.org/html/rfc7628
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/backend/libpq/auth-oauth.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include <unistd.h>
+#include <fcntl.h>
+
+#include "common/oauth-common.h"
+#include "fmgr.h"
+#include "lib/stringinfo.h"
+#include "libpq/auth.h"
+#include "libpq/hba.h"
+#include "libpq/oauth.h"
+#include "libpq/sasl.h"
+#include "storage/fd.h"
+#include "storage/ipc.h"
+#include "utils/json.h"
+#include "utils/varlena.h"
+
+/* GUC */
+char	   *oauth_validator_libraries_string = NULL;
+
+static void oauth_get_mechanisms(Port *port, StringInfo buf);
+static void *oauth_init(Port *port, const char *selected_mech, const char *shadow_pass);
+static int	oauth_exchange(void *opaq, const char *input, int inputlen,
+						   char **output, int *outputlen, const char **logdetail);
+
+static void load_validator_library(const char *libname);
+static void shutdown_validator_library(void *arg);
+
+static ValidatorModuleState *validator_module_state;
+static const OAuthValidatorCallbacks *ValidatorCallbacks;
+
+/* Mechanism declaration */
+const pg_be_sasl_mech pg_be_oauth_mech = {
+	.get_mechanisms = oauth_get_mechanisms,
+	.init = oauth_init,
+	.exchange = oauth_exchange,
+
+	.max_message_length = PG_MAX_AUTH_TOKEN_LENGTH,
+};
+
+/* Valid states for the oauth_exchange() machine. */
+enum oauth_state
+{
+	OAUTH_STATE_INIT = 0,
+	OAUTH_STATE_ERROR,
+	OAUTH_STATE_FINISHED,
+};
+
+/* Mechanism callback state. */
+struct oauth_ctx
+{
+	enum oauth_state state;
+	Port	   *port;
+	const char *issuer;
+	const char *scope;
+};
+
+static char *sanitize_char(char c);
+static char *parse_kvpairs_for_auth(char **input);
+static void generate_error_response(struct oauth_ctx *ctx, char **output, int *outputlen);
+static bool validate(Port *port, const char *auth);
+
+/* Constants seen in an OAUTHBEARER client initial response. */
+#define KVSEP 0x01				/* separator byte for key/value pairs */
+#define AUTH_KEY "auth"			/* key containing the Authorization header */
+#define BEARER_SCHEME "Bearer " /* required header scheme (case-insensitive!) */
+
+/*
+ * Retrieves the OAUTHBEARER mechanism list (currently a single item).
+ *
+ * For a full description of the API, see libpq/sasl.h.
+ */
+static void
+oauth_get_mechanisms(Port *port, StringInfo buf)
+{
+	/* Only OAUTHBEARER is supported. */
+	appendStringInfoString(buf, OAUTHBEARER_NAME);
+	appendStringInfoChar(buf, '\0');
+}
+
+/*
+ * Initializes mechanism state and loads the configured validator module.
+ *
+ * For a full description of the API, see libpq/sasl.h.
+ */
+static void *
+oauth_init(Port *port, const char *selected_mech, const char *shadow_pass)
+{
+	struct oauth_ctx *ctx;
+
+	if (strcmp(selected_mech, OAUTHBEARER_NAME) != 0)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("client selected an invalid SASL authentication mechanism"));
+
+	ctx = palloc0(sizeof(*ctx));
+
+	ctx->state = OAUTH_STATE_INIT;
+	ctx->port = port;
+
+	Assert(port->hba);
+	ctx->issuer = port->hba->oauth_issuer;
+	ctx->scope = port->hba->oauth_scope;
+
+	load_validator_library(port->hba->oauth_validator);
+
+	return ctx;
+}
+
+/*
+ * Implements the OAUTHBEARER SASL exchange (RFC 7628, Sec. 3.2). This pulls
+ * apart the client initial response and validates the Bearer token. It also
+ * handles the dummy error response for a failed handshake, as described in
+ * Sec. 3.2.3.
+ *
+ * For a full description of the API, see libpq/sasl.h.
+ */
+static int
+oauth_exchange(void *opaq, const char *input, int inputlen,
+			   char **output, int *outputlen, const char **logdetail)
+{
+	char	   *input_copy;
+	char	   *p;
+	char		cbind_flag;
+	char	   *auth;
+	int			status;
+
+	struct oauth_ctx *ctx = opaq;
+
+	*output = NULL;
+	*outputlen = -1;
+
+	/*
+	 * If the client didn't include an "Initial Client Response" in the
+	 * SASLInitialResponse message, send an empty challenge, to which the
+	 * client will respond with the same data that usually comes in the
+	 * Initial Client Response.
+	 */
+	if (input == NULL)
+	{
+		Assert(ctx->state == OAUTH_STATE_INIT);
+
+		*output = pstrdup("");
+		*outputlen = 0;
+		return PG_SASL_EXCHANGE_CONTINUE;
+	}
+
+	/*
+	 * Check that the input length agrees with the string length of the input.
+	 */
+	if (inputlen == 0)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("The message is empty."));
+	if (inputlen != strlen(input))
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message length does not match input length."));
+
+	switch (ctx->state)
+	{
+		case OAUTH_STATE_INIT:
+			/* Handle this case below. */
+			break;
+
+		case OAUTH_STATE_ERROR:
+
+			/*
+			 * Only one response is valid for the client during authentication
+			 * failure: a single kvsep.
+			 */
+			if (inputlen != 1 || *input != KVSEP)
+				ereport(ERROR,
+						errcode(ERRCODE_PROTOCOL_VIOLATION),
+						errmsg("malformed OAUTHBEARER message"),
+						errdetail("Client did not send a kvsep response."));
+
+			/* The (failed) handshake is now complete. */
+			ctx->state = OAUTH_STATE_FINISHED;
+			return PG_SASL_EXCHANGE_FAILURE;
+
+		default:
+			elog(ERROR, "invalid OAUTHBEARER exchange state");
+			return PG_SASL_EXCHANGE_FAILURE;
+	}
+
+	/* Handle the client's initial message. */
+	p = input_copy = pstrdup(input);
+
+	/*
+	 * OAUTHBEARER does not currently define a channel binding (so there is no
+	 * OAUTHBEARER-PLUS, and we do not accept a 'p' specifier). We accept a
+	 * 'y' specifier purely for the remote chance that a future specification
+	 * could define one; then future clients can still interoperate with this
+	 * server implementation. 'n' is the expected case.
+	 */
+	cbind_flag = *p;
+	switch (cbind_flag)
+	{
+		case 'p':
+			ereport(ERROR,
+					errcode(ERRCODE_PROTOCOL_VIOLATION),
+					errmsg("malformed OAUTHBEARER message"),
+					errdetail("The server does not support channel binding for OAuth, but the client message includes channel binding data."));
+			break;
+
+		case 'y':				/* fall through */
+		case 'n':
+			p++;
+			if (*p != ',')
+				ereport(ERROR,
+						errcode(ERRCODE_PROTOCOL_VIOLATION),
+						errmsg("malformed OAUTHBEARER message"),
+						errdetail("Comma expected, but found character \"%s\".",
+								  sanitize_char(*p)));
+			p++;
+			break;
+
+		default:
+			ereport(ERROR,
+					errcode(ERRCODE_PROTOCOL_VIOLATION),
+					errmsg("malformed OAUTHBEARER message"),
+					errdetail("Unexpected channel-binding flag \"%s\".",
+							  sanitize_char(cbind_flag)));
+	}
+
+	/*
+	 * Forbid optional authzid (authorization identity).  We don't support it.
+	 */
+	if (*p == 'a')
+		ereport(ERROR,
+				errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				errmsg("client uses authorization identity, but it is not supported"));
+	if (*p != ',')
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Unexpected attribute \"%s\" in client-first-message.",
+						  sanitize_char(*p)));
+	p++;
+
+	/* All remaining fields are separated by the RFC's kvsep (\x01). */
+	if (*p != KVSEP)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Key-value separator expected, but found character \"%s\".",
+						  sanitize_char(*p)));
+	p++;
+
+	auth = parse_kvpairs_for_auth(&p);
+	if (!auth)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message does not contain an auth value."));
+
+	/* We should be at the end of our message. */
+	if (*p)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message contains additional data after the final terminator."));
+
+	if (!validate(ctx->port, auth))
+	{
+		generate_error_response(ctx, output, outputlen);
+
+		ctx->state = OAUTH_STATE_ERROR;
+		status = PG_SASL_EXCHANGE_CONTINUE;
+	}
+	else
+	{
+		ctx->state = OAUTH_STATE_FINISHED;
+		status = PG_SASL_EXCHANGE_SUCCESS;
+	}
+
+	/* Don't let extra copies of the bearer token hang around. */
+	explicit_bzero(input_copy, inputlen);
+
+	return status;
+}
+
+/*
+ * Convert an arbitrary byte to printable form.  For error messages.
+ *
+ * If it's a printable ASCII character, print it as a single character.
+ * otherwise, print it in hex.
+ *
+ * The returned pointer points to a static buffer.
+ */
+static char *
+sanitize_char(char c)
+{
+	static char buf[5];
+
+	if (c >= 0x21 && c <= 0x7E)
+		snprintf(buf, sizeof(buf), "'%c'", c);
+	else
+		snprintf(buf, sizeof(buf), "0x%02x", (unsigned char) c);
+	return buf;
+}
+
+/*
+ * Performs syntactic validation of a key and value from the initial client
+ * response. (Semantic validation of interesting values must be performed
+ * later.)
+ */
+static void
+validate_kvpair(const char *key, const char *val)
+{
+	/*-----
+	 * From Sec 3.1:
+	 *     key            = 1*(ALPHA)
+	 */
+	static const char *key_allowed_set =
+		"abcdefghijklmnopqrstuvwxyz"
+		"ABCDEFGHIJKLMNOPQRSTUVWXYZ";
+
+	size_t		span;
+
+	if (!key[0])
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message contains an empty key name."));
+
+	span = strspn(key, key_allowed_set);
+	if (key[span] != '\0')
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message contains an invalid key name."));
+
+	/*-----
+	 * From Sec 3.1:
+	 *     value          = *(VCHAR / SP / HTAB / CR / LF )
+	 *
+	 * The VCHAR (visible character) class is large; a loop is more
+	 * straightforward than strspn().
+	 */
+	for (; *val; ++val)
+	{
+		if (0x21 <= *val && *val <= 0x7E)
+			continue;			/* VCHAR */
+
+		switch (*val)
+		{
+			case ' ':
+			case '\t':
+			case '\r':
+			case '\n':
+				continue;		/* SP, HTAB, CR, LF */
+
+			default:
+				ereport(ERROR,
+						errcode(ERRCODE_PROTOCOL_VIOLATION),
+						errmsg("malformed OAUTHBEARER message"),
+						errdetail("Message contains an invalid value."));
+		}
+	}
+}
+
+/*
+ * Consumes all kvpairs in an OAUTHBEARER exchange message. If the "auth" key is
+ * found, its value is returned.
+ */
+static char *
+parse_kvpairs_for_auth(char **input)
+{
+	char	   *pos = *input;
+	char	   *auth = NULL;
+
+	/*----
+	 * The relevant ABNF, from Sec. 3.1:
+	 *
+	 *     kvsep          = %x01
+	 *     key            = 1*(ALPHA)
+	 *     value          = *(VCHAR / SP / HTAB / CR / LF )
+	 *     kvpair         = key "=" value kvsep
+	 *   ;;gs2-header     = See RFC 5801
+	 *     client-resp    = (gs2-header kvsep *kvpair kvsep) / kvsep
+	 *
+	 * By the time we reach this code, the gs2-header and initial kvsep have
+	 * already been validated. We start at the beginning of the first kvpair.
+	 */
+
+	while (*pos)
+	{
+		char	   *end;
+		char	   *sep;
+		char	   *key;
+		char	   *value;
+
+		/*
+		 * Find the end of this kvpair. Note that input is null-terminated by
+		 * the SASL code, so the strchr() is bounded.
+		 */
+		end = strchr(pos, KVSEP);
+		if (!end)
+			ereport(ERROR,
+					errcode(ERRCODE_PROTOCOL_VIOLATION),
+					errmsg("malformed OAUTHBEARER message"),
+					errdetail("Message contains an unterminated key/value pair."));
+		*end = '\0';
+
+		if (pos == end)
+		{
+			/* Empty kvpair, signifying the end of the list. */
+			*input = pos + 1;
+			return auth;
+		}
+
+		/*
+		 * Find the end of the key name.
+		 */
+		sep = strchr(pos, '=');
+		if (!sep)
+			ereport(ERROR,
+					errcode(ERRCODE_PROTOCOL_VIOLATION),
+					errmsg("malformed OAUTHBEARER message"),
+					errdetail("Message contains a key without a value."));
+		*sep = '\0';
+
+		/* Both key and value are now safely terminated. */
+		key = pos;
+		value = sep + 1;
+		validate_kvpair(key, value);
+
+		if (strcmp(key, AUTH_KEY) == 0)
+		{
+			if (auth)
+				ereport(ERROR,
+						errcode(ERRCODE_PROTOCOL_VIOLATION),
+						errmsg("malformed OAUTHBEARER message"),
+						errdetail("Message contains multiple auth values."));
+
+			auth = value;
+		}
+		else
+		{
+			/*
+			 * The RFC also defines the host and port keys, but they are not
+			 * required for OAUTHBEARER and we do not use them. Also, per Sec.
+			 * 3.1, any key/value pairs we don't recognize must be ignored.
+			 */
+		}
+
+		/* Move to the next pair. */
+		pos = end + 1;
+	}
+
+	ereport(ERROR,
+			errcode(ERRCODE_PROTOCOL_VIOLATION),
+			errmsg("malformed OAUTHBEARER message"),
+			errdetail("Message did not contain a final terminator."));
+
+	pg_unreachable();
+	return NULL;
+}
+
+/*
+ * Builds the JSON response for failed authentication (RFC 7628, Sec. 3.2.2).
+ * This contains the required scopes for entry and a pointer to the OAuth/OpenID
+ * discovery document, which the client may use to conduct its OAuth flow.
+ */
+static void
+generate_error_response(struct oauth_ctx *ctx, char **output, int *outputlen)
+{
+	StringInfoData buf;
+	StringInfoData issuer;
+
+	/*
+	 * The admin needs to set an issuer and scope for OAuth to work. There's
+	 * not really a way to hide this from the user, either, because we can't
+	 * choose a "default" issuer, so be honest in the failure message.
+	 *
+	 * TODO: see if there's a better place to fail, earlier than this.
+	 */
+	if (!ctx->issuer || !ctx->scope)
+		ereport(FATAL,
+				errcode(ERRCODE_INTERNAL_ERROR),
+				errmsg("OAuth is not properly configured for this user"),
+				errdetail_log("The issuer and scope parameters must be set in pg_hba.conf."));
+
+	/*
+	 * Build a default .well-known URI based on our issuer, unless the HBA has
+	 * already provided one.
+	 */
+	initStringInfo(&issuer);
+	appendStringInfoString(&issuer, ctx->issuer);
+	if (strstr(ctx->issuer, "/.well-known/") == NULL)
+		appendStringInfoString(&issuer, "/.well-known/openid-configuration");
+
+	initStringInfo(&buf);
+
+	/*
+	 * Escaping the string here is belt-and-suspenders defensive programming
+	 * since escapable characters aren't valid in either the issuer URI or the
+	 * scope list, but the HBA doesn't enforce that yet.
+	 */
+	appendStringInfoString(&buf, "{ \"status\": \"invalid_token\", ");
+
+	appendStringInfoString(&buf, "\"openid-configuration\": ");
+	escape_json(&buf, issuer.data);
+	pfree(issuer.data);
+
+	appendStringInfoString(&buf, ", \"scope\": ");
+	escape_json(&buf, ctx->scope);
+
+	appendStringInfoString(&buf, " }");
+
+	*output = buf.data;
+	*outputlen = buf.len;
+}
+
+/*-----
+ * Validates the provided Authorization header and returns the token from
+ * within it. NULL is returned on validation failure.
+ *
+ * Only Bearer tokens are accepted. The ABNF is defined in RFC 6750, Sec.
+ * 2.1:
+ *
+ *      b64token    = 1*( ALPHA / DIGIT /
+ *                        "-" / "." / "_" / "~" / "+" / "/" ) *"="
+ *      credentials = "Bearer" 1*SP b64token
+ *
+ * The "credentials" construction is what we receive in our auth value.
+ *
+ * Since that spec is subordinate to HTTP (i.e. the HTTP Authorization
+ * header format; RFC 9110 Sec. 11), the "Bearer" scheme string must be
+ * compared case-insensitively. (This is not mentioned in RFC 6750, but the
+ * OAUTHBEARER spec points it out: RFC 7628 Sec. 4.)
+ *
+ * Invalid formats are technically a protocol violation, but we shouldn't
+ * reflect any information about the sensitive Bearer token back to the
+ * client; log at COMMERROR instead.
+ */
+static const char *
+validate_token_format(const char *header)
+{
+	size_t		span;
+	const char *token;
+	static const char *const b64token_allowed_set =
+		"abcdefghijklmnopqrstuvwxyz"
+		"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+		"0123456789-._~+/";
+
+	/* Missing auth headers should be handled by the caller. */
+	Assert(header);
+
+	if (header[0] == '\0')
+	{
+		/*
+		 * A completely empty auth header represents a query for
+		 * authentication parameters. The client expects it to fail; there's
+		 * no need to make any extra noise in the logs.
+		 *
+		 * TODO: should we find a way to return STATUS_EOF at the top level,
+		 * to suppress the authentication error entirely?
+		 */
+		return NULL;
+	}
+
+	if (pg_strncasecmp(header, BEARER_SCHEME, strlen(BEARER_SCHEME)))
+	{
+		ereport(COMMERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAuth bearer token"),
+				errdetail_log("Client response indicated a non-Bearer authentication scheme."));
+		return NULL;
+	}
+
+	/* Pull the bearer token out of the auth value. */
+	token = header + strlen(BEARER_SCHEME);
+
+	/* Swallow any additional spaces. */
+	while (*token == ' ')
+		token++;
+
+	/* Tokens must not be empty. */
+	if (!*token)
+	{
+		ereport(COMMERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAuth bearer token"),
+				errdetail_log("Bearer token is empty."));
+		return NULL;
+	}
+
+	/*
+	 * Make sure the token contains only allowed characters. Tokens may end
+	 * with any number of '=' characters.
+	 */
+	span = strspn(token, b64token_allowed_set);
+	while (token[span] == '=')
+		span++;
+
+	if (token[span] != '\0')
+	{
+		/*
+		 * This error message could be more helpful by printing the
+		 * problematic character(s), but that'd be a bit like printing a piece
+		 * of someone's password into the logs.
+		 */
+		ereport(COMMERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAuth bearer token"),
+				errdetail_log("Bearer token is not in the correct format."));
+		return NULL;
+	}
+
+	return token;
+}
+
+/*
+ * Checks that the "auth" kvpair in the client response contains a syntactically
+ * valid Bearer token, then passes it along to the loaded validator module for
+ * authorization. Returns true if validation succeeds.
+ */
+static bool
+validate(Port *port, const char *auth)
+{
+	int			map_status;
+	ValidatorModuleResult *ret;
+	const char *token;
+	bool		status;
+
+	/* Ensure that we have a correct token to validate */
+	if (!(token = validate_token_format(auth)))
+		return false;
+
+	/*
+	 * Ensure that we have a validation library loaded, this should always be
+	 * the case and an error here is indicative of a bug.
+	 */
+	if (!ValidatorCallbacks || !ValidatorCallbacks->validate_cb)
+		ereport(FATAL,
+				errcode(ERRCODE_INTERNAL_ERROR),
+				errmsg("validation of OAuth token requested without a validator loaded"));
+
+	/* Call the validation function from the validator module */
+	ret = ValidatorCallbacks->validate_cb(validator_module_state,
+										  token, port->user_name);
+	if (ret == NULL)
+	{
+		ereport(LOG, errmsg("internal error in OAuth validator module"));
+		return false;
+	}
+
+	/*
+	 * Log any authentication results even if the token isn't authorized; it
+	 * might be useful for auditing or troubleshooting.
+	 */
+	if (ret->authn_id)
+		set_authn_id(port, ret->authn_id);
+
+	if (!ret->authorized)
+	{
+		ereport(LOG,
+				errmsg("OAuth bearer authentication failed for user \"%s\"",
+					   port->user_name),
+				errdetail_log("Validator failed to authorize the provided token."));
+
+		status = false;
+		goto cleanup;
+	}
+
+	if (port->hba->oauth_skip_usermap)
+	{
+		/*
+		 * If the validator is our authorization authority, we're done.
+		 * Authentication may or may not have been performed depending on the
+		 * validator implementation; all that matters is that the validator
+		 * says the user can log in with the target role.
+		 */
+		status = true;
+		goto cleanup;
+	}
+
+	/* Make sure the validator authenticated the user. */
+	if (ret->authn_id == NULL || ret->authn_id[0] == '\0')
+	{
+		ereport(LOG,
+				errmsg("OAuth bearer authentication failed for user \"%s\"",
+					   port->user_name),
+				errdetail_log("Validator provided no identity."));
+
+		status = false;
+		goto cleanup;
+	}
+
+	/* Finally, check the user map. */
+	map_status = check_usermap(port->hba->usermap, port->user_name,
+							   MyClientConnectionInfo.authn_id, false);
+	status = (map_status == STATUS_OK);
+
+cleanup:
+
+	/*
+	 * Clear and free the validation result from the validator module once
+	 * we're done with it.
+	 */
+	if (ret->authn_id != NULL)
+		pfree(ret->authn_id);
+	pfree(ret);
+
+	return status;
+}
+
+/*
+ * load_validator_library
+ *
+ * Load the configured validator library in order to perform token validation.
+ * There is no built-in fallback since validation is implementation specific. If
+ * no validator library is configured, or if it fails to load, then error out
+ * since token validation won't be possible.
+ */
+static void
+load_validator_library(const char *libname)
+{
+	OAuthValidatorModuleInit validator_init;
+	MemoryContextCallback *mcb;
+
+	Assert(libname && *libname);
+
+	validator_init = (OAuthValidatorModuleInit)
+		load_external_function(libname, "_PG_oauth_validator_module_init",
+							   false, NULL);
+
+	/*
+	 * The validator init function is required since it will set the callbacks
+	 * for the validator library.
+	 */
+	if (validator_init == NULL)
+		ereport(ERROR,
+				errmsg("%s module \"%s\" must define the symbol %s",
+					   "OAuth validator", libname, "_PG_oauth_validator_module_init"));
+
+	ValidatorCallbacks = (*validator_init) ();
+	Assert(ValidatorCallbacks);
+
+	/* Allocate memory for validator library private state data */
+	validator_module_state = (ValidatorModuleState *) palloc0(sizeof(ValidatorModuleState));
+	if (ValidatorCallbacks->startup_cb != NULL)
+		ValidatorCallbacks->startup_cb(validator_module_state);
+
+	/* Shut down the library before cleaning up its state. */
+	mcb = palloc0(sizeof(*mcb));
+	mcb->func = shutdown_validator_library;
+
+	MemoryContextRegisterResetCallback(CurrentMemoryContext, mcb);
+}
+
+/*
+ * Call the validator module's shutdown callback, if one is provided. This is
+ * invoked during memory context reset.
+ */
+static void
+shutdown_validator_library(void *arg)
+{
+	if (ValidatorCallbacks->shutdown_cb != NULL)
+		ValidatorCallbacks->shutdown_cb(validator_module_state);
+}
+
+/*
+ * Ensure an OAuth validator named in the HBA is permitted by the configuration.
+ *
+ * If the validator is currently unset and exactly one library is declared in
+ * oauth_validator_libraries, then that library will be used as the validator.
+ * Otherwise the name must be present in the list of oauth_validator_libraries.
+ */
+bool
+check_oauth_validator(HbaLine *hbaline, int elevel, char **err_msg)
+{
+	int			line_num = hbaline->linenumber;
+	char	   *file_name = hbaline->sourcefile;
+	char	   *rawstring;
+	List	   *elemlist = NIL;
+
+	*err_msg = NULL;
+
+	if (oauth_validator_libraries_string[0] == '\0')
+	{
+		ereport(elevel,
+				errcode(ERRCODE_CONFIG_FILE_ERROR),
+				errmsg("oauth_validator_libraries must be set for authentication method %s",
+					   "oauth"),
+				errcontext("line %d of configuration file \"%s\"",
+						   line_num, file_name));
+		*err_msg = psprintf("oauth_validator_libraries must be set for authentication method %s",
+							"oauth");
+		return false;
+	}
+
+	/* SplitDirectoriesString needs a modifiable copy */
+	rawstring = pstrdup(oauth_validator_libraries_string);
+
+	if (!SplitDirectoriesString(rawstring, ',', &elemlist))
+	{
+		/* syntax error in list */
+		ereport(elevel,
+				errcode(ERRCODE_CONFIG_FILE_ERROR),
+				errmsg("invalid list syntax in parameter \"%s\"",
+					   "oauth_validator_libraries"));
+		*err_msg = psprintf("invalid list syntax in parameter \"%s\"",
+							"oauth_validator_libraries");
+		goto done;
+	}
+
+	if (!hbaline->oauth_validator)
+	{
+		if (elemlist->length == 1)
+		{
+			hbaline->oauth_validator = pstrdup(linitial(elemlist));
+			goto done;
+		}
+
+		ereport(elevel,
+				errcode(ERRCODE_CONFIG_FILE_ERROR),
+				errmsg("authentication method \"oauth\" requires argument \"validator\" to be set when oauth_validator_libraries contains multiple options"),
+				errcontext("line %d of configuration file \"%s\"",
+						   line_num, file_name));
+		*err_msg = "authentication method \"oauth\" requires argument \"validator\" to be set when oauth_validator_libraries contains multiple options";
+		goto done;
+	}
+
+	foreach_ptr(char, allowed, elemlist)
+	{
+		if (strcmp(allowed, hbaline->oauth_validator) == 0)
+			goto done;
+	}
+
+	ereport(elevel,
+			errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+			errmsg("validator \"%s\" is not permitted by %s",
+				   hbaline->oauth_validator, "oauth_validator_libraries"),
+			errcontext("line %d of configuration file \"%s\"",
+					   line_num, file_name));
+	*err_msg = psprintf("validator \"%s\" is not permitted by %s",
+						hbaline->oauth_validator, "oauth_validator_libraries");
+
+done:
+	list_free_deep(elemlist);
+	pfree(rawstring);
+
+	return (*err_msg == NULL);
+}
diff --git a/src/backend/libpq/auth.c b/src/backend/libpq/auth.c
index d6ef32cc823..0f65014e64f 100644
--- a/src/backend/libpq/auth.c
+++ b/src/backend/libpq/auth.c
@@ -29,6 +29,7 @@
 #include "libpq/auth.h"
 #include "libpq/crypt.h"
 #include "libpq/libpq.h"
+#include "libpq/oauth.h"
 #include "libpq/pqformat.h"
 #include "libpq/sasl.h"
 #include "libpq/scram.h"
@@ -45,7 +46,6 @@
  */
 static void auth_failed(Port *port, int status, const char *logdetail);
 static char *recv_password_packet(Port *port);
-static void set_authn_id(Port *port, const char *id);
 
 
 /*----------------------------------------------------------------
@@ -289,6 +289,9 @@ auth_failed(Port *port, int status, const char *logdetail)
 		case uaRADIUS:
 			errstr = gettext_noop("RADIUS authentication failed for user \"%s\"");
 			break;
+		case uaOAuth:
+			errstr = gettext_noop("OAuth bearer authentication failed for user \"%s\"");
+			break;
 		default:
 			errstr = gettext_noop("authentication failed for user \"%s\": invalid authentication method");
 			break;
@@ -324,7 +327,7 @@ auth_failed(Port *port, int status, const char *logdetail)
  * lifetime of MyClientConnectionInfo, so it is safe to pass a string that is
  * managed by an external library.
  */
-static void
+void
 set_authn_id(Port *port, const char *id)
 {
 	Assert(id);
@@ -611,6 +614,9 @@ ClientAuthentication(Port *port)
 		case uaTrust:
 			status = STATUS_OK;
 			break;
+		case uaOAuth:
+			status = CheckSASLAuth(&pg_be_oauth_mech, port, NULL, NULL);
+			break;
 	}
 
 	if ((status == STATUS_OK && port->hba->clientcert == clientCertFull)
diff --git a/src/backend/libpq/hba.c b/src/backend/libpq/hba.c
index 510c9ffc6d7..332fad27835 100644
--- a/src/backend/libpq/hba.c
+++ b/src/backend/libpq/hba.c
@@ -32,6 +32,7 @@
 #include "libpq/hba.h"
 #include "libpq/ifaddr.h"
 #include "libpq/libpq-be.h"
+#include "libpq/oauth.h"
 #include "postmaster/postmaster.h"
 #include "regex/regex.h"
 #include "replication/walsender.h"
@@ -114,7 +115,8 @@ static const char *const UserAuthName[] =
 	"ldap",
 	"cert",
 	"radius",
-	"peer"
+	"peer",
+	"oauth",
 };
 
 /*
@@ -1747,6 +1749,8 @@ parse_hba_line(TokenizedAuthLine *tok_line, int elevel)
 #endif
 	else if (strcmp(token->string, "radius") == 0)
 		parsedline->auth_method = uaRADIUS;
+	else if (strcmp(token->string, "oauth") == 0)
+		parsedline->auth_method = uaOAuth;
 	else
 	{
 		ereport(elevel,
@@ -2039,6 +2043,36 @@ parse_hba_line(TokenizedAuthLine *tok_line, int elevel)
 		parsedline->clientcert = clientCertFull;
 	}
 
+	/*
+	 * Enforce proper configuration of OAuth authentication.
+	 */
+	if (parsedline->auth_method == uaOAuth)
+	{
+		MANDATORY_AUTH_ARG(parsedline->oauth_scope, "scope", "oauth");
+		MANDATORY_AUTH_ARG(parsedline->oauth_issuer, "issuer", "oauth");
+
+		/* Ensure a validator library is set and permitted by the config. */
+		if (!check_oauth_validator(parsedline, elevel, err_msg))
+			return NULL;
+
+		/*
+		 * Supplying a usermap combined with the option to skip usermapping is
+		 * nonsensical and indicates a configuration error.
+		 */
+		if (parsedline->oauth_skip_usermap && parsedline->usermap != NULL)
+		{
+			ereport(elevel,
+					errcode(ERRCODE_CONFIG_FILE_ERROR),
+			/* translator: strings are replaced with hba options */
+					errmsg("%s cannot be used in combination with %s",
+						   "map", "delegate_ident_mapping"),
+					errcontext("line %d of configuration file \"%s\"",
+							   line_num, file_name));
+			*err_msg = "map cannot be used in combination with delegate_ident_mapping";
+			return NULL;
+		}
+	}
+
 	return parsedline;
 }
 
@@ -2066,8 +2100,9 @@ parse_hba_auth_opt(char *name, char *val, HbaLine *hbaline,
 			hbaline->auth_method != uaPeer &&
 			hbaline->auth_method != uaGSS &&
 			hbaline->auth_method != uaSSPI &&
-			hbaline->auth_method != uaCert)
-			INVALID_AUTH_OPTION("map", gettext_noop("ident, peer, gssapi, sspi, and cert"));
+			hbaline->auth_method != uaCert &&
+			hbaline->auth_method != uaOAuth)
+			INVALID_AUTH_OPTION("map", gettext_noop("ident, peer, gssapi, sspi, cert, and oauth"));
 		hbaline->usermap = pstrdup(val);
 	}
 	else if (strcmp(name, "clientcert") == 0)
@@ -2450,6 +2485,29 @@ parse_hba_auth_opt(char *name, char *val, HbaLine *hbaline,
 		hbaline->radiusidentifiers = parsed_identifiers;
 		hbaline->radiusidentifiers_s = pstrdup(val);
 	}
+	else if (strcmp(name, "issuer") == 0)
+	{
+		REQUIRE_AUTH_OPTION(uaOAuth, "issuer", "oauth");
+		hbaline->oauth_issuer = pstrdup(val);
+	}
+	else if (strcmp(name, "scope") == 0)
+	{
+		REQUIRE_AUTH_OPTION(uaOAuth, "scope", "oauth");
+		hbaline->oauth_scope = pstrdup(val);
+	}
+	else if (strcmp(name, "validator") == 0)
+	{
+		REQUIRE_AUTH_OPTION(uaOAuth, "validator", "oauth");
+		hbaline->oauth_validator = pstrdup(val);
+	}
+	else if (strcmp(name, "delegate_ident_mapping") == 0)
+	{
+		REQUIRE_AUTH_OPTION(uaOAuth, "delegate_ident_mapping", "oauth");
+		if (strcmp(val, "1") == 0)
+			hbaline->oauth_skip_usermap = true;
+		else
+			hbaline->oauth_skip_usermap = false;
+	}
 	else
 	{
 		ereport(elevel,
diff --git a/src/backend/libpq/meson.build b/src/backend/libpq/meson.build
index 0f0421037e4..31aa2faae1e 100644
--- a/src/backend/libpq/meson.build
+++ b/src/backend/libpq/meson.build
@@ -1,6 +1,7 @@
 # Copyright (c) 2022-2025, PostgreSQL Global Development Group
 
 backend_sources += files(
+  'auth-oauth.c',
   'auth-sasl.c',
   'auth-scram.c',
   'auth.c',
diff --git a/src/backend/libpq/pg_hba.conf.sample b/src/backend/libpq/pg_hba.conf.sample
index bad13497a34..b64c8dea97c 100644
--- a/src/backend/libpq/pg_hba.conf.sample
+++ b/src/backend/libpq/pg_hba.conf.sample
@@ -53,8 +53,8 @@
 # directly connected to.
 #
 # METHOD can be "trust", "reject", "md5", "password", "scram-sha-256",
-# "gss", "sspi", "ident", "peer", "pam", "ldap", "radius" or "cert".
-# Note that "password" sends passwords in clear text; "md5" or
+# "gss", "sspi", "ident", "peer", "pam", "oauth", "ldap", "radius" or
+# "cert".  Note that "password" sends passwords in clear text; "md5" or
 # "scram-sha-256" are preferred since they send encrypted passwords.
 #
 # OPTIONS are a set of options for the authentication in the format
diff --git a/src/backend/utils/adt/hbafuncs.c b/src/backend/utils/adt/hbafuncs.c
index 03c38e8c451..b62c3d944cf 100644
--- a/src/backend/utils/adt/hbafuncs.c
+++ b/src/backend/utils/adt/hbafuncs.c
@@ -152,6 +152,25 @@ get_hba_options(HbaLine *hba)
 				CStringGetTextDatum(psprintf("radiusports=%s", hba->radiusports_s));
 	}
 
+	if (hba->auth_method == uaOAuth)
+	{
+		if (hba->oauth_issuer)
+			options[noptions++] =
+				CStringGetTextDatum(psprintf("issuer=%s", hba->oauth_issuer));
+
+		if (hba->oauth_scope)
+			options[noptions++] =
+				CStringGetTextDatum(psprintf("scope=%s", hba->oauth_scope));
+
+		if (hba->oauth_validator)
+			options[noptions++] =
+				CStringGetTextDatum(psprintf("validator=%s", hba->oauth_validator));
+
+		if (hba->oauth_skip_usermap)
+			options[noptions++] =
+				CStringGetTextDatum(psprintf("delegate_ident_mapping=true"));
+	}
+
 	/* If you add more options, consider increasing MAX_HBA_OPTIONS. */
 	Assert(noptions <= MAX_HBA_OPTIONS);
 
diff --git a/src/backend/utils/misc/guc_tables.c b/src/backend/utils/misc/guc_tables.c
index ce7534d4d23..7747a09c2a9 100644
--- a/src/backend/utils/misc/guc_tables.c
+++ b/src/backend/utils/misc/guc_tables.c
@@ -49,6 +49,7 @@
 #include "jit/jit.h"
 #include "libpq/auth.h"
 #include "libpq/libpq.h"
+#include "libpq/oauth.h"
 #include "libpq/scram.h"
 #include "nodes/queryjumble.h"
 #include "optimizer/cost.h"
@@ -4832,6 +4833,17 @@ struct config_string ConfigureNamesString[] =
 		check_restrict_nonsystem_relation_kind, assign_restrict_nonsystem_relation_kind, NULL
 	},
 
+	{
+		{"oauth_validator_libraries", PGC_SIGHUP, CONN_AUTH_AUTH,
+			gettext_noop("Lists libraries that may be called to validate OAuth v2 bearer tokens."),
+			NULL,
+			GUC_LIST_INPUT | GUC_LIST_QUOTE | GUC_SUPERUSER_ONLY
+		},
+		&oauth_validator_libraries_string,
+		"",
+		NULL, NULL, NULL
+	},
+
 	/* End-of-list marker */
 	{
 		{NULL, 0, 0, NULL, NULL}, NULL, NULL, NULL, NULL, NULL
diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample
index c40b7a3121e..9184ea0f1d4 100644
--- a/src/backend/utils/misc/postgresql.conf.sample
+++ b/src/backend/utils/misc/postgresql.conf.sample
@@ -121,6 +121,9 @@
 #ssl_passphrase_command = ''
 #ssl_passphrase_command_supports_reload = off
 
+# OAuth
+#oauth_validator_libraries = ''	# comma-separated list of trusted validator modules
+
 
 #------------------------------------------------------------------------------
 # RESOURCE USAGE (except WAL)
diff --git a/src/include/common/oauth-common.h b/src/include/common/oauth-common.h
new file mode 100644
index 00000000000..8fe56267780
--- /dev/null
+++ b/src/include/common/oauth-common.h
@@ -0,0 +1,19 @@
+/*-------------------------------------------------------------------------
+ *
+ * oauth-common.h
+ *		Declarations for helper functions used for OAuth/OIDC authentication
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/include/common/oauth-common.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef OAUTH_COMMON_H
+#define OAUTH_COMMON_H
+
+/* Name of SASL mechanism per IANA */
+#define OAUTHBEARER_NAME "OAUTHBEARER"
+
+#endif							/* OAUTH_COMMON_H */
diff --git a/src/include/libpq/auth.h b/src/include/libpq/auth.h
index 902c5f6de32..25b5742068f 100644
--- a/src/include/libpq/auth.h
+++ b/src/include/libpq/auth.h
@@ -39,6 +39,7 @@ extern PGDLLIMPORT bool pg_gss_accept_delegation;
 extern void ClientAuthentication(Port *port);
 extern void sendAuthRequest(Port *port, AuthRequest areq, const char *extradata,
 							int extralen);
+extern void set_authn_id(Port *port, const char *id);
 
 /* Hook for plugins to get control in ClientAuthentication() */
 typedef void (*ClientAuthentication_hook_type) (Port *, int);
diff --git a/src/include/libpq/hba.h b/src/include/libpq/hba.h
index b20d0051f7d..3657f182db3 100644
--- a/src/include/libpq/hba.h
+++ b/src/include/libpq/hba.h
@@ -39,7 +39,8 @@ typedef enum UserAuth
 	uaCert,
 	uaRADIUS,
 	uaPeer,
-#define USER_AUTH_LAST uaPeer	/* Must be last value of this enum */
+	uaOAuth,
+#define USER_AUTH_LAST uaOAuth	/* Must be last value of this enum */
 } UserAuth;
 
 /*
@@ -135,6 +136,10 @@ typedef struct HbaLine
 	char	   *radiusidentifiers_s;
 	List	   *radiusports;
 	char	   *radiusports_s;
+	char	   *oauth_issuer;
+	char	   *oauth_scope;
+	char	   *oauth_validator;
+	bool		oauth_skip_usermap;
 } HbaLine;
 
 typedef struct IdentLine
diff --git a/src/include/libpq/oauth.h b/src/include/libpq/oauth.h
new file mode 100644
index 00000000000..4fcdda74305
--- /dev/null
+++ b/src/include/libpq/oauth.h
@@ -0,0 +1,54 @@
+/*-------------------------------------------------------------------------
+ *
+ * oauth.h
+ *	  Interface to libpq/auth-oauth.c
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/include/libpq/oauth.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PG_OAUTH_H
+#define PG_OAUTH_H
+
+#include "libpq/libpq-be.h"
+#include "libpq/sasl.h"
+
+extern PGDLLIMPORT char *oauth_validator_libraries_string;
+
+typedef struct ValidatorModuleState
+{
+	void	   *private_data;
+} ValidatorModuleState;
+
+typedef struct ValidatorModuleResult
+{
+	bool		authorized;
+	char	   *authn_id;
+} ValidatorModuleResult;
+
+typedef void (*ValidatorStartupCB) (ValidatorModuleState *state);
+typedef void (*ValidatorShutdownCB) (ValidatorModuleState *state);
+typedef ValidatorModuleResult *(*ValidatorValidateCB) (ValidatorModuleState *state, const char *token, const char *role);
+
+typedef struct OAuthValidatorCallbacks
+{
+	ValidatorStartupCB startup_cb;
+	ValidatorShutdownCB shutdown_cb;
+	ValidatorValidateCB validate_cb;
+} OAuthValidatorCallbacks;
+
+typedef const OAuthValidatorCallbacks *(*OAuthValidatorModuleInit) (void);
+extern PGDLLEXPORT const OAuthValidatorCallbacks *_PG_oauth_validator_module_init(void);
+
+/* Implementation */
+extern const pg_be_sasl_mech pg_be_oauth_mech;
+
+/*
+ * Ensure a validator named in the HBA is permitted by the configuration.
+ */
+extern bool check_oauth_validator(HbaLine *hba, int elevel, char **err_msg);
+
+#endif							/* PG_OAUTH_H */
diff --git a/src/include/pg_config.h.in b/src/include/pg_config.h.in
index 07b2f798abd..c04ee38d086 100644
--- a/src/include/pg_config.h.in
+++ b/src/include/pg_config.h.in
@@ -229,6 +229,9 @@
 /* Define to 1 if you have the `crypto' library (-lcrypto). */
 #undef HAVE_LIBCRYPTO
 
+/* Define to 1 if you have the `curl' library (-lcurl). */
+#undef HAVE_LIBCURL
+
 /* Define to 1 if you have the `ldap' library (-lldap). */
 #undef HAVE_LIBLDAP
 
@@ -442,6 +445,9 @@
 /* Define to 1 if you have the <termios.h> header file. */
 #undef HAVE_TERMIOS_H
 
+/* Define to 1 if curl_global_init() is guaranteed to be threadsafe. */
+#undef HAVE_THREADSAFE_CURL_GLOBAL_INIT
+
 /* Define to 1 if your compiler understands `typeof' or something similar. */
 #undef HAVE_TYPEOF
 
@@ -663,6 +669,9 @@
 /* Define to 1 to build with LDAP support. (--with-ldap) */
 #undef USE_LDAP
 
+/* Define to 1 to build with libcurl support. (--with-libcurl) */
+#undef USE_LIBCURL
+
 /* Define to 1 to build with XML support. (--with-libxml) */
 #undef USE_LIBXML
 
diff --git a/src/interfaces/libpq/Makefile b/src/interfaces/libpq/Makefile
index 701810a272a..90b0b65db6f 100644
--- a/src/interfaces/libpq/Makefile
+++ b/src/interfaces/libpq/Makefile
@@ -31,6 +31,7 @@ endif
 
 OBJS = \
 	$(WIN32RES) \
+	fe-auth-oauth.o \
 	fe-auth-scram.o \
 	fe-cancel.o \
 	fe-connect.o \
@@ -63,6 +64,10 @@ OBJS += \
 	fe-secure-gssapi.o
 endif
 
+ifeq ($(with_libcurl),yes)
+OBJS += fe-auth-oauth-curl.o
+endif
+
 ifeq ($(PORTNAME), cygwin)
 override shlib = cyg$(NAME)$(DLSUFFIX)
 endif
@@ -81,7 +86,7 @@ endif
 # that are built correctly for use in a shlib.
 SHLIB_LINK_INTERNAL = -lpgcommon_shlib -lpgport_shlib
 ifneq ($(PORTNAME), win32)
-SHLIB_LINK += $(filter -lcrypt -ldes -lcom_err -lcrypto -lk5crypto -lkrb5 -lgssapi_krb5 -lgss -lgssapi -lssl -lsocket -lnsl -lresolv -lintl -lm, $(LIBS)) $(LDAP_LIBS_FE) $(PTHREAD_LIBS)
+SHLIB_LINK += $(filter -lcrypt -ldes -lcom_err -lcrypto -lk5crypto -lkrb5 -lgssapi_krb5 -lgss -lgssapi -lssl -lcurl -lsocket -lnsl -lresolv -lintl -lm, $(LIBS)) $(LDAP_LIBS_FE) $(PTHREAD_LIBS)
 else
 SHLIB_LINK += $(filter -lcrypt -ldes -lcom_err -lcrypto -lk5crypto -lkrb5 -lgssapi32 -lssl -lsocket -lnsl -lresolv -lintl -lm $(PTHREAD_LIBS), $(LIBS)) $(LDAP_LIBS_FE)
 endif
@@ -110,6 +115,8 @@ backend_src = $(top_srcdir)/src/backend
 # which seems to insert references to that even in pure C code. Excluding
 # __tsan_func_exit is necessary when using ThreadSanitizer data race detector
 # which use this function for instrumentation of function exit.
+# libcurl registers an exit handler in the memory debugging code when running
+# with LeakSanitizer.
 # Skip the test when profiling, as gcc may insert exit() calls for that.
 # Also skip the test on platforms where libpq infrastructure may be provided
 # by statically-linked libraries, as we can't expect them to honor this
@@ -117,7 +124,7 @@ backend_src = $(top_srcdir)/src/backend
 libpq-refs-stamp: $(shlib)
 ifneq ($(enable_coverage), yes)
 ifeq (,$(filter solaris,$(PORTNAME)))
-	@if nm -A -u $< 2>/dev/null | grep -v -e __cxa_atexit -e __tsan_func_exit | grep exit; then \
+	@if nm -A -u $< 2>/dev/null | grep -v -e __cxa_atexit -e __tsan_func_exit -e _atexit | grep exit; then \
 		echo 'libpq must not be calling any function which invokes exit'; exit 1; \
 	fi
 endif
diff --git a/src/interfaces/libpq/exports.txt b/src/interfaces/libpq/exports.txt
index 2ad2cbf5ca3..9b789cbec0b 100644
--- a/src/interfaces/libpq/exports.txt
+++ b/src/interfaces/libpq/exports.txt
@@ -206,3 +206,6 @@ PQsocketPoll              203
 PQsetChunkedRowsMode      204
 PQgetCurrentTimeUSec      205
 PQservice                 206
+PQsetAuthDataHook         207
+PQgetAuthDataHook         208
+PQdefaultAuthDataHook     209
diff --git a/src/interfaces/libpq/fe-auth-oauth-curl.c b/src/interfaces/libpq/fe-auth-oauth-curl.c
new file mode 100644
index 00000000000..96c5096e4ca
--- /dev/null
+++ b/src/interfaces/libpq/fe-auth-oauth-curl.c
@@ -0,0 +1,2850 @@
+/*-------------------------------------------------------------------------
+ *
+ * fe-auth-oauth-curl.c
+ *	   The libcurl implementation of OAuth/OIDC authentication, using the
+ *	   OAuth Device Authorization Grant (RFC 8628).
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/interfaces/libpq/fe-auth-oauth-curl.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres_fe.h"
+
+#include <curl/curl.h>
+#include <math.h>
+#ifdef HAVE_SYS_EPOLL_H
+#include <sys/epoll.h>
+#include <sys/timerfd.h>
+#endif
+#ifdef HAVE_SYS_EVENT_H
+#include <sys/event.h>
+#endif
+#include <unistd.h>
+
+#include "common/jsonapi.h"
+#include "fe-auth.h"
+#include "fe-auth-oauth.h"
+#include "libpq-int.h"
+#include "mb/pg_wchar.h"
+
+#define MAX_OAUTH_RESPONSE_SIZE (1024 * 1024)
+
+/*
+ * Parsed JSON Representations
+ *
+ * As a general rule, we parse and cache only the fields we're currently using.
+ * When adding new fields, ensure the corresponding free_*() function is updated
+ * too.
+ */
+
+/*
+ * The OpenID Provider configuration (alternatively named "authorization server
+ * metadata") jointly described by OpenID Connect Discovery 1.0 and RFC 8414:
+ *
+ *     https://openid.net/specs/openid-connect-discovery-1_0.html
+ *     https://www.rfc-editor.org/rfc/rfc8414#section-3.2
+ */
+struct provider
+{
+	char	   *issuer;
+	char	   *token_endpoint;
+	char	   *device_authorization_endpoint;
+	struct curl_slist *grant_types_supported;
+};
+
+static void
+free_provider(struct provider *provider)
+{
+	free(provider->issuer);
+	free(provider->token_endpoint);
+	free(provider->device_authorization_endpoint);
+	curl_slist_free_all(provider->grant_types_supported);
+}
+
+/*
+ * The Device Authorization response, described by RFC 8628:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc8628#section-3.2
+ */
+struct device_authz
+{
+	char	   *device_code;
+	char	   *user_code;
+	char	   *verification_uri;
+	char	   *verification_uri_complete;
+	char	   *expires_in_str;
+	char	   *interval_str;
+
+	/* Fields below are parsed from the corresponding string above. */
+	int			expires_in;
+	int			interval;
+};
+
+static void
+free_device_authz(struct device_authz *authz)
+{
+	free(authz->device_code);
+	free(authz->user_code);
+	free(authz->verification_uri);
+	free(authz->verification_uri_complete);
+	free(authz->expires_in_str);
+	free(authz->interval_str);
+}
+
+/*
+ * The Token Endpoint error response, as described by RFC 6749:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc6749#section-5.2
+ *
+ * Note that this response type can also be returned from the Device
+ * Authorization Endpoint.
+ */
+struct token_error
+{
+	char	   *error;
+	char	   *error_description;
+};
+
+static void
+free_token_error(struct token_error *err)
+{
+	free(err->error);
+	free(err->error_description);
+}
+
+/*
+ * The Access Token response, as described by RFC 6749:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc6749#section-4.1.4
+ *
+ * During the Device Authorization flow, several temporary errors are expected
+ * as part of normal operation. To make it easy to handle these in the happy
+ * path, this contains an embedded token_error that is filled in if needed.
+ */
+struct token
+{
+	/* for successful responses */
+	char	   *access_token;
+	char	   *token_type;
+
+	/* for error responses */
+	struct token_error err;
+};
+
+static void
+free_token(struct token *tok)
+{
+	free(tok->access_token);
+	free(tok->token_type);
+	free_token_error(&tok->err);
+}
+
+/*
+ * Asynchronous State
+ */
+
+/* States for the overall async machine. */
+enum OAuthStep
+{
+	OAUTH_STEP_INIT = 0,
+	OAUTH_STEP_DISCOVERY,
+	OAUTH_STEP_DEVICE_AUTHORIZATION,
+	OAUTH_STEP_TOKEN_REQUEST,
+	OAUTH_STEP_WAIT_INTERVAL,
+};
+
+/*
+ * The async_ctx holds onto state that needs to persist across multiple calls
+ * to pg_fe_run_oauth_flow(). Almost everything interacts with this in some
+ * way.
+ */
+struct async_ctx
+{
+	enum OAuthStep step;		/* where are we in the flow? */
+
+	int			timerfd;		/* descriptor for signaling async timeouts */
+	pgsocket	mux;			/* the multiplexer socket containing all
+								 * descriptors tracked by libcurl, plus the
+								 * timerfd */
+	CURLM	   *curlm;			/* top-level multi handle for libcurl
+								 * operations */
+	CURL	   *curl;			/* the (single) easy handle for serial
+								 * requests */
+
+	struct curl_slist *headers; /* common headers for all requests */
+	PQExpBufferData work_data;	/* scratch buffer for general use (remember to
+								 * clear out prior contents first!) */
+
+	/*------
+	 * Since a single logical operation may stretch across multiple calls to
+	 * our entry point, errors have three parts:
+	 *
+	 * - errctx:	an optional static string, describing the global operation
+	 *				currently in progress. It'll be translated for you.
+	 *
+	 * - errbuf:	contains the actual error message. Generally speaking, use
+	 *				actx_error[_str] to manipulate this. This must be filled
+	 *				with something useful on an error.
+	 *
+	 * - curl_err:	an optional static error buffer used by libcurl to put
+	 *				detailed information about failures. Unfortunately
+	 *				untranslatable.
+	 *
+	 * These pieces will be combined into a single error message looking
+	 * something like the following, with errctx and/or curl_err omitted when
+	 * absent:
+	 *
+	 *     connection to server ... failed: errctx: errbuf (curl_err)
+	 */
+	const char *errctx;			/* not freed; must point to static allocation */
+	PQExpBufferData errbuf;
+	char		curl_err[CURL_ERROR_SIZE];
+
+	/*
+	 * These documents need to survive over multiple calls, and are therefore
+	 * cached directly in the async_ctx.
+	 */
+	struct provider provider;
+	struct device_authz authz;
+
+	int			running;		/* is asynchronous work in progress? */
+	bool		user_prompted;	/* have we already sent the authz prompt? */
+	bool		used_basic_auth;	/* did we send a client secret? */
+	bool		debugging;		/* can we give unsafe developer assistance? */
+};
+
+/*
+ * Tears down the Curl handles and frees the async_ctx.
+ */
+static void
+free_async_ctx(PGconn *conn, struct async_ctx *actx)
+{
+	/*
+	 * TODO: in general, none of the error cases below should ever happen if
+	 * we have no bugs above. But if we do hit them, surfacing those errors
+	 * somehow might be the only way to have a chance to debug them. What's
+	 * the best way to do that? Assertions? Spraying messages on stderr?
+	 * Bubbling an error code to the top? Appending to the connection's error
+	 * message only helps if the bug caused a connection failure; otherwise
+	 * it'll be buried...
+	 */
+
+	if (actx->curlm && actx->curl)
+	{
+		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));
+	}
+
+	if (actx->curl)
+	{
+		/*
+		 * curl_multi_cleanup() doesn't free any associated easy handles; we
+		 * need to do that separately. We only ever have one easy handle per
+		 * multi handle.
+		 */
+		curl_easy_cleanup(actx->curl);
+	}
+
+	if (actx->curlm)
+	{
+		CURLMcode	err = curl_multi_cleanup(actx->curlm);
+
+		if (err)
+			libpq_append_conn_error(conn,
+									"libcurl multi handle cleanup failed: %s",
+									curl_multi_strerror(err));
+	}
+
+	free_provider(&actx->provider);
+	free_device_authz(&actx->authz);
+
+	curl_slist_free_all(actx->headers);
+	termPQExpBuffer(&actx->work_data);
+	termPQExpBuffer(&actx->errbuf);
+
+	if (actx->mux != PGINVALID_SOCKET)
+		close(actx->mux);
+	if (actx->timerfd >= 0)
+		close(actx->timerfd);
+
+	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.
+ */
+void
+pg_fe_cleanup_oauth_flow(PGconn *conn)
+{
+	fe_oauth_state *state = conn->sasl_state;
+
+	if (state->async_ctx)
+	{
+		free_async_ctx(conn, state->async_ctx);
+		state->async_ctx = NULL;
+	}
+
+	conn->altsock = PGINVALID_SOCKET;
+}
+
+/*
+ * Macros for manipulating actx->errbuf. actx_error() translates and formats a
+ * string for you; actx_error_str() appends a string directly without
+ * translation.
+ */
+
+#define actx_error(ACTX, FMT, ...) \
+	appendPQExpBuffer(&(ACTX)->errbuf, libpq_gettext(FMT), ##__VA_ARGS__)
+
+#define actx_error_str(ACTX, S) \
+	appendPQExpBufferStr(&(ACTX)->errbuf, S)
+
+/*
+ * Macros for getting and setting state for the connection's two libcurl
+ * handles, so you don't have to write out the error handling every time.
+ */
+
+#define CHECK_MSETOPT(ACTX, OPT, VAL, FAILACTION) \
+	do { \
+		struct async_ctx *_actx = (ACTX); \
+		CURLMcode	_setopterr = curl_multi_setopt(_actx->curlm, OPT, VAL); \
+		if (_setopterr) { \
+			actx_error(_actx, "failed to set %s on OAuth connection: %s",\
+					   #OPT, curl_multi_strerror(_setopterr)); \
+			FAILACTION; \
+		} \
+	} while (0)
+
+#define CHECK_SETOPT(ACTX, OPT, VAL, FAILACTION) \
+	do { \
+		struct async_ctx *_actx = (ACTX); \
+		CURLcode	_setopterr = curl_easy_setopt(_actx->curl, OPT, VAL); \
+		if (_setopterr) { \
+			actx_error(_actx, "failed to set %s on OAuth connection: %s",\
+					   #OPT, curl_easy_strerror(_setopterr)); \
+			FAILACTION; \
+		} \
+	} while (0)
+
+#define CHECK_GETINFO(ACTX, INFO, OUT, FAILACTION) \
+	do { \
+		struct async_ctx *_actx = (ACTX); \
+		CURLcode	_getinfoerr = curl_easy_getinfo(_actx->curl, INFO, OUT); \
+		if (_getinfoerr) { \
+			actx_error(_actx, "failed to get %s from OAuth response: %s",\
+					   #INFO, curl_easy_strerror(_getinfoerr)); \
+			FAILACTION; \
+		} \
+	} while (0)
+
+/*
+ * General JSON Parsing for OAuth Responses
+ */
+
+/*
+ * Represents a single name/value pair in a JSON object. This is the primary
+ * interface to parse_oauth_json().
+ *
+ * All fields are stored internally as strings or lists of strings, so clients
+ * have to explicitly parse other scalar types (though they will have gone
+ * through basic lexical validation). Storing nested objects is not currently
+ * supported, nor is parsing arrays of anything other than strings.
+ */
+struct json_field
+{
+	const char *name;			/* name (key) of the member */
+
+	JsonTokenType type;			/* currently supports JSON_TOKEN_STRING,
+								 * JSON_TOKEN_NUMBER, and
+								 * JSON_TOKEN_ARRAY_START */
+	union
+	{
+		char	  **scalar;		/* for all scalar types */
+		struct curl_slist **array;	/* for type == JSON_TOKEN_ARRAY_START */
+	}			target;
+
+	bool		required;		/* REQUIRED field, or just OPTIONAL? */
+};
+
+/* Documentation macros for json_field.required. */
+#define REQUIRED true
+#define OPTIONAL false
+
+/* Parse state for parse_oauth_json(). */
+struct oauth_parse
+{
+	PQExpBuffer errbuf;			/* detail message for JSON_SEM_ACTION_FAILED */
+	int			nested;			/* nesting level (zero is the top) */
+
+	const struct json_field *fields;	/* field definition array */
+	const struct json_field *active;	/* points inside the fields array */
+};
+
+#define oauth_parse_set_error(ctx, fmt, ...) \
+	appendPQExpBuffer((ctx)->errbuf, libpq_gettext(fmt), ##__VA_ARGS__)
+
+static void
+report_type_mismatch(struct oauth_parse *ctx)
+{
+	char	   *msgfmt;
+
+	Assert(ctx->active);
+
+	/*
+	 * At the moment, the only fields we're interested in are strings,
+	 * numbers, and arrays of strings.
+	 */
+	switch (ctx->active->type)
+	{
+		case JSON_TOKEN_STRING:
+			msgfmt = "field \"%s\" must be a string";
+			break;
+
+		case JSON_TOKEN_NUMBER:
+			msgfmt = "field \"%s\" must be a number";
+			break;
+
+		case JSON_TOKEN_ARRAY_START:
+			msgfmt = "field \"%s\" must be an array of strings";
+			break;
+
+		default:
+			Assert(false);
+			msgfmt = "field \"%s\" has unexpected type";
+	}
+
+	oauth_parse_set_error(ctx, msgfmt, ctx->active->name);
+}
+
+static JsonParseErrorType
+oauth_json_object_start(void *state)
+{
+	struct oauth_parse *ctx = state;
+
+	if (ctx->active)
+	{
+		/*
+		 * Currently, none of the fields we're interested in can be or contain
+		 * objects, so we can reject this case outright.
+		 */
+		report_type_mismatch(ctx);
+		return JSON_SEM_ACTION_FAILED;
+	}
+
+	++ctx->nested;
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_object_field_start(void *state, char *name, bool isnull)
+{
+	struct oauth_parse *ctx = state;
+
+	/* We care only about the top-level fields. */
+	if (ctx->nested == 1)
+	{
+		const struct json_field *field = ctx->fields;
+
+		/*
+		 * We should never start parsing a new field while a previous one is
+		 * still active.
+		 */
+		if (ctx->active)
+		{
+			Assert(false);
+			oauth_parse_set_error(ctx,
+								  "internal error: started field '%s' before field '%s' was finished",
+								  name, ctx->active->name);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		while (field->name)
+		{
+			if (strcmp(name, field->name) == 0)
+			{
+				ctx->active = field;
+				break;
+			}
+
+			++field;
+		}
+
+		/*
+		 * We don't allow duplicate field names; error out if the target has
+		 * already been set.
+		 */
+		if (ctx->active)
+		{
+			field = ctx->active;
+
+			if ((field->type == JSON_TOKEN_ARRAY_START && *field->target.array)
+				|| (field->type != JSON_TOKEN_ARRAY_START && *field->target.scalar))
+			{
+				oauth_parse_set_error(ctx, "field \"%s\" is duplicated",
+									  field->name);
+				return JSON_SEM_ACTION_FAILED;
+			}
+		}
+	}
+
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_object_end(void *state)
+{
+	struct oauth_parse *ctx = state;
+
+	--ctx->nested;
+
+	/*
+	 * All fields should be fully processed by the end of the top-level
+	 * object.
+	 */
+	if (!ctx->nested && ctx->active)
+	{
+		Assert(false);
+		oauth_parse_set_error(ctx,
+							  "internal error: field '%s' still active at end of object",
+							  ctx->active->name);
+		return JSON_SEM_ACTION_FAILED;
+	}
+
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_array_start(void *state)
+{
+	struct oauth_parse *ctx = state;
+
+	if (!ctx->nested)
+	{
+		oauth_parse_set_error(ctx, "top-level element must be an object");
+		return JSON_SEM_ACTION_FAILED;
+	}
+
+	if (ctx->active)
+	{
+		if (ctx->active->type != JSON_TOKEN_ARRAY_START
+		/* The arrays we care about must not have arrays as values. */
+			|| ctx->nested > 1)
+		{
+			report_type_mismatch(ctx);
+			return JSON_SEM_ACTION_FAILED;
+		}
+	}
+
+	++ctx->nested;
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_array_end(void *state)
+{
+	struct oauth_parse *ctx = state;
+
+	if (ctx->active)
+	{
+		/*
+		 * Clear the target (which should be an array inside the top-level
+		 * object). For this to be safe, no target arrays can contain other
+		 * arrays; we check for that in the array_start callback.
+		 */
+		if (ctx->nested != 2 || ctx->active->type != JSON_TOKEN_ARRAY_START)
+		{
+			Assert(false);
+			oauth_parse_set_error(ctx,
+								  "internal error: found unexpected array end while parsing field '%s'",
+								  ctx->active->name);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		ctx->active = NULL;
+	}
+
+	--ctx->nested;
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_scalar(void *state, char *token, JsonTokenType type)
+{
+	struct oauth_parse *ctx = state;
+
+	if (!ctx->nested)
+	{
+		oauth_parse_set_error(ctx, "top-level element must be an object");
+		return JSON_SEM_ACTION_FAILED;
+	}
+
+	if (ctx->active)
+	{
+		const struct json_field *field = ctx->active;
+		JsonTokenType expected = field->type;
+
+		/* Make sure this matches what the active field expects. */
+		if (expected == JSON_TOKEN_ARRAY_START)
+		{
+			/* Are we actually inside an array? */
+			if (ctx->nested < 2)
+			{
+				report_type_mismatch(ctx);
+				return JSON_SEM_ACTION_FAILED;
+			}
+
+			/* Currently, arrays can only contain strings. */
+			expected = JSON_TOKEN_STRING;
+		}
+
+		if (type != expected)
+		{
+			report_type_mismatch(ctx);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		if (field->type != JSON_TOKEN_ARRAY_START)
+		{
+			/* Ensure that we're parsing the top-level keys... */
+			if (ctx->nested != 1)
+			{
+				Assert(false);
+				oauth_parse_set_error(ctx,
+									  "internal error: scalar target found at nesting level %d",
+									  ctx->nested);
+				return JSON_SEM_ACTION_FAILED;
+			}
+
+			/* ...and that a result has not already been set. */
+			if (*field->target.scalar)
+			{
+				Assert(false);
+				oauth_parse_set_error(ctx,
+									  "internal error: scalar field '%s' would be assigned twice",
+									  ctx->active->name);
+				return JSON_SEM_ACTION_FAILED;
+			}
+
+			*field->target.scalar = strdup(token);
+			if (!*field->target.scalar)
+				return JSON_OUT_OF_MEMORY;
+
+			ctx->active = NULL;
+
+			return JSON_SUCCESS;
+		}
+		else
+		{
+			struct curl_slist *temp;
+
+			/* The target array should be inside the top-level object. */
+			if (ctx->nested != 2)
+			{
+				Assert(false);
+				oauth_parse_set_error(ctx,
+									  "internal error: array member found at nesting level %d",
+									  ctx->nested);
+				return JSON_SEM_ACTION_FAILED;
+			}
+
+			/* Note that curl_slist_append() makes a copy of the token. */
+			temp = curl_slist_append(*field->target.array, token);
+			if (!temp)
+				return JSON_OUT_OF_MEMORY;
+
+			*field->target.array = temp;
+		}
+	}
+	else
+	{
+		/* otherwise we just ignore it */
+	}
+
+	return JSON_SUCCESS;
+}
+
+/*
+ * Checks the Content-Type header against the expected type. Parameters are
+ * allowed but ignored.
+ */
+static bool
+check_content_type(struct async_ctx *actx, const char *type)
+{
+	const size_t type_len = strlen(type);
+	char	   *content_type;
+
+	CHECK_GETINFO(actx, CURLINFO_CONTENT_TYPE, &content_type, return false);
+
+	if (!content_type)
+	{
+		actx_error(actx, "no content type was provided");
+		return false;
+	}
+
+	/*
+	 * We need to perform a length limited comparison and not compare the
+	 * whole string.
+	 */
+	if (pg_strncasecmp(content_type, type, type_len) != 0)
+		goto fail;
+
+	/* On an exact match, we're done. */
+	Assert(strlen(content_type) >= type_len);
+	if (content_type[type_len] == '\0')
+		return true;
+
+	/*
+	 * Only a semicolon (optionally preceded by HTTP optional whitespace) is
+	 * acceptable after the prefix we checked. This marks the start of media
+	 * type parameters, which we currently have no use for.
+	 */
+	for (size_t i = type_len; content_type[i]; ++i)
+	{
+		switch (content_type[i])
+		{
+			case ';':
+				return true;	/* success! */
+
+			case ' ':
+			case '\t':
+				/* HTTP optional whitespace allows only spaces and htabs. */
+				break;
+
+			default:
+				goto fail;
+		}
+	}
+
+fail:
+	actx_error(actx, "unexpected content type: \"%s\"", content_type);
+	return false;
+}
+
+/*
+ * A helper function for general JSON parsing. fields is the array of field
+ * definitions with their backing pointers. The response will be parsed from
+ * actx->curl and actx->work_data (as set up by start_request()), and any
+ * parsing errors will be placed into actx->errbuf.
+ */
+static bool
+parse_oauth_json(struct async_ctx *actx, const struct json_field *fields)
+{
+	PQExpBuffer resp = &actx->work_data;
+	JsonLexContext lex = {0};
+	JsonSemAction sem = {0};
+	JsonParseErrorType err;
+	struct oauth_parse ctx = {0};
+	bool		success = false;
+
+	if (!check_content_type(actx, "application/json"))
+		return false;
+
+	if (strlen(resp->data) != resp->len)
+	{
+		actx_error(actx, "response contains embedded NULLs");
+		return false;
+	}
+
+	/*
+	 * pg_parse_json doesn't validate the incoming UTF-8, so we have to check
+	 * that up front.
+	 */
+	if (pg_encoding_verifymbstr(PG_UTF8, resp->data, resp->len) != resp->len)
+	{
+		actx_error(actx, "response is not valid UTF-8");
+		return false;
+	}
+
+	makeJsonLexContextCstringLen(&lex, resp->data, resp->len, PG_UTF8, true);
+	setJsonLexContextOwnsTokens(&lex, true);	/* must not leak on error */
+
+	ctx.errbuf = &actx->errbuf;
+	ctx.fields = fields;
+	sem.semstate = &ctx;
+
+	sem.object_start = oauth_json_object_start;
+	sem.object_field_start = oauth_json_object_field_start;
+	sem.object_end = oauth_json_object_end;
+	sem.array_start = oauth_json_array_start;
+	sem.array_end = oauth_json_array_end;
+	sem.scalar = oauth_json_scalar;
+
+	err = pg_parse_json(&lex, &sem);
+
+	if (err != JSON_SUCCESS)
+	{
+		/*
+		 * For JSON_SEM_ACTION_FAILED, we've already written the error
+		 * message. Other errors come directly from pg_parse_json(), already
+		 * translated.
+		 */
+		if (err != JSON_SEM_ACTION_FAILED)
+			actx_error_str(actx, json_errdetail(err, &lex));
+
+		goto cleanup;
+	}
+
+	/* Check all required fields. */
+	while (fields->name)
+	{
+		if (fields->required
+			&& !*fields->target.scalar
+			&& !*fields->target.array)
+		{
+			actx_error(actx, "field \"%s\" is missing", fields->name);
+			goto cleanup;
+		}
+
+		fields++;
+	}
+
+	success = true;
+
+cleanup:
+	freeJsonLexContext(&lex);
+	return success;
+}
+
+/*
+ * JSON Parser Definitions
+ */
+
+/*
+ * Parses authorization server metadata. Fields are defined by OIDC Discovery
+ * 1.0 and RFC 8414.
+ */
+static bool
+parse_provider(struct async_ctx *actx, struct provider *provider)
+{
+	struct json_field fields[] = {
+		{"issuer", JSON_TOKEN_STRING, {&provider->issuer}, REQUIRED},
+		{"token_endpoint", JSON_TOKEN_STRING, {&provider->token_endpoint}, REQUIRED},
+
+		/*----
+		 * The following fields are technically REQUIRED, but we don't use
+		 * them anywhere yet:
+		 *
+		 * - jwks_uri
+		 * - response_types_supported
+		 * - subject_types_supported
+		 * - id_token_signing_alg_values_supported
+		 */
+
+		{"device_authorization_endpoint", JSON_TOKEN_STRING, {&provider->device_authorization_endpoint}, OPTIONAL},
+		{"grant_types_supported", JSON_TOKEN_ARRAY_START, {.array = &provider->grant_types_supported}, OPTIONAL},
+
+		{0},
+	};
+
+	return parse_oauth_json(actx, fields);
+}
+
+/*
+ * Parses a valid JSON number into a double. The input must have come from
+ * pg_parse_json(), so that we know the lexer has validated it; there's no
+ * in-band signal for invalid formats.
+ */
+static double
+parse_json_number(const char *s)
+{
+	double		parsed;
+	int			cnt;
+
+	/*
+	 * The JSON lexer has already validated the number, which is stricter than
+	 * the %f format, so we should be good to use sscanf().
+	 */
+	cnt = sscanf(s, "%lf", &parsed);
+
+	if (cnt != 1)
+	{
+		/*
+		 * Either the lexer screwed up or our assumption above isn't true, and
+		 * either way a developer needs to take a look.
+		 */
+		Assert(cnt == 1);
+		return 0;
+	}
+
+	return parsed;
+}
+
+/*
+ * Parses the "interval" JSON number, corresponding to the number of seconds to
+ * wait between token endpoint requests.
+ *
+ * RFC 8628 is pretty silent on sanity checks for the interval. As a matter of
+ * practicality, round any fractional intervals up to the next second, and clamp
+ * the result at a minimum of one. (Zero-second intervals would result in an
+ * expensive network polling loop.) Tests may remove the lower bound with
+ * PGOAUTHDEBUG, for improved performance.
+ */
+static int
+parse_interval(struct async_ctx *actx, const char *interval_str)
+{
+	double		parsed;
+
+	parsed = parse_json_number(interval_str);
+	parsed = ceil(parsed);
+
+	if (parsed < 1)
+		return actx->debugging ? 0 : 1;
+
+	else if (INT_MAX <= parsed)
+		return INT_MAX;
+
+	return parsed;
+}
+
+/*
+ * Parses the "expires_in" JSON number, corresponding to the number of seconds
+ * remaining in the lifetime of the device code request.
+ *
+ * Similar to parse_interval, but we have even fewer requirements for reasonable
+ * values since we don't use the expiration time directly (it's passed to the
+ * PQAUTHDATA_PROMPT_OAUTH_DEVICE hook, in case the application wants to do
+ * something with it). We simply round and clamp to int range.
+ */
+static int
+parse_expires_in(struct async_ctx *actx, const char *expires_in_str)
+{
+	double		parsed;
+
+	parsed = parse_json_number(expires_in_str);
+	parsed = round(parsed);
+
+	if (INT_MAX <= parsed)
+		return INT_MAX;
+	else if (parsed <= INT_MIN)
+		return INT_MIN;
+
+	return parsed;
+}
+
+/*
+ * Parses the Device Authorization Response (RFC 8628, Sec. 3.2).
+ */
+static bool
+parse_device_authz(struct async_ctx *actx, struct device_authz *authz)
+{
+	struct json_field fields[] = {
+		{"device_code", JSON_TOKEN_STRING, {&authz->device_code}, REQUIRED},
+		{"user_code", JSON_TOKEN_STRING, {&authz->user_code}, REQUIRED},
+		{"verification_uri", JSON_TOKEN_STRING, {&authz->verification_uri}, REQUIRED},
+		{"expires_in", JSON_TOKEN_NUMBER, {&authz->expires_in_str}, REQUIRED},
+
+		/*
+		 * Some services (Google, Azure) spell verification_uri differently.
+		 * We accept either.
+		 */
+		{"verification_url", JSON_TOKEN_STRING, {&authz->verification_uri}, REQUIRED},
+
+		{"verification_uri_complete", JSON_TOKEN_STRING, {&authz->verification_uri_complete}, OPTIONAL},
+		{"interval", JSON_TOKEN_NUMBER, {&authz->interval_str}, OPTIONAL},
+
+		{0},
+	};
+
+	if (!parse_oauth_json(actx, fields))
+		return false;
+
+	/*
+	 * Parse our numeric fields. Lexing has already completed by this time, so
+	 * we at least know they're valid JSON numbers.
+	 */
+	if (authz->interval_str)
+		authz->interval = parse_interval(actx, authz->interval_str);
+	else
+	{
+		/*
+		 * RFC 8628 specifies 5 seconds as the default value if the server
+		 * doesn't provide an interval.
+		 */
+		authz->interval = 5;
+	}
+
+	Assert(authz->expires_in_str);	/* ensured by parse_oauth_json() */
+	authz->expires_in = parse_expires_in(actx, authz->expires_in_str);
+
+	return true;
+}
+
+/*
+ * Parses the device access token error response (RFC 8628, Sec. 3.5, which
+ * uses the error response defined in RFC 6749, Sec. 5.2).
+ */
+static bool
+parse_token_error(struct async_ctx *actx, struct token_error *err)
+{
+	bool		result;
+	struct json_field fields[] = {
+		{"error", JSON_TOKEN_STRING, {&err->error}, REQUIRED},
+
+		{"error_description", JSON_TOKEN_STRING, {&err->error_description}, OPTIONAL},
+
+		{0},
+	};
+
+	result = parse_oauth_json(actx, fields);
+
+	/*
+	 * Since token errors are parsed during other active error paths, only
+	 * override the errctx if parsing explicitly fails.
+	 */
+	if (!result)
+		actx->errctx = "failed to parse token error response";
+
+	return result;
+}
+
+/*
+ * Constructs a message from the token error response and puts it into
+ * actx->errbuf.
+ */
+static void
+record_token_error(struct async_ctx *actx, const struct token_error *err)
+{
+	if (err->error_description)
+		appendPQExpBuffer(&actx->errbuf, "%s ", err->error_description);
+	else
+	{
+		/*
+		 * Try to get some more helpful detail into the error string. A 401
+		 * status in particular implies that the oauth_client_secret is
+		 * missing or wrong.
+		 */
+		long		response_code;
+
+		CHECK_GETINFO(actx, CURLINFO_RESPONSE_CODE, &response_code, response_code = 0);
+
+		if (response_code == 401)
+		{
+			actx_error(actx, actx->used_basic_auth
+					   ? "provider rejected the oauth_client_secret"
+					   : "provider requires client authentication, and no oauth_client_secret is set");
+			actx_error_str(actx, " ");
+		}
+	}
+
+	appendPQExpBuffer(&actx->errbuf, "(%s)", err->error);
+}
+
+/*
+ * Parses the device access token response (RFC 8628, Sec. 3.5, which uses the
+ * success response defined in RFC 6749, Sec. 5.1).
+ */
+static bool
+parse_access_token(struct async_ctx *actx, struct token *tok)
+{
+	struct json_field fields[] = {
+		{"access_token", JSON_TOKEN_STRING, {&tok->access_token}, REQUIRED},
+		{"token_type", JSON_TOKEN_STRING, {&tok->token_type}, REQUIRED},
+
+		/*---
+		 * We currently have no use for the following OPTIONAL fields:
+		 *
+		 * - expires_in: This will be important for maintaining a token cache,
+		 *               but we do not yet implement one.
+		 *
+		 * - refresh_token: Ditto.
+		 *
+		 * - scope: This is only sent when the authorization server sees fit to
+		 *          change our scope request. It's not clear what we should do
+		 *          about this; either it's been done as a matter of policy, or
+		 *          the user has explicitly denied part of the authorization,
+		 *          and either way the server-side validator is in a better
+		 *          place to complain if the change isn't acceptable.
+		 */
+
+		{0},
+	};
+
+	return parse_oauth_json(actx, fields);
+}
+
+/*
+ * libcurl Multi Setup/Callbacks
+ */
+
+/*
+ * Sets up the actx->mux, which is the altsock that PQconnectPoll clients will
+ * select() on instead of the Postgres socket during OAuth negotiation.
+ *
+ * This is just an epoll set or kqueue abstracting multiple other descriptors.
+ * For epoll, the timerfd is always part of the set; it's just disabled when
+ * we're not using it. For kqueue, the "timerfd" is actually a second kqueue
+ * instance which is only added to the set when needed.
+ */
+static bool
+setup_multiplexer(struct async_ctx *actx)
+{
+#ifdef HAVE_SYS_EPOLL_H
+	struct epoll_event ev = {.events = EPOLLIN};
+
+	actx->mux = epoll_create1(EPOLL_CLOEXEC);
+	if (actx->mux < 0)
+	{
+		actx_error(actx, "failed to create epoll set: %m");
+		return false;
+	}
+
+	actx->timerfd = timerfd_create(CLOCK_MONOTONIC, TFD_CLOEXEC);
+	if (actx->timerfd < 0)
+	{
+		actx_error(actx, "failed to create timerfd: %m");
+		return false;
+	}
+
+	if (epoll_ctl(actx->mux, EPOLL_CTL_ADD, actx->timerfd, &ev) < 0)
+	{
+		actx_error(actx, "failed to add timerfd to epoll set: %m");
+		return false;
+	}
+
+	return true;
+#endif
+#ifdef HAVE_SYS_EVENT_H
+	actx->mux = kqueue();
+	if (actx->mux < 0)
+	{
+		actx_error(actx, "failed to create kqueue: %m");
+		return false;
+	}
+
+	/*
+	 * Originally, we set EVFILT_TIMER directly on the top-level multiplexer.
+	 * This makes it difficult to implement timer_expired(), though, so now we
+	 * set EVFILT_TIMER on a separate actx->timerfd, which is chained to
+	 * actx->mux while the timer is active.
+	 */
+	actx->timerfd = kqueue();
+	if (actx->timerfd < 0)
+	{
+		actx_error(actx, "failed to create timer kqueue: %m");
+		return false;
+	}
+
+	return true;
+#endif
+
+	actx_error(actx, "libpq does not support the Device Authorization flow on this platform");
+	return false;
+}
+
+/*
+ * Adds and removes sockets from the multiplexer set, as directed by the
+ * libcurl multi handle.
+ */
+static int
+register_socket(CURL *curl, curl_socket_t socket, int what, void *ctx,
+				void *socketp)
+{
+#ifdef HAVE_SYS_EPOLL_H
+	struct async_ctx *actx = ctx;
+	struct epoll_event ev = {0};
+	int			res;
+	int			op = EPOLL_CTL_ADD;
+
+	switch (what)
+	{
+		case CURL_POLL_IN:
+			ev.events = EPOLLIN;
+			break;
+
+		case CURL_POLL_OUT:
+			ev.events = EPOLLOUT;
+			break;
+
+		case CURL_POLL_INOUT:
+			ev.events = EPOLLIN | EPOLLOUT;
+			break;
+
+		case CURL_POLL_REMOVE:
+			op = EPOLL_CTL_DEL;
+			break;
+
+		default:
+			actx_error(actx, "unknown libcurl socket operation: %d", what);
+			return -1;
+	}
+
+	res = epoll_ctl(actx->mux, op, socket, &ev);
+	if (res < 0 && errno == EEXIST)
+	{
+		/* We already had this socket in the pollset. */
+		op = EPOLL_CTL_MOD;
+		res = epoll_ctl(actx->mux, op, socket, &ev);
+	}
+
+	if (res < 0)
+	{
+		switch (op)
+		{
+			case EPOLL_CTL_ADD:
+				actx_error(actx, "could not add to epoll set: %m");
+				break;
+
+			case EPOLL_CTL_DEL:
+				actx_error(actx, "could not delete from epoll set: %m");
+				break;
+
+			default:
+				actx_error(actx, "could not update epoll set: %m");
+		}
+
+		return -1;
+	}
+#endif
+#ifdef HAVE_SYS_EVENT_H
+	struct async_ctx *actx = ctx;
+	struct kevent ev[2] = {{0}};
+	struct kevent ev_out[2];
+	struct timespec timeout = {0};
+	int			nev = 0;
+	int			res;
+
+	switch (what)
+	{
+		case CURL_POLL_IN:
+			EV_SET(&ev[nev], socket, EVFILT_READ, EV_ADD | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			break;
+
+		case CURL_POLL_OUT:
+			EV_SET(&ev[nev], socket, EVFILT_WRITE, EV_ADD | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			break;
+
+		case CURL_POLL_INOUT:
+			EV_SET(&ev[nev], socket, EVFILT_READ, EV_ADD | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			EV_SET(&ev[nev], socket, EVFILT_WRITE, EV_ADD | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			break;
+
+		case CURL_POLL_REMOVE:
+
+			/*
+			 * We don't know which of these is currently registered, perhaps
+			 * both, so we try to remove both.  This means we need to tolerate
+			 * ENOENT below.
+			 */
+			EV_SET(&ev[nev], socket, EVFILT_READ, EV_DELETE | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			EV_SET(&ev[nev], socket, EVFILT_WRITE, EV_DELETE | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			break;
+
+		default:
+			actx_error(actx, "unknown libcurl socket operation: %d", what);
+			return -1;
+	}
+
+	res = kevent(actx->mux, ev, nev, ev_out, lengthof(ev_out), &timeout);
+	if (res < 0)
+	{
+		actx_error(actx, "could not modify kqueue: %m");
+		return -1;
+	}
+
+	/*
+	 * We can't use the simple errno version of kevent, because we need to
+	 * skip over ENOENT while still allowing a second change to be processed.
+	 * So we need a longer-form error checking loop.
+	 */
+	for (int i = 0; i < res; ++i)
+	{
+		/*
+		 * EV_RECEIPT should guarantee one EV_ERROR result for every change,
+		 * whether successful or not. Failed entries contain a non-zero errno
+		 * in the data field.
+		 */
+		Assert(ev_out[i].flags & EV_ERROR);
+
+		errno = ev_out[i].data;
+		if (errno && errno != ENOENT)
+		{
+			switch (what)
+			{
+				case CURL_POLL_REMOVE:
+					actx_error(actx, "could not delete from kqueue: %m");
+					break;
+				default:
+					actx_error(actx, "could not add to kqueue: %m");
+			}
+			return -1;
+		}
+	}
+#endif
+
+	return 0;
+}
+
+/*
+ * Enables or disables the timer in the multiplexer set. The timeout value is
+ * in milliseconds (negative values disable the timer).
+ *
+ * For epoll, rather than continually adding and removing the timer, we keep it
+ * in the set at all times and just disarm it when it's not needed. For kqueue,
+ * the timer is removed completely when disabled to prevent stale timeouts from
+ * remaining in the queue.
+ */
+static bool
+set_timer(struct async_ctx *actx, long timeout)
+{
+#if HAVE_SYS_EPOLL_H
+	struct itimerspec spec = {0};
+
+	if (timeout < 0)
+	{
+		/* the zero itimerspec will disarm the timer below */
+	}
+	else if (timeout == 0)
+	{
+		/*
+		 * A zero timeout means libcurl wants us to call back immediately.
+		 * That's not technically an option for timerfd, but we can make the
+		 * timeout ridiculously short.
+		 */
+		spec.it_value.tv_nsec = 1;
+	}
+	else
+	{
+		spec.it_value.tv_sec = timeout / 1000;
+		spec.it_value.tv_nsec = (timeout % 1000) * 1000000;
+	}
+
+	if (timerfd_settime(actx->timerfd, 0 /* no flags */ , &spec, NULL) < 0)
+	{
+		actx_error(actx, "setting timerfd to %ld: %m", timeout);
+		return false;
+	}
+
+	return true;
+#endif
+#ifdef HAVE_SYS_EVENT_H
+	struct kevent ev;
+
+	/* Enable/disable the timer itself. */
+	EV_SET(&ev, 1, EVFILT_TIMER, timeout < 0 ? EV_DELETE : (EV_ADD | EV_ONESHOT),
+		   0, timeout, 0);
+	if (kevent(actx->timerfd, &ev, 1, NULL, 0, NULL) < 0 && errno != ENOENT)
+	{
+		actx_error(actx, "setting kqueue timer to %ld: %m", timeout);
+		return false;
+	}
+
+	/*
+	 * Add/remove the timer to/from the mux. (In contrast with epoll, if we
+	 * allowed the timer to remain registered here after being disabled, the
+	 * mux queue would retain any previous stale timeout notifications and
+	 * remain readable.)
+	 */
+	EV_SET(&ev, actx->timerfd, EVFILT_READ, timeout < 0 ? EV_DELETE : EV_ADD,
+		   0, 0, 0);
+	if (kevent(actx->mux, &ev, 1, NULL, 0, NULL) < 0 && errno != ENOENT)
+	{
+		actx_error(actx, "could not update timer on kqueue: %m");
+		return false;
+	}
+
+	return true;
+#endif
+
+	actx_error(actx, "libpq does not support timers on this platform");
+	return false;
+}
+
+/*
+ * Returns 1 if the timeout in the multiplexer set has expired since the last
+ * call to set_timer(), 0 if the timer is still running, or -1 (with an
+ * actx_error() report) if the timer cannot be queried.
+ */
+static int
+timer_expired(struct async_ctx *actx)
+{
+#if HAVE_SYS_EPOLL_H
+	struct itimerspec spec = {0};
+
+	if (timerfd_gettime(actx->timerfd, &spec) < 0)
+	{
+		actx_error(actx, "getting timerfd value: %m");
+		return -1;
+	}
+
+	/*
+	 * This implementation assumes we're using single-shot timers. If you
+	 * change to using intervals, you'll need to reimplement this function
+	 * too, possibly with the read() or select() interfaces for timerfd.
+	 */
+	Assert(spec.it_interval.tv_sec == 0
+		   && spec.it_interval.tv_nsec == 0);
+
+	/* If the remaining time to expiration is zero, we're done. */
+	return (spec.it_value.tv_sec == 0
+			&& spec.it_value.tv_nsec == 0);
+#endif
+#ifdef HAVE_SYS_EVENT_H
+	int			res;
+
+	/* Is the timer queue ready? */
+	res = PQsocketPoll(actx->timerfd, 1 /* forRead */ , 0, 0);
+	if (res < 0)
+	{
+		actx_error(actx, "checking kqueue for timeout: %m");
+		return -1;
+	}
+
+	return (res > 0);
+#endif
+
+	actx_error(actx, "libpq does not support timers on this platform");
+	return -1;
+}
+
+/*
+ * Adds or removes timeouts from the multiplexer set, as directed by the
+ * libcurl multi handle.
+ */
+static int
+register_timer(CURLM *curlm, long timeout, void *ctx)
+{
+	struct async_ctx *actx = ctx;
+
+	/*
+	 * There might be an optimization opportunity here: if timeout == 0, we
+	 * could signal drive_request to immediately call
+	 * curl_multi_socket_action, rather than returning all the way up the
+	 * stack only to come right back. But it's not clear that the additional
+	 * code complexity is worth it.
+	 */
+	if (!set_timer(actx, timeout))
+		return -1;				/* actx_error already called */
+
+	return 0;
+}
+
+/*
+ * Prints Curl request debugging information to stderr.
+ *
+ * Note that this will expose a number of critical secrets, so users have to opt
+ * into this (see PGOAUTHDEBUG).
+ */
+static int
+debug_callback(CURL *handle, curl_infotype type, char *data, size_t size,
+			   void *clientp)
+{
+	const char *prefix;
+	bool		printed_prefix = false;
+	PQExpBufferData buf;
+
+	/* Prefixes are modeled off of the default libcurl debug output. */
+	switch (type)
+	{
+		case CURLINFO_TEXT:
+			prefix = "*";
+			break;
+
+		case CURLINFO_HEADER_IN:	/* fall through */
+		case CURLINFO_DATA_IN:
+			prefix = "<";
+			break;
+
+		case CURLINFO_HEADER_OUT:	/* fall through */
+		case CURLINFO_DATA_OUT:
+			prefix = ">";
+			break;
+
+		default:
+			return 0;
+	}
+
+	initPQExpBuffer(&buf);
+
+	/*
+	 * Split the output into lines for readability; sometimes multiple headers
+	 * are included in a single call. We also don't allow unprintable ASCII
+	 * through without a basic <XX> escape.
+	 */
+	for (int i = 0; i < size; i++)
+	{
+		char		c = data[i];
+
+		if (!printed_prefix)
+		{
+			appendPQExpBuffer(&buf, "%s ", prefix);
+			printed_prefix = true;
+		}
+
+		if (c >= 0x20 && c <= 0x7E)
+			appendPQExpBufferChar(&buf, c);
+		else if ((type == CURLINFO_HEADER_IN
+				  || type == CURLINFO_HEADER_OUT
+				  || type == CURLINFO_TEXT)
+				 && (c == '\r' || c == '\n'))
+		{
+			/*
+			 * Don't bother emitting <0D><0A> for headers and text; it's not
+			 * helpful noise.
+			 */
+		}
+		else
+			appendPQExpBuffer(&buf, "<%02X>", c);
+
+		if (c == '\n')
+		{
+			appendPQExpBufferChar(&buf, c);
+			printed_prefix = false;
+		}
+	}
+
+	if (printed_prefix)
+		appendPQExpBufferChar(&buf, '\n');	/* finish the line */
+
+	fprintf(stderr, "%s", buf.data);
+	termPQExpBuffer(&buf);
+	return 0;
+}
+
+/*
+ * Initializes the two libcurl handles in the async_ctx. The multi handle,
+ * actx->curlm, is what drives the asynchronous engine and tells us what to do
+ * next. The easy handle, actx->curl, encapsulates the state for a single
+ * request/response. It's added to the multi handle as needed, during
+ * start_request().
+ */
+static bool
+setup_curl_handles(struct async_ctx *actx)
+{
+	/*
+	 * Create our multi handle. This encapsulates the entire conversation with
+	 * libcurl for this connection.
+	 */
+	actx->curlm = curl_multi_init();
+	if (!actx->curlm)
+	{
+		/* We don't get a lot of feedback on the failure reason. */
+		actx_error(actx, "failed to create libcurl multi handle");
+		return false;
+	}
+
+	/*
+	 * The multi handle tells us what to wait on using two callbacks. These
+	 * will manipulate actx->mux as needed.
+	 */
+	CHECK_MSETOPT(actx, CURLMOPT_SOCKETFUNCTION, register_socket, return false);
+	CHECK_MSETOPT(actx, CURLMOPT_SOCKETDATA, actx, return false);
+	CHECK_MSETOPT(actx, CURLMOPT_TIMERFUNCTION, register_timer, return false);
+	CHECK_MSETOPT(actx, CURLMOPT_TIMERDATA, actx, return false);
+
+	/*
+	 * Set up an easy handle. All of our requests are made serially, so we
+	 * only ever need to keep track of one.
+	 */
+	actx->curl = curl_easy_init();
+	if (!actx->curl)
+	{
+		actx_error(actx, "failed to create libcurl handle");
+		return false;
+	}
+
+	/*
+	 * Multi-threaded applications must set CURLOPT_NOSIGNAL. This requires us
+	 * to handle the possibility of SIGPIPE ourselves using pq_block_sigpipe;
+	 * see pg_fe_run_oauth_flow().
+	 *
+	 * NB: If libcurl is not built against a friendly DNS resolver (c-ares or
+	 * threaded), setting this option prevents DNS lookups from timing out
+	 * correctly. We warn about this situation at configure time.
+	 */
+	CHECK_SETOPT(actx, CURLOPT_NOSIGNAL, 1L, return false);
+
+	if (actx->debugging)
+	{
+		/*
+		 * Set a callback for retrieving error information from libcurl, the
+		 * function only takes effect when CURLOPT_VERBOSE has been set so
+		 * make sure the order is kept.
+		 */
+		CHECK_SETOPT(actx, CURLOPT_DEBUGFUNCTION, debug_callback, return false);
+		CHECK_SETOPT(actx, CURLOPT_VERBOSE, 1L, return false);
+	}
+
+	CHECK_SETOPT(actx, CURLOPT_ERRORBUFFER, actx->curl_err, return false);
+
+	/*
+	 * Only HTTPS is allowed. (Debug mode additionally allows HTTP; this is
+	 * intended for testing only.)
+	 *
+	 * There's a bit of unfortunate complexity around the choice of
+	 * CURLoption. CURLOPT_PROTOCOLS is deprecated in modern Curls, but its
+	 * replacement didn't show up until relatively recently.
+	 */
+	{
+#if CURL_AT_LEAST_VERSION(7, 85, 0)
+		const CURLoption popt = CURLOPT_PROTOCOLS_STR;
+		const char *protos = "https";
+		const char *const unsafe = "https,http";
+#else
+		const CURLoption popt = CURLOPT_PROTOCOLS;
+		long		protos = CURLPROTO_HTTPS;
+		const long	unsafe = CURLPROTO_HTTPS | CURLPROTO_HTTP;
+#endif
+
+		if (actx->debugging)
+			protos = unsafe;
+
+		CHECK_SETOPT(actx, popt, protos, return false);
+	}
+
+	/*
+	 * If we're in debug mode, allow the developer to change the trusted CA
+	 * list. For now, this is not something we expose outside of the UNSAFE
+	 * mode, because it's not clear that it's useful in production: both libpq
+	 * and the user's browser must trust the same authorization servers for
+	 * the flow to work at all, so any changes to the roots are likely to be
+	 * done system-wide.
+	 */
+	if (actx->debugging)
+	{
+		const char *env;
+
+		if ((env = getenv("PGOAUTHCAFILE")) != NULL)
+			CHECK_SETOPT(actx, CURLOPT_CAINFO, env, return false);
+	}
+
+	/*
+	 * Suppress the Accept header to make our request as minimal as possible.
+	 * (Ideally we would set it to "application/json" instead, but OpenID is
+	 * pretty strict when it comes to provider behavior, so we have to check
+	 * what comes back anyway.)
+	 */
+	actx->headers = curl_slist_append(actx->headers, "Accept:");
+	if (actx->headers == NULL)
+	{
+		actx_error(actx, "out of memory");
+		return false;
+	}
+	CHECK_SETOPT(actx, CURLOPT_HTTPHEADER, actx->headers, return false);
+
+	return true;
+}
+
+/*
+ * Generic HTTP Request Handlers
+ */
+
+/*
+ * Response callback from libcurl which appends the response body into
+ * actx->work_data (see start_request()). The maximum size of the data is
+ * defined by CURL_MAX_WRITE_SIZE which by default is 16kb (and can only be
+ * changed by recompiling libcurl).
+ */
+static size_t
+append_data(char *buf, size_t size, size_t nmemb, void *userdata)
+{
+	struct async_ctx *actx = userdata;
+	PQExpBuffer resp = &actx->work_data;
+	size_t		len = size * nmemb;
+
+	/* In case we receive data over the threshold, abort the transfer */
+	if ((resp->len + len) > MAX_OAUTH_RESPONSE_SIZE)
+	{
+		actx_error(actx, "response is too large");
+		return 0;
+	}
+
+	/* The data passed from libcurl is not null-terminated */
+	appendBinaryPQExpBuffer(resp, buf, len);
+
+	/*
+	 * Signal an error in order to abort the transfer in case we ran out of
+	 * memory in accepting the data.
+	 */
+	if (PQExpBufferBroken(resp))
+	{
+		actx_error(actx, "out of memory");
+		return 0;
+	}
+
+	return len;
+}
+
+/*
+ * Begins an HTTP request on the multi handle. The caller should have set up all
+ * request-specific options on actx->curl first. The server's response body will
+ * be accumulated in actx->work_data (which will be reset, so don't store
+ * anything important there across this call).
+ *
+ * Once a request is queued, it can be driven to completion via drive_request().
+ * If actx->running is zero upon return, the request has already finished and
+ * drive_request() can be called without returning control to the client.
+ */
+static bool
+start_request(struct async_ctx *actx)
+{
+	CURLMcode	err;
+
+	resetPQExpBuffer(&actx->work_data);
+	CHECK_SETOPT(actx, CURLOPT_WRITEFUNCTION, append_data, return false);
+	CHECK_SETOPT(actx, CURLOPT_WRITEDATA, actx, return false);
+
+	err = curl_multi_add_handle(actx->curlm, actx->curl);
+	if (err)
+	{
+		actx_error(actx, "failed to queue HTTP request: %s",
+				   curl_multi_strerror(err));
+		return false;
+	}
+
+	/*
+	 * actx->running tracks the number of running handles, so we can
+	 * immediately call back if no waiting is needed.
+	 *
+	 * Even though this is nominally an asynchronous process, there are some
+	 * operations that can synchronously fail by this point (e.g. connections
+	 * to closed local ports) or even synchronously succeed if the stars align
+	 * (all the libcurl connection caches hit and the server is fast).
+	 */
+	err = curl_multi_socket_action(actx->curlm, CURL_SOCKET_TIMEOUT, 0, &actx->running);
+	if (err)
+	{
+		actx_error(actx, "asynchronous HTTP request failed: %s",
+				   curl_multi_strerror(err));
+		return false;
+	}
+
+	return true;
+}
+
+/*
+ * CURL_IGNORE_DEPRECATION was added in 7.87.0. If it's not defined, we can make
+ * it a no-op.
+ */
+#ifndef CURL_IGNORE_DEPRECATION
+#define CURL_IGNORE_DEPRECATION(x) x
+#endif
+
+/*
+ * Drives the multi handle towards completion. The caller should have already
+ * set up an asynchronous request via start_request().
+ */
+static PostgresPollingStatusType
+drive_request(struct async_ctx *actx)
+{
+	CURLMcode	err;
+	CURLMsg    *msg;
+	int			msgs_left;
+	bool		done;
+
+	if (actx->running)
+	{
+		/*---
+		 * There's an async request in progress. Pump the multi handle.
+		 *
+		 * curl_multi_socket_all() is officially deprecated, because it's
+		 * inefficient and pointless if your event loop has already handed you
+		 * the exact sockets that are ready. But that's not our use case --
+		 * our client has no way to tell us which sockets are ready. (They
+		 * don't even know there are sockets to begin with.)
+		 *
+		 * We can grab the list of triggered events from the multiplexer
+		 * ourselves, but that's effectively what curl_multi_socket_all() is
+		 * going to do. And there are currently no plans for the Curl project
+		 * to remove or break this API, so ignore the deprecation. See
+		 *
+		 *    https://curl.se/mail/lib-2024-11/0028.html
+		 *
+		 */
+		CURL_IGNORE_DEPRECATION(
+			err = curl_multi_socket_all(actx->curlm, &actx->running);
+		)
+
+		if (err)
+		{
+			actx_error(actx, "asynchronous HTTP request failed: %s",
+					   curl_multi_strerror(err));
+			return PGRES_POLLING_FAILED;
+		}
+
+		if (actx->running)
+		{
+			/* We'll come back again. */
+			return PGRES_POLLING_READING;
+		}
+	}
+
+	done = false;
+	while ((msg = curl_multi_info_read(actx->curlm, &msgs_left)) != NULL)
+	{
+		if (msg->msg != CURLMSG_DONE)
+		{
+			/*
+			 * Future libcurl versions may define new message types; we don't
+			 * know how to handle them, so we'll ignore them.
+			 */
+			continue;
+		}
+
+		/* First check the status of the request itself. */
+		if (msg->data.result != CURLE_OK)
+		{
+			/*
+			 * If a more specific error hasn't already been reported, use
+			 * libcurl's description.
+			 */
+			if (actx->errbuf.len == 0)
+				actx_error_str(actx, curl_easy_strerror(msg->data.result));
+
+			return PGRES_POLLING_FAILED;
+		}
+
+		/* Now remove the finished handle; we'll add it back later if needed. */
+		err = curl_multi_remove_handle(actx->curlm, msg->easy_handle);
+		if (err)
+		{
+			actx_error(actx, "libcurl easy handle removal failed: %s",
+					   curl_multi_strerror(err));
+			return PGRES_POLLING_FAILED;
+		}
+
+		done = true;
+	}
+
+	/* Sanity check. */
+	if (!done)
+	{
+		actx_error(actx, "no result was retrieved for the finished handle");
+		return PGRES_POLLING_FAILED;
+	}
+
+	return PGRES_POLLING_OK;
+}
+
+/*
+ * URL-Encoding Helpers
+ */
+
+/*
+ * Encodes a string using the application/x-www-form-urlencoded format, and
+ * appends it to the given buffer.
+ */
+static void
+append_urlencoded(PQExpBuffer buf, const char *s)
+{
+	char	   *escaped;
+	char	   *haystack;
+	char	   *match;
+
+	escaped = curl_easy_escape(NULL, s, 0);
+	if (!escaped)
+	{
+		termPQExpBuffer(buf);	/* mark the buffer broken */
+		return;
+	}
+
+	/*
+	 * curl_easy_escape() almost does what we want, but we need the
+	 * query-specific flavor which uses '+' instead of '%20' for spaces. The
+	 * Curl command-line tool does this with a simple search-and-replace, so
+	 * follow its lead.
+	 */
+	haystack = escaped;
+
+	while ((match = strstr(haystack, "%20")) != NULL)
+	{
+		/* Append the unmatched portion, followed by the plus sign. */
+		appendBinaryPQExpBuffer(buf, haystack, match - haystack);
+		appendPQExpBufferChar(buf, '+');
+
+		/* Keep searching after the match. */
+		haystack = match + 3 /* strlen("%20") */ ;
+	}
+
+	/* Push the remainder of the string onto the buffer. */
+	appendPQExpBufferStr(buf, haystack);
+
+	curl_free(escaped);
+}
+
+/*
+ * Convenience wrapper for encoding a single string. Returns NULL on allocation
+ * failure.
+ */
+static char *
+urlencode(const char *s)
+{
+	PQExpBufferData buf;
+
+	initPQExpBuffer(&buf);
+	append_urlencoded(&buf, s);
+
+	return PQExpBufferDataBroken(buf) ? NULL : buf.data;
+}
+
+/*
+ * Appends a key/value pair to the end of an application/x-www-form-urlencoded
+ * list.
+ */
+static void
+build_urlencoded(PQExpBuffer buf, const char *key, const char *value)
+{
+	if (buf->len)
+		appendPQExpBufferChar(buf, '&');
+
+	append_urlencoded(buf, key);
+	appendPQExpBufferChar(buf, '=');
+	append_urlencoded(buf, value);
+}
+
+/*
+ * Specific HTTP Request Handlers
+ *
+ * This is finally the beginning of the actual application logic. Generally
+ * speaking, a single request consists of a start_* and a finish_* step, with
+ * drive_request() pumping the machine in between.
+ */
+
+/*
+ * Queue an OpenID Provider Configuration Request:
+ *
+ *     https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationRequest
+ *     https://www.rfc-editor.org/rfc/rfc8414#section-3.1
+ *
+ * This is done first to get the endpoint URIs we need to contact and to make
+ * sure the provider provides a device authorization flow. finish_discovery()
+ * will fill in actx->provider.
+ */
+static bool
+start_discovery(struct async_ctx *actx, const char *discovery_uri)
+{
+	CHECK_SETOPT(actx, CURLOPT_HTTPGET, 1L, return false);
+	CHECK_SETOPT(actx, CURLOPT_URL, discovery_uri, return false);
+
+	return start_request(actx);
+}
+
+static bool
+finish_discovery(struct async_ctx *actx)
+{
+	long		response_code;
+
+	/*----
+	 * Now check the response. OIDC Discovery 1.0 is pretty strict:
+	 *
+	 *     A successful response MUST use the 200 OK HTTP status code and
+	 *     return a JSON object using the application/json content type that
+	 *     contains a set of Claims as its members that are a subset of the
+	 *     Metadata values defined in Section 3.
+	 *
+	 * Compared to standard HTTP semantics, this makes life easy -- we don't
+	 * need to worry about redirections (which would call the Issuer host
+	 * validation into question), or non-authoritative responses, or any other
+	 * complications.
+	 */
+	CHECK_GETINFO(actx, CURLINFO_RESPONSE_CODE, &response_code, return false);
+
+	if (response_code != 200)
+	{
+		actx_error(actx, "unexpected response code %ld", response_code);
+		return false;
+	}
+
+	/*
+	 * Pull the fields we care about from the document.
+	 */
+	actx->errctx = "failed to parse OpenID discovery document";
+	if (!parse_provider(actx, &actx->provider))
+		return false;			/* error message already set */
+
+	/*
+	 * Fill in any defaults for OPTIONAL/RECOMMENDED fields we care about.
+	 */
+	if (!actx->provider.grant_types_supported)
+	{
+		/*
+		 * Per Section 3, the default is ["authorization_code", "implicit"].
+		 */
+		struct curl_slist *temp = actx->provider.grant_types_supported;
+
+		temp = curl_slist_append(temp, "authorization_code");
+		if (temp)
+		{
+			temp = curl_slist_append(temp, "implicit");
+		}
+
+		if (!temp)
+		{
+			actx_error(actx, "out of memory");
+			return false;
+		}
+
+		actx->provider.grant_types_supported = temp;
+	}
+
+	return true;
+}
+
+/*
+ * Ensure that the discovery document is provided by the expected issuer.
+ * Currently, issuers are statically configured in the connection string.
+ */
+static bool
+check_issuer(struct async_ctx *actx, PGconn *conn)
+{
+	const struct provider *provider = &actx->provider;
+
+	Assert(conn->oauth_issuer_id);	/* ensured by setup_oauth_parameters() */
+	Assert(provider->issuer);	/* ensured by parse_provider() */
+
+	/*---
+	 * We require strict equality for issuer identifiers -- no path or case
+	 * normalization, no substitution of default ports and schemes, etc. This
+	 * is done to match the rules in OIDC Discovery Sec. 4.3 for config
+	 * validation:
+	 *
+	 *    The issuer value returned MUST be identical to the Issuer URL that
+	 *    was used as the prefix to /.well-known/openid-configuration to
+	 *    retrieve the configuration information.
+	 *
+	 * as well as the rules set out in RFC 9207 for avoiding mix-up attacks:
+	 *
+	 *    Clients MUST then [...] compare the result to the issuer identifier
+	 *    of the authorization server where the authorization request was
+	 *    sent to. This comparison MUST use simple string comparison as defined
+	 *    in Section 6.2.1 of [RFC3986].
+	 */
+	if (strcmp(conn->oauth_issuer_id, provider->issuer) != 0)
+	{
+		actx_error(actx,
+				   "the issuer identifier (%s) does not match oauth_issuer (%s)",
+				   provider->issuer, conn->oauth_issuer_id);
+		return false;
+	}
+
+	return true;
+}
+
+#define HTTPS_SCHEME "https://"
+#define OAUTH_GRANT_TYPE_DEVICE_CODE "urn:ietf:params:oauth:grant-type:device_code"
+
+/*
+ * Ensure that the provider supports the Device Authorization flow (i.e. it
+ * provides an authorization endpoint, and both the token and authorization
+ * endpoint URLs seem reasonable).
+ */
+static bool
+check_for_device_flow(struct async_ctx *actx)
+{
+	const struct provider *provider = &actx->provider;
+
+	Assert(provider->issuer);	/* ensured by parse_provider() */
+	Assert(provider->token_endpoint);	/* ensured by parse_provider() */
+
+	if (!provider->device_authorization_endpoint)
+	{
+		actx_error(actx,
+				   "issuer \"%s\" does not provide a device authorization endpoint",
+				   provider->issuer);
+		return false;
+	}
+
+	/*
+	 * The original implementation checked that OAUTH_GRANT_TYPE_DEVICE_CODE
+	 * was present in the discovery document's grant_types_supported list. MS
+	 * Entra does not advertise this grant type, though, and since it doesn't
+	 * make sense to stand up a device_authorization_endpoint without also
+	 * accepting device codes at the token_endpoint, that's the only thing we
+	 * currently require.
+	 */
+
+	/*
+	 * Although libcurl will fail later if the URL contains an unsupported
+	 * scheme, that error message is going to be a bit opaque. This is a
+	 * decent time to bail out if we're not using HTTPS for the endpoints
+	 * we'll use for the flow.
+	 */
+	if (!actx->debugging)
+	{
+		if (pg_strncasecmp(provider->device_authorization_endpoint,
+						   HTTPS_SCHEME, strlen(HTTPS_SCHEME)) != 0)
+		{
+			actx_error(actx,
+					   "device authorization endpoint \"%s\" must use HTTPS",
+					   provider->device_authorization_endpoint);
+			return false;
+		}
+
+		if (pg_strncasecmp(provider->token_endpoint,
+						   HTTPS_SCHEME, strlen(HTTPS_SCHEME)) != 0)
+		{
+			actx_error(actx,
+					   "token endpoint \"%s\" must use HTTPS",
+					   provider->token_endpoint);
+			return false;
+		}
+	}
+
+	return true;
+}
+
+/*
+ * Adds the client ID (and secret, if provided) to the current request, using
+ * either HTTP headers or the request body.
+ */
+static bool
+add_client_identification(struct async_ctx *actx, PQExpBuffer reqbody, PGconn *conn)
+{
+	bool		success = false;
+	char	   *username = NULL;
+	char	   *password = NULL;
+
+	if (conn->oauth_client_secret)	/* Zero-length secrets are permitted! */
+	{
+		/*----
+		 * Use HTTP Basic auth to send the client_id and secret. Per RFC 6749,
+		 * Sec. 2.3.1,
+		 *
+		 *   Including the client credentials in the request-body using the
+		 *   two parameters is NOT RECOMMENDED and SHOULD be limited to
+		 *   clients unable to directly utilize the HTTP Basic authentication
+		 *   scheme (or other password-based HTTP authentication schemes).
+		 *
+		 * Additionally:
+		 *
+		 *   The client identifier is encoded using the
+		 *   "application/x-www-form-urlencoded" encoding algorithm per Appendix
+		 *   B, and the encoded value is used as the username; the client
+		 *   password is encoded using the same algorithm and used as the
+		 *   password.
+		 *
+		 * (Appendix B modifies application/x-www-form-urlencoded by requiring
+		 * an initial UTF-8 encoding step. Since the client ID and secret must
+		 * both be 7-bit ASCII -- RFC 6749 Appendix A -- we don't worry about
+		 * that in this function.)
+		 *
+		 * client_id is not added to the request body in this case. Not only
+		 * would it be redundant, but some providers in the wild (e.g. Okta)
+		 * refuse to accept it.
+		 */
+		username = urlencode(conn->oauth_client_id);
+		password = urlencode(conn->oauth_client_secret);
+
+		if (!username || !password)
+		{
+			actx_error(actx, "out of memory");
+			goto cleanup;
+		}
+
+		CHECK_SETOPT(actx, CURLOPT_HTTPAUTH, CURLAUTH_BASIC, goto cleanup);
+		CHECK_SETOPT(actx, CURLOPT_USERNAME, username, goto cleanup);
+		CHECK_SETOPT(actx, CURLOPT_PASSWORD, password, goto cleanup);
+
+		actx->used_basic_auth = true;
+	}
+	else
+	{
+		/*
+		 * If we're not otherwise authenticating, client_id is REQUIRED in the
+		 * request body.
+		 */
+		build_urlencoded(reqbody, "client_id", conn->oauth_client_id);
+
+		CHECK_SETOPT(actx, CURLOPT_HTTPAUTH, CURLAUTH_NONE, goto cleanup);
+		actx->used_basic_auth = false;
+	}
+
+	success = true;
+
+cleanup:
+	free(username);
+	free(password);
+
+	return success;
+}
+
+/*
+ * Queue a Device Authorization Request:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc8628#section-3.1
+ *
+ * This is the second step. We ask the provider to verify the end user out of
+ * band and authorize us to act on their behalf; it will give us the required
+ * nonces for us to later poll the request status, which we'll grab in
+ * finish_device_authz().
+ */
+static bool
+start_device_authz(struct async_ctx *actx, PGconn *conn)
+{
+	const char *device_authz_uri = actx->provider.device_authorization_endpoint;
+	PQExpBuffer work_buffer = &actx->work_data;
+
+	Assert(conn->oauth_client_id);	/* ensured by setup_oauth_parameters() */
+	Assert(device_authz_uri);	/* ensured by check_for_device_flow() */
+
+	/* Construct our request body. */
+	resetPQExpBuffer(work_buffer);
+	if (conn->oauth_scope && conn->oauth_scope[0])
+		build_urlencoded(work_buffer, "scope", conn->oauth_scope);
+
+	if (!add_client_identification(actx, work_buffer, conn))
+		return false;
+
+	if (PQExpBufferBroken(work_buffer))
+	{
+		actx_error(actx, "out of memory");
+		return false;
+	}
+
+	/* Make our request. */
+	CHECK_SETOPT(actx, CURLOPT_URL, device_authz_uri, return false);
+	CHECK_SETOPT(actx, CURLOPT_COPYPOSTFIELDS, work_buffer->data, return false);
+
+	return start_request(actx);
+}
+
+static bool
+finish_device_authz(struct async_ctx *actx)
+{
+	long		response_code;
+
+	CHECK_GETINFO(actx, CURLINFO_RESPONSE_CODE, &response_code, return false);
+
+	/*
+	 * Per RFC 8628, Section 3, a successful device authorization response
+	 * uses 200 OK.
+	 */
+	if (response_code == 200)
+	{
+		actx->errctx = "failed to parse device authorization";
+		if (!parse_device_authz(actx, &actx->authz))
+			return false;		/* error message already set */
+
+		return true;
+	}
+
+	/*
+	 * The device authorization endpoint uses the same error response as the
+	 * token endpoint, so the error handling roughly follows
+	 * finish_token_request(). The key difference is that an error here is
+	 * immediately fatal.
+	 */
+	if (response_code == 400 || response_code == 401)
+	{
+		struct token_error err = {0};
+
+		if (!parse_token_error(actx, &err))
+		{
+			free_token_error(&err);
+			return false;
+		}
+
+		record_token_error(actx, &err);
+
+		free_token_error(&err);
+		return false;
+	}
+
+	/* Any other response codes are considered invalid */
+	actx_error(actx, "unexpected response code %ld", response_code);
+	return false;
+}
+
+/*
+ * Queue an Access Token Request:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc6749#section-4.1.3
+ *
+ * This is the final step. We continually poll the token endpoint to see if the
+ * user has authorized us yet. finish_token_request() will pull either the token
+ * or a (ideally temporary) error status from the provider.
+ */
+static bool
+start_token_request(struct async_ctx *actx, PGconn *conn)
+{
+	const char *token_uri = actx->provider.token_endpoint;
+	const char *device_code = actx->authz.device_code;
+	PQExpBuffer work_buffer = &actx->work_data;
+
+	Assert(conn->oauth_client_id);	/* ensured by setup_oauth_parameters() */
+	Assert(token_uri);			/* ensured by parse_provider() */
+	Assert(device_code);		/* ensured by parse_device_authz() */
+
+	/* Construct our request body. */
+	resetPQExpBuffer(work_buffer);
+	build_urlencoded(work_buffer, "device_code", device_code);
+	build_urlencoded(work_buffer, "grant_type", OAUTH_GRANT_TYPE_DEVICE_CODE);
+
+	if (!add_client_identification(actx, work_buffer, conn))
+		return false;
+
+	if (PQExpBufferBroken(work_buffer))
+	{
+		actx_error(actx, "out of memory");
+		return false;
+	}
+
+	/* Make our request. */
+	CHECK_SETOPT(actx, CURLOPT_URL, token_uri, return false);
+	CHECK_SETOPT(actx, CURLOPT_COPYPOSTFIELDS, work_buffer->data, return false);
+
+	return start_request(actx);
+}
+
+static bool
+finish_token_request(struct async_ctx *actx, struct token *tok)
+{
+	long		response_code;
+
+	CHECK_GETINFO(actx, CURLINFO_RESPONSE_CODE, &response_code, return false);
+
+	/*
+	 * Per RFC 6749, Section 5, a successful response uses 200 OK.
+	 */
+	if (response_code == 200)
+	{
+		actx->errctx = "failed to parse access token response";
+		if (!parse_access_token(actx, tok))
+			return false;		/* error message already set */
+
+		return true;
+	}
+
+	/*
+	 * An error response uses either 400 Bad Request or 401 Unauthorized.
+	 * There are references online to implementations using 403 for error
+	 * return which would violate the specification. For now we stick to the
+	 * specification but we might have to revisit this.
+	 */
+	if (response_code == 400 || response_code == 401)
+	{
+		if (!parse_token_error(actx, &tok->err))
+			return false;
+
+		return true;
+	}
+
+	/* Any other response codes are considered invalid */
+	actx_error(actx, "unexpected response code %ld", response_code);
+	return false;
+}
+
+/*
+ * Finishes the token request and examines the response. If the flow has
+ * completed, a valid token will be returned via the parameter list. Otherwise,
+ * the token parameter remains unchanged, and the caller needs to wait for
+ * another interval (which will have been increased in response to a slow_down
+ * message from the server) before starting a new token request.
+ *
+ * False is returned only for permanent error conditions.
+ */
+static bool
+handle_token_response(struct async_ctx *actx, char **token)
+{
+	bool		success = false;
+	struct token tok = {0};
+	const struct token_error *err;
+
+	if (!finish_token_request(actx, &tok))
+		goto token_cleanup;
+
+	/* A successful token request gives either a token or an in-band error. */
+	Assert(tok.access_token || tok.err.error);
+
+	if (tok.access_token)
+	{
+		*token = tok.access_token;
+		tok.access_token = NULL;
+
+		success = true;
+		goto token_cleanup;
+	}
+
+	/*
+	 * authorization_pending and slow_down are the only acceptable errors;
+	 * anything else and we bail. These are defined in RFC 8628, Sec. 3.5.
+	 */
+	err = &tok.err;
+	if (strcmp(err->error, "authorization_pending") != 0 &&
+		strcmp(err->error, "slow_down") != 0)
+	{
+		record_token_error(actx, err);
+		goto token_cleanup;
+	}
+
+	/*
+	 * A slow_down error requires us to permanently increase our retry
+	 * interval by five seconds.
+	 */
+	if (strcmp(err->error, "slow_down") == 0)
+	{
+		int			prev_interval = actx->authz.interval;
+
+		actx->authz.interval += 5;
+		if (actx->authz.interval < prev_interval)
+		{
+			actx_error(actx, "slow_down interval overflow");
+			goto token_cleanup;
+		}
+	}
+
+	success = true;
+
+token_cleanup:
+	free_token(&tok);
+	return success;
+}
+
+/*
+ * Displays a device authorization prompt for action by the end user, either via
+ * the PQauthDataHook, or by a message on standard error if no hook is set.
+ */
+static bool
+prompt_user(struct async_ctx *actx, PGconn *conn)
+{
+	int			res;
+	PGpromptOAuthDevice prompt = {
+		.verification_uri = actx->authz.verification_uri,
+		.user_code = actx->authz.user_code,
+		.verification_uri_complete = actx->authz.verification_uri_complete,
+		.expires_in = actx->authz.expires_in,
+	};
+
+	res = PQauthDataHook(PQAUTHDATA_PROMPT_OAUTH_DEVICE, conn, &prompt);
+
+	if (!res)
+	{
+		/*
+		 * translator: The first %s is a URL for the user to visit in a
+		 * browser, and the second %s is a code to be copy-pasted there.
+		 */
+		fprintf(stderr, libpq_gettext("Visit %s and enter the code: %s\n"),
+				prompt.verification_uri, prompt.user_code);
+	}
+	else if (res < 0)
+	{
+		actx_error(actx, "device prompt failed");
+		return false;
+	}
+
+	return true;
+}
+
+/*
+ * Calls curl_global_init() in a thread-safe way.
+ *
+ * libcurl has stringent requirements for the thread context in which you call
+ * curl_global_init(), because it's going to try initializing a bunch of other
+ * libraries (OpenSSL, Winsock, etc). Recent versions of libcurl have improved
+ * the thread-safety situation, but there's a chicken-and-egg problem at
+ * runtime: you can't check the thread safety until you've initialized libcurl,
+ * which you can't do from within a thread unless you know it's thread-safe...
+ *
+ * Returns true if initialization was successful. Successful or not, this
+ * function will not try to reinitialize Curl on successive calls.
+ */
+static bool
+initialize_curl(PGconn *conn)
+{
+	/*
+	 * Don't let the compiler play tricks with this variable. In the
+	 * HAVE_THREADSAFE_CURL_GLOBAL_INIT case, we don't care if two threads
+	 * enter simultaneously, but we do care if this gets set transiently to
+	 * PG_BOOL_YES/NO in cases where that's not the final answer.
+	 */
+	static volatile PGTernaryBool init_successful = PG_BOOL_UNKNOWN;
+#if HAVE_THREADSAFE_CURL_GLOBAL_INIT
+	curl_version_info_data *info;
+#endif
+
+#if !HAVE_THREADSAFE_CURL_GLOBAL_INIT
+
+	/*
+	 * Lock around the whole function. If a libpq client performs its own work
+	 * with libcurl, it must either ensure that Curl is initialized safely
+	 * before calling us (in which case our call will be a no-op), or else it
+	 * must guard its own calls to curl_global_init() with a registered
+	 * threadlock handler. See PQregisterThreadLock().
+	 */
+	pglock_thread();
+#endif
+
+	/*
+	 * Skip initialization if we've already done it. (Curl tracks the number
+	 * of calls; there's no point in incrementing the counter every time we
+	 * connect.)
+	 */
+	if (init_successful == PG_BOOL_YES)
+		goto done;
+	else if (init_successful == PG_BOOL_NO)
+	{
+		libpq_append_conn_error(conn,
+								"curl_global_init previously failed during OAuth setup");
+		goto done;
+	}
+
+	/*
+	 * We know we've already initialized Winsock by this point (see
+	 * pqMakeEmptyPGconn()), so we should be able to safely skip that bit. But
+	 * we have to tell libcurl to initialize everything else, because other
+	 * pieces of our client executable may already be using libcurl for their
+	 * own purposes. If we initialize libcurl with only a subset of its
+	 * features, we could break those other clients nondeterministically, and
+	 * that would probably be a nightmare to debug.
+	 *
+	 * If some other part of the program has already called this, it's a
+	 * no-op.
+	 */
+	if (curl_global_init(CURL_GLOBAL_ALL & ~CURL_GLOBAL_WIN32) != CURLE_OK)
+	{
+		libpq_append_conn_error(conn,
+								"curl_global_init failed during OAuth setup");
+		init_successful = PG_BOOL_NO;
+		goto done;
+	}
+
+#if HAVE_THREADSAFE_CURL_GLOBAL_INIT
+
+	/*
+	 * If we determined at configure time that the Curl installation is
+	 * threadsafe, our job here is much easier. We simply initialize above
+	 * without any locking (concurrent or duplicated calls are fine in that
+	 * situation), then double-check to make sure the runtime setting agrees,
+	 * to try to catch silent downgrades.
+	 */
+	info = curl_version_info(CURLVERSION_NOW);
+	if (!(info->features & CURL_VERSION_THREADSAFE))
+	{
+		/*
+		 * 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 threadsafe\n"
+								"\tCurl initialization was reported threadsafe 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;
+	}
+#endif
+
+	init_successful = PG_BOOL_YES;
+
+done:
+#if !HAVE_THREADSAFE_CURL_GLOBAL_INIT
+	pgunlock_thread();
+#endif
+	return (init_successful == PG_BOOL_YES);
+}
+
+/*
+ * The core nonblocking libcurl implementation. This will be called several
+ * times to pump the async engine.
+ *
+ * The architecture is based on PQconnectPoll(). The first half drives the
+ * connection state forward as necessary, returning if we're not ready to
+ * proceed to the next step yet. The second half performs the actual transition
+ * between states.
+ *
+ * You can trace the overall OAuth flow through the second half. It's linear
+ * until we get to the end, where we flip back and forth between
+ * OAUTH_STEP_TOKEN_REQUEST and OAUTH_STEP_WAIT_INTERVAL to regularly ping the
+ * provider.
+ */
+static PostgresPollingStatusType
+pg_fe_run_oauth_flow_impl(PGconn *conn)
+{
+	fe_oauth_state *state = conn->sasl_state;
+	struct async_ctx *actx;
+
+	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. */
+		conn->altsock = actx->mux;
+
+		switch (actx->step)
+		{
+			case OAUTH_STEP_INIT:
+				break;
+
+			case OAUTH_STEP_DISCOVERY:
+			case OAUTH_STEP_DEVICE_AUTHORIZATION:
+			case OAUTH_STEP_TOKEN_REQUEST:
+				{
+					PostgresPollingStatusType status;
+
+					status = drive_request(actx);
+
+					if (status == PGRES_POLLING_FAILED)
+						goto error_return;
+					else if (status != PGRES_POLLING_OK)
+					{
+						/* not done yet */
+						return status;
+					}
+
+					break;
+				}
+
+			case OAUTH_STEP_WAIT_INTERVAL:
+
+				/*
+				 * The client application is supposed to wait until our timer
+				 * expires before calling PQconnectPoll() again, but that
+				 * might not happen. To avoid sending a token request early,
+				 * check the timer before continuing.
+				 */
+				if (!timer_expired(actx))
+				{
+					conn->altsock = actx->timerfd;
+					return PGRES_POLLING_READING;
+				}
+
+				/* Disable the expired timer. */
+				if (!set_timer(actx, -1))
+					goto error_return;
+
+				break;
+		}
+
+		/*
+		 * Each case here must ensure that actx->running is set while we're
+		 * waiting on some asynchronous work. Most cases rely on
+		 * start_request() to do that for them.
+		 */
+		switch (actx->step)
+		{
+			case OAUTH_STEP_INIT:
+				actx->errctx = "failed to fetch OpenID discovery document";
+				if (!start_discovery(actx, conn->oauth_discovery_uri))
+					goto error_return;
+
+				actx->step = OAUTH_STEP_DISCOVERY;
+				break;
+
+			case OAUTH_STEP_DISCOVERY:
+				if (!finish_discovery(actx))
+					goto error_return;
+
+				if (!check_issuer(actx, conn))
+					goto error_return;
+
+				actx->errctx = "cannot run OAuth device authorization";
+				if (!check_for_device_flow(actx))
+					goto error_return;
+
+				actx->errctx = "failed to obtain device authorization";
+				if (!start_device_authz(actx, conn))
+					goto error_return;
+
+				actx->step = OAUTH_STEP_DEVICE_AUTHORIZATION;
+				break;
+
+			case OAUTH_STEP_DEVICE_AUTHORIZATION:
+				if (!finish_device_authz(actx))
+					goto error_return;
+
+				actx->errctx = "failed to obtain access token";
+				if (!start_token_request(actx, conn))
+					goto error_return;
+
+				actx->step = OAUTH_STEP_TOKEN_REQUEST;
+				break;
+
+			case OAUTH_STEP_TOKEN_REQUEST:
+				if (!handle_token_response(actx, &conn->oauth_token))
+					goto error_return;
+
+				if (!actx->user_prompted)
+				{
+					/*
+					 * Now that we know the token endpoint isn't broken, give
+					 * the user the login instructions.
+					 */
+					if (!prompt_user(actx, conn))
+						goto error_return;
+
+					actx->user_prompted = true;
+				}
+
+				if (conn->oauth_token)
+					break;		/* done! */
+
+				/*
+				 * Wait for the required interval before issuing the next
+				 * request.
+				 */
+				if (!set_timer(actx, actx->authz.interval * 1000))
+					goto error_return;
+
+				/*
+				 * No Curl requests are running, so we can simplify by having
+				 * the client wait directly on the timerfd rather than the
+				 * multiplexer.
+				 */
+				conn->altsock = actx->timerfd;
+
+				actx->step = OAUTH_STEP_WAIT_INTERVAL;
+				actx->running = 1;
+				break;
+
+			case OAUTH_STEP_WAIT_INTERVAL:
+				actx->errctx = "failed to obtain access token";
+				if (!start_token_request(actx, conn))
+					goto error_return;
+
+				actx->step = OAUTH_STEP_TOKEN_REQUEST;
+				break;
+		}
+
+		/*
+		 * The vast majority of the time, if we don't have a token at this
+		 * point, actx->running will be set. But there are some corner cases
+		 * where we can immediately loop back around; see start_request().
+		 */
+	} while (!conn->oauth_token && !actx->running);
+
+	/* If we've stored a token, we're done. Otherwise come back later. */
+	return conn->oauth_token ? PGRES_POLLING_OK : PGRES_POLLING_READING;
+
+error_return:
+
+	/*
+	 * Assemble the three parts of our error: context, body, and detail. See
+	 * also the documentation for struct async_ctx.
+	 */
+	if (actx->errctx)
+	{
+		appendPQExpBufferStr(&conn->errorMessage,
+							 libpq_gettext(actx->errctx));
+		appendPQExpBufferStr(&conn->errorMessage, ": ");
+	}
+
+	if (PQExpBufferDataBroken(actx->errbuf))
+		appendPQExpBufferStr(&conn->errorMessage,
+							 libpq_gettext("out of memory"));
+	else
+		appendPQExpBufferStr(&conn->errorMessage, actx->errbuf.data);
+
+	if (actx->curl_err[0])
+	{
+		size_t		len;
+
+		appendPQExpBuffer(&conn->errorMessage, " (%s)", actx->curl_err);
+
+		/* Sometimes libcurl adds a newline to the error buffer. :( */
+		len = conn->errorMessage.len;
+		if (len >= 2 && conn->errorMessage.data[len - 2] == '\n')
+		{
+			conn->errorMessage.data[len - 2] = ')';
+			conn->errorMessage.data[len - 1] = '\0';
+			conn->errorMessage.len--;
+		}
+	}
+
+	appendPQExpBufferStr(&conn->errorMessage, "\n");
+
+	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.
+ */
+PostgresPollingStatusType
+pg_fe_run_oauth_flow(PGconn *conn)
+{
+	PostgresPollingStatusType result;
+#ifndef WIN32
+	sigset_t	osigset;
+	bool		sigpipe_pending;
+	bool		masked;
+
+	/*---
+	 * Ignore SIGPIPE on this thread during all Curl processing.
+	 *
+	 * Because we support multiple threads, we have to set up libcurl with
+	 * CURLOPT_NOSIGNAL, which disables its default global handling of
+	 * SIGPIPE. From the Curl docs:
+	 *
+	 *     libcurl makes an effort to never cause such SIGPIPE signals to
+	 *     trigger, but some operating systems have no way to avoid them and
+	 *     even on those that have there are some corner cases when they may
+	 *     still happen, contrary to our desire.
+	 *
+	 * Note that libcurl is also at the mercy of its DNS resolution and SSL
+	 * libraries; if any of them forget a MSG_NOSIGNAL then we're in trouble.
+	 * Modern platforms and libraries seem to get it right, so this is a
+	 * difficult corner case to exercise in practice, and unfortunately it's
+	 * not really clear whether it's necessary in all cases.
+	 */
+	masked = (pq_block_sigpipe(&osigset, &sigpipe_pending) == 0);
+#endif
+
+	result = pg_fe_run_oauth_flow_impl(conn);
+
+#ifndef WIN32
+	if (masked)
+	{
+		/*
+		 * Undo the SIGPIPE mask. Assume we may have gotten EPIPE (we have no
+		 * way of knowing at this level).
+		 */
+		pq_reset_sigpipe(&osigset, sigpipe_pending, true /* EPIPE, maybe */ );
+	}
+#endif
+
+	return result;
+}
diff --git a/src/interfaces/libpq/fe-auth-oauth.c b/src/interfaces/libpq/fe-auth-oauth.c
new file mode 100644
index 00000000000..8beae9604c7
--- /dev/null
+++ b/src/interfaces/libpq/fe-auth-oauth.c
@@ -0,0 +1,1153 @@
+/*-------------------------------------------------------------------------
+ *
+ * fe-auth-oauth.c
+ *	   The front-end (client) implementation of OAuth/OIDC authentication
+ *	   using the SASL OAUTHBEARER mechanism.
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/interfaces/libpq/fe-auth-oauth.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres_fe.h"
+
+#include "common/base64.h"
+#include "common/hmac.h"
+#include "common/jsonapi.h"
+#include "common/oauth-common.h"
+#include "fe-auth.h"
+#include "fe-auth-oauth.h"
+#include "mb/pg_wchar.h"
+
+/* The exported OAuth callback mechanism. */
+static void *oauth_init(PGconn *conn, const char *password,
+						const char *sasl_mechanism);
+static SASLStatus oauth_exchange(void *opaq, bool final,
+								 char *input, int inputlen,
+								 char **output, int *outputlen);
+static bool oauth_channel_bound(void *opaq);
+static void oauth_free(void *opaq);
+
+const pg_fe_sasl_mech pg_oauth_mech = {
+	oauth_init,
+	oauth_exchange,
+	oauth_channel_bound,
+	oauth_free,
+};
+
+/*
+ * Initializes mechanism state for OAUTHBEARER.
+ *
+ * For a full description of the API, see libpq/fe-auth-sasl.h.
+ */
+static void *
+oauth_init(PGconn *conn, const char *password,
+		   const char *sasl_mechanism)
+{
+	fe_oauth_state *state;
+
+	/*
+	 * We only support one SASL mechanism here; anything else is programmer
+	 * error.
+	 */
+	Assert(sasl_mechanism != NULL);
+	Assert(strcmp(sasl_mechanism, OAUTHBEARER_NAME) == 0);
+
+	state = calloc(1, sizeof(*state));
+	if (!state)
+		return NULL;
+
+	state->step = FE_OAUTH_INIT;
+	state->conn = conn;
+
+	return state;
+}
+
+/*
+ * Frees the state allocated by oauth_init().
+ *
+ * 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().
+ */
+static void
+oauth_free(void *opaq)
+{
+	fe_oauth_state *state = opaq;
+
+	/* Any async authentication state should have been cleaned up already. */
+	Assert(!state->async_ctx);
+
+	free(state);
+}
+
+#define kvsep "\x01"
+
+/*
+ * Constructs an OAUTHBEARER client initial response (RFC 7628, Sec. 3.1).
+ *
+ * If discover is true, the initial response will contain a request for the
+ * server's required OAuth parameters (Sec. 4.3). Otherwise, conn->token must
+ * be set; it will be sent as the connection's bearer token.
+ *
+ * Returns the response as a null-terminated string, or NULL on error.
+ */
+static char *
+client_initial_response(PGconn *conn, bool discover)
+{
+	static const char *const resp_format = "n,," kvsep "auth=%s%s" kvsep kvsep;
+
+	PQExpBufferData buf;
+	const char *authn_scheme;
+	char	   *response = NULL;
+	const char *token = conn->oauth_token;
+
+	if (discover)
+	{
+		/* Parameter discovery uses a completely empty auth value. */
+		authn_scheme = token = "";
+	}
+	else
+	{
+		/*
+		 * Use a Bearer authentication scheme (RFC 6750, Sec. 2.1). A trailing
+		 * space is used as a separator.
+		 */
+		authn_scheme = "Bearer ";
+
+		/* conn->token must have been set in this case. */
+		if (!token)
+		{
+			Assert(false);
+			libpq_append_conn_error(conn,
+									"internal error: no OAuth token was set for the connection");
+			return NULL;
+		}
+	}
+
+	initPQExpBuffer(&buf);
+	appendPQExpBuffer(&buf, resp_format, authn_scheme, token);
+
+	if (!PQExpBufferDataBroken(buf))
+		response = strdup(buf.data);
+	termPQExpBuffer(&buf);
+
+	if (!response)
+		libpq_append_conn_error(conn, "out of memory");
+
+	return response;
+}
+
+/*
+ * JSON Parser (for the OAUTHBEARER error result)
+ */
+
+/* Relevant JSON fields in the error result object. */
+#define ERROR_STATUS_FIELD "status"
+#define ERROR_SCOPE_FIELD "scope"
+#define ERROR_OPENID_CONFIGURATION_FIELD "openid-configuration"
+
+struct json_ctx
+{
+	char	   *errmsg;			/* any non-NULL value stops all processing */
+	PQExpBufferData errbuf;		/* backing memory for errmsg */
+	int			nested;			/* nesting level (zero is the top) */
+
+	const char *target_field_name;	/* points to a static allocation */
+	char	  **target_field;	/* see below */
+
+	/* target_field, if set, points to one of the following: */
+	char	   *status;
+	char	   *scope;
+	char	   *discovery_uri;
+};
+
+#define oauth_json_has_error(ctx) \
+	(PQExpBufferDataBroken((ctx)->errbuf) || (ctx)->errmsg)
+
+#define oauth_json_set_error(ctx, ...) \
+	do { \
+		appendPQExpBuffer(&(ctx)->errbuf, __VA_ARGS__); \
+		(ctx)->errmsg = (ctx)->errbuf.data; \
+	} while (0)
+
+static JsonParseErrorType
+oauth_json_object_start(void *state)
+{
+	struct json_ctx *ctx = state;
+
+	if (ctx->target_field)
+	{
+		Assert(ctx->nested == 1);
+
+		oauth_json_set_error(ctx,
+							 libpq_gettext("field \"%s\" must be a string"),
+							 ctx->target_field_name);
+	}
+
+	++ctx->nested;
+	return oauth_json_has_error(ctx) ? JSON_SEM_ACTION_FAILED : JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_object_end(void *state)
+{
+	struct json_ctx *ctx = state;
+
+	--ctx->nested;
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_object_field_start(void *state, char *name, bool isnull)
+{
+	struct json_ctx *ctx = state;
+
+	/* Only top-level keys are considered. */
+	if (ctx->nested == 1)
+	{
+		if (strcmp(name, ERROR_STATUS_FIELD) == 0)
+		{
+			ctx->target_field_name = ERROR_STATUS_FIELD;
+			ctx->target_field = &ctx->status;
+		}
+		else if (strcmp(name, ERROR_SCOPE_FIELD) == 0)
+		{
+			ctx->target_field_name = ERROR_SCOPE_FIELD;
+			ctx->target_field = &ctx->scope;
+		}
+		else if (strcmp(name, ERROR_OPENID_CONFIGURATION_FIELD) == 0)
+		{
+			ctx->target_field_name = ERROR_OPENID_CONFIGURATION_FIELD;
+			ctx->target_field = &ctx->discovery_uri;
+		}
+	}
+
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_array_start(void *state)
+{
+	struct json_ctx *ctx = state;
+
+	if (!ctx->nested)
+	{
+		ctx->errmsg = libpq_gettext("top-level element must be an object");
+	}
+	else if (ctx->target_field)
+	{
+		Assert(ctx->nested == 1);
+
+		oauth_json_set_error(ctx,
+							 libpq_gettext("field \"%s\" must be a string"),
+							 ctx->target_field_name);
+	}
+
+	return oauth_json_has_error(ctx) ? JSON_SEM_ACTION_FAILED : JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_scalar(void *state, char *token, JsonTokenType type)
+{
+	struct json_ctx *ctx = state;
+
+	if (!ctx->nested)
+	{
+		ctx->errmsg = libpq_gettext("top-level element must be an object");
+		return JSON_SEM_ACTION_FAILED;
+	}
+
+	if (ctx->target_field)
+	{
+		if (ctx->nested != 1)
+		{
+			/*
+			 * ctx->target_field should not have been set for nested keys.
+			 * Assert and don't continue any further for production builds.
+			 */
+			Assert(false);
+			oauth_json_set_error(ctx,	/* don't bother translating */
+								 "internal error: target scalar found at nesting level %d during OAUTHBEARER parsing",
+								 ctx->nested);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		/*
+		 * We don't allow duplicate field names; error out if the target has
+		 * already been set.
+		 */
+		if (*ctx->target_field)
+		{
+			oauth_json_set_error(ctx,
+								 libpq_gettext("field \"%s\" is duplicated"),
+								 ctx->target_field_name);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		/* The only fields we support are strings. */
+		if (type != JSON_TOKEN_STRING)
+		{
+			oauth_json_set_error(ctx,
+								 libpq_gettext("field \"%s\" must be a string"),
+								 ctx->target_field_name);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		*ctx->target_field = strdup(token);
+		if (!*ctx->target_field)
+			return JSON_OUT_OF_MEMORY;
+
+		ctx->target_field = NULL;
+		ctx->target_field_name = NULL;
+	}
+	else
+	{
+		/* otherwise we just ignore it */
+	}
+
+	return JSON_SUCCESS;
+}
+
+#define HTTPS_SCHEME "https://"
+#define HTTP_SCHEME "http://"
+
+/* We support both well-known suffixes defined by RFC 8414. */
+#define WK_PREFIX "/.well-known/"
+#define OPENID_WK_SUFFIX "openid-configuration"
+#define OAUTH_WK_SUFFIX "oauth-authorization-server"
+
+/*
+ * Derives an issuer identifier from one of our recognized .well-known URIs,
+ * using the rules in RFC 8414.
+ */
+static char *
+issuer_from_well_known_uri(PGconn *conn, const char *wkuri)
+{
+	const char *authority_start = NULL;
+	const char *wk_start;
+	const char *wk_end;
+	char	   *issuer;
+	ptrdiff_t	start_offset,
+				end_offset;
+	size_t		end_len;
+
+	/*
+	 * https:// is required for issuer identifiers (RFC 8414, Sec. 2; OIDC
+	 * Discovery 1.0, Sec. 3). This is a case-insensitive comparison at this
+	 * level (but issuer identifier comparison at the level above this is
+	 * case-sensitive, so in practice it's probably moot).
+	 */
+	if (pg_strncasecmp(wkuri, HTTPS_SCHEME, strlen(HTTPS_SCHEME)) == 0)
+		authority_start = wkuri + strlen(HTTPS_SCHEME);
+
+	if (!authority_start
+		&& oauth_unsafe_debugging_enabled()
+		&& pg_strncasecmp(wkuri, HTTP_SCHEME, strlen(HTTP_SCHEME)) == 0)
+	{
+		/* Allow http:// for testing only. */
+		authority_start = wkuri + strlen(HTTP_SCHEME);
+	}
+
+	if (!authority_start)
+	{
+		libpq_append_conn_error(conn,
+								"OAuth discovery URI \"%s\" must use HTTPS",
+								wkuri);
+		return NULL;
+	}
+
+	/*
+	 * Well-known URIs in general may support queries and fragments, but the
+	 * two types we support here do not. (They must be constructed from the
+	 * components of issuer identifiers, which themselves may not contain any
+	 * queries or fragments.)
+	 *
+	 * It's important to check this first, to avoid getting tricked later by a
+	 * prefix buried inside a query or fragment.
+	 */
+	if (strpbrk(authority_start, "?#") != NULL)
+	{
+		libpq_append_conn_error(conn,
+								"OAuth discovery URI \"%s\" must not contain query or fragment components",
+								wkuri);
+		return NULL;
+	}
+
+	/*
+	 * Find the start of the .well-known prefix. IETF rules (RFC 8615) state
+	 * this must be at the beginning of the path component, but OIDC defined
+	 * it at the end instead (OIDC Discovery 1.0, Sec. 4), so we have to
+	 * search for it anywhere.
+	 */
+	wk_start = strstr(authority_start, WK_PREFIX);
+	if (!wk_start)
+	{
+		libpq_append_conn_error(conn,
+								"OAuth discovery URI \"%s\" is not a .well-known URI",
+								wkuri);
+		return NULL;
+	}
+
+	/*
+	 * Now find the suffix type. We only support the two defined in OIDC
+	 * Discovery 1.0 and RFC 8414.
+	 */
+	wk_end = wk_start + strlen(WK_PREFIX);
+
+	if (strncmp(wk_end, OPENID_WK_SUFFIX, strlen(OPENID_WK_SUFFIX)) == 0)
+		wk_end += strlen(OPENID_WK_SUFFIX);
+	else if (strncmp(wk_end, OAUTH_WK_SUFFIX, strlen(OAUTH_WK_SUFFIX)) == 0)
+		wk_end += strlen(OAUTH_WK_SUFFIX);
+	else
+		wk_end = NULL;
+
+	/*
+	 * Even if there's a match, we still need to check to make sure the suffix
+	 * takes up the entire path segment, to weed out constructions like
+	 * "/.well-known/openid-configuration-bad".
+	 */
+	if (!wk_end || (*wk_end != '/' && *wk_end != '\0'))
+	{
+		libpq_append_conn_error(conn,
+								"OAuth discovery URI \"%s\" uses an unsupported .well-known suffix",
+								wkuri);
+		return NULL;
+	}
+
+	/*
+	 * Finally, make sure the .well-known components are provided either as a
+	 * prefix (IETF style) or as a postfix (OIDC style). In other words,
+	 * "https://localhost/a/.well-known/openid-configuration/b" is not allowed
+	 * to claim association with "https://localhost/a/b".
+	 */
+	if (*wk_end != '\0')
+	{
+		/*
+		 * It's not at the end, so it's required to be at the beginning at the
+		 * path. Find the starting slash.
+		 */
+		const char *path_start;
+
+		path_start = strchr(authority_start, '/');
+		Assert(path_start);		/* otherwise we wouldn't have found WK_PREFIX */
+
+		if (wk_start != path_start)
+		{
+			libpq_append_conn_error(conn,
+									"OAuth discovery URI \"%s\" uses an invalid format",
+									wkuri);
+			return NULL;
+		}
+	}
+
+	/* Checks passed! Now build the issuer. */
+	issuer = strdup(wkuri);
+	if (!issuer)
+	{
+		libpq_append_conn_error(conn, "out of memory");
+		return NULL;
+	}
+
+	/*
+	 * The .well-known components are from [wk_start, wk_end). Remove those to
+	 * form the issuer ID, by shifting the path suffix (which may be empty)
+	 * leftwards.
+	 */
+	start_offset = wk_start - wkuri;
+	end_offset = wk_end - wkuri;
+	end_len = strlen(wk_end) + 1;	/* move the NULL terminator too */
+
+	memmove(issuer + start_offset, issuer + end_offset, end_len);
+
+	return issuer;
+}
+
+/*
+ * Parses the server error result (RFC 7628, Sec. 3.2.2) contained in msg and
+ * stores any discovered openid_configuration and scope settings for the
+ * connection.
+ */
+static bool
+handle_oauth_sasl_error(PGconn *conn, const char *msg, int msglen)
+{
+	JsonLexContext lex = {0};
+	JsonSemAction sem = {0};
+	JsonParseErrorType err;
+	struct json_ctx ctx = {0};
+	char	   *errmsg = NULL;
+	bool		success = false;
+
+	Assert(conn->oauth_issuer_id);	/* ensured by setup_oauth_parameters() */
+
+	/* Sanity check. */
+	if (strlen(msg) != msglen)
+	{
+		libpq_append_conn_error(conn,
+								"server's error message contained an embedded NULL, and was discarded");
+		return false;
+	}
+
+	/*
+	 * pg_parse_json doesn't validate the incoming UTF-8, so we have to check
+	 * that up front.
+	 */
+	if (pg_encoding_verifymbstr(PG_UTF8, msg, msglen) != msglen)
+	{
+		libpq_append_conn_error(conn,
+								"server's error response is not valid UTF-8");
+		return false;
+	}
+
+	makeJsonLexContextCstringLen(&lex, msg, msglen, PG_UTF8, true);
+	setJsonLexContextOwnsTokens(&lex, true);	/* must not leak on error */
+
+	initPQExpBuffer(&ctx.errbuf);
+	sem.semstate = &ctx;
+
+	sem.object_start = oauth_json_object_start;
+	sem.object_end = oauth_json_object_end;
+	sem.object_field_start = oauth_json_object_field_start;
+	sem.array_start = oauth_json_array_start;
+	sem.scalar = oauth_json_scalar;
+
+	err = pg_parse_json(&lex, &sem);
+
+	if (err == JSON_SEM_ACTION_FAILED)
+	{
+		if (PQExpBufferDataBroken(ctx.errbuf))
+			errmsg = libpq_gettext("out of memory");
+		else if (ctx.errmsg)
+			errmsg = ctx.errmsg;
+		else
+		{
+			/*
+			 * Developer error: one of the action callbacks didn't call
+			 * oauth_json_set_error() before erroring out.
+			 */
+			Assert(oauth_json_has_error(&ctx));
+			errmsg = "<unexpected empty error>";
+		}
+	}
+	else if (err != JSON_SUCCESS)
+		errmsg = json_errdetail(err, &lex);
+
+	if (errmsg)
+		libpq_append_conn_error(conn,
+								"failed to parse server's error response: %s",
+								errmsg);
+
+	/* Don't need the error buffer or the JSON lexer anymore. */
+	termPQExpBuffer(&ctx.errbuf);
+	freeJsonLexContext(&lex);
+
+	if (errmsg)
+		goto cleanup;
+
+	if (ctx.discovery_uri)
+	{
+		char	   *discovery_issuer;
+
+		/*
+		 * The URI MUST correspond to our existing issuer, to avoid mix-ups.
+		 *
+		 * Issuer comparison is done byte-wise, rather than performing any URL
+		 * normalization; this follows the suggestions for issuer comparison
+		 * in RFC 9207 Sec. 2.4 (which requires simple string comparison) and
+		 * vastly simplifies things. Since this is the key protection against
+		 * a rogue server sending the client to an untrustworthy location,
+		 * simpler is better.
+		 */
+		discovery_issuer = issuer_from_well_known_uri(conn, ctx.discovery_uri);
+		if (!discovery_issuer)
+			goto cleanup;		/* error message already set */
+
+		if (strcmp(conn->oauth_issuer_id, discovery_issuer) != 0)
+		{
+			libpq_append_conn_error(conn,
+									"server's discovery document at %s (issuer \"%s\") is incompatible with oauth_issuer (%s)",
+									ctx.discovery_uri, discovery_issuer,
+									conn->oauth_issuer_id);
+
+			free(discovery_issuer);
+			goto cleanup;
+		}
+
+		free(discovery_issuer);
+
+		if (!conn->oauth_discovery_uri)
+		{
+			conn->oauth_discovery_uri = ctx.discovery_uri;
+			ctx.discovery_uri = NULL;
+		}
+		else
+		{
+			/* This must match the URI we'd previously determined. */
+			if (strcmp(conn->oauth_discovery_uri, ctx.discovery_uri) != 0)
+			{
+				libpq_append_conn_error(conn,
+										"server's discovery document has moved to %s (previous location was %s)",
+										ctx.discovery_uri,
+										conn->oauth_discovery_uri);
+				goto cleanup;
+			}
+		}
+	}
+
+	if (ctx.scope)
+	{
+		/* Servers may not override a previously set oauth_scope. */
+		if (!conn->oauth_scope)
+		{
+			conn->oauth_scope = ctx.scope;
+			ctx.scope = NULL;
+		}
+	}
+
+	if (!ctx.status)
+	{
+		libpq_append_conn_error(conn,
+								"server sent error response without a status");
+		goto cleanup;
+	}
+
+	if (strcmp(ctx.status, "invalid_token") != 0)
+	{
+		/*
+		 * invalid_token is the only error code we'll automatically retry for;
+		 * otherwise, just bail out now.
+		 */
+		libpq_append_conn_error(conn,
+								"server rejected OAuth bearer token: %s",
+								ctx.status);
+		goto cleanup;
+	}
+
+	success = true;
+
+cleanup:
+	free(ctx.status);
+	free(ctx.scope);
+	free(ctx.discovery_uri);
+
+	return success;
+}
+
+/*
+ * Callback implementation of conn->async_auth() for a user-defined OAuth flow.
+ * Delegates the retrieval of the token to the application's 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_*
+ * statuses for use by PQconnectPoll().
+ */
+static PostgresPollingStatusType
+run_user_oauth_flow(PGconn *conn)
+{
+	fe_oauth_state *state = conn->sasl_state;
+	PGoauthBearerRequest *request = state->async_ctx;
+	PostgresPollingStatusType status;
+
+	if (!request->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);
+	if (status == PGRES_POLLING_FAILED)
+	{
+		libpq_append_conn_error(conn, "user-defined OAuth flow failed");
+		return status;
+	}
+	else if (status == PGRES_POLLING_OK)
+	{
+		/*
+		 * 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.)
+		 */
+		if (!request->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);
+		if (!conn->oauth_token)
+		{
+			libpq_append_conn_error(conn, "out of memory");
+			return PGRES_POLLING_FAILED;
+		}
+
+		return PGRES_POLLING_OK;
+	}
+
+	/* The hook wants the client to poll the altsock. Make sure it set one. */
+	if (conn->altsock == PGINVALID_SOCKET)
+	{
+		libpq_append_conn_error(conn,
+								"user-defined OAuth flow did not provide a socket for polling");
+		return PGRES_POLLING_FAILED;
+	}
+
+	return status;
+}
+
+/*
+ * Cleanup callback for the async user flow. Delegates most of its job to the
+ * user-provided cleanup implementation, then disconnects the altsock.
+ */
+static void
+cleanup_user_oauth_flow(PGconn *conn)
+{
+	fe_oauth_state *state = conn->sasl_state;
+	PGoauthBearerRequest *request = state->async_ctx;
+
+	Assert(request);
+
+	if (request->cleanup)
+		request->cleanup(conn, request);
+	conn->altsock = PGINVALID_SOCKET;
+
+	free(request);
+	state->async_ctx = NULL;
+}
+
+/*
+ * Chooses an OAuth client flow for the connection, which will retrieve a Bearer
+ * 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
+ * asynchronous callbacks which will be managed by run_user_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.)
+ *
+ * If neither a custom handler nor the builtin flow is available, the connection
+ * fails here.
+ */
+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,
+	};
+
+	Assert(request.openid_configuration);
+
+	/* The client may have overridden the OAuth flow. */
+	res = PQauthDataHook(PQAUTHDATA_OAUTH_BEARER_TOKEN, conn, &request);
+	if (res > 0)
+	{
+		PGoauthBearerRequest *request_copy;
+
+		if (request.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);
+			if (!conn->oauth_token)
+			{
+				libpq_append_conn_error(conn, "out of memory");
+				goto fail;
+			}
+
+			/* short-circuit */
+			if (request.cleanup)
+				request.cleanup(conn, &request);
+			return true;
+		}
+
+		request_copy = malloc(sizeof(*request_copy));
+		if (!request_copy)
+		{
+			libpq_append_conn_error(conn, "out of memory");
+			goto fail;
+		}
+
+		memcpy(request_copy, &request, sizeof(request));
+
+		conn->async_auth = run_user_oauth_flow;
+		conn->cleanup_async_auth = cleanup_user_oauth_flow;
+		state->async_ctx = request_copy;
+	}
+	else if (res < 0)
+	{
+		libpq_append_conn_error(conn, "user-defined OAuth flow failed");
+		goto fail;
+	}
+	else
+	{
+#if USE_LIBCURL
+		/* Hand off to our built-in OAuth flow. */
+		conn->async_auth = pg_fe_run_oauth_flow;
+		conn->cleanup_async_auth = pg_fe_cleanup_oauth_flow;
+
+#else
+		libpq_append_conn_error(conn, "no custom OAuth flows are available, and libpq was not built with libcurl support");
+		goto fail;
+
+#endif
+	}
+
+	return true;
+
+fail:
+	if (request.cleanup)
+		request.cleanup(conn, &request);
+	return false;
+}
+
+/*
+ * Fill in our issuer identifier and discovery URI, if possible, using the
+ * connection parameters. If conn->oauth_discovery_uri can't be populated in
+ * this function, it will be requested from the server.
+ */
+static bool
+setup_oauth_parameters(PGconn *conn)
+{
+	/*---
+	 * To talk to a server, we require the user to provide issuer and client
+	 * identifiers.
+	 *
+	 * While it's possible for an OAuth client to support multiple issuers, it
+	 * requires additional effort to make sure the flows in use are safe -- to
+	 * quote RFC 9207,
+	 *
+	 *     OAuth clients that interact with only one authorization server are
+	 *     not vulnerable to mix-up attacks. However, when such clients decide
+	 *     to add support for a second authorization server in the future, they
+	 *     become vulnerable and need to apply countermeasures to mix-up
+	 *     attacks.
+	 *
+	 * For now, we allow only one.
+	 */
+	if (!conn->oauth_issuer || !conn->oauth_client_id)
+	{
+		libpq_append_conn_error(conn,
+								"server requires OAuth authentication, but oauth_issuer and oauth_client_id are not both set");
+		return false;
+	}
+
+	/*
+	 * oauth_issuer is interpreted differently if it's a well-known discovery
+	 * URI rather than just an issuer identifier.
+	 */
+	if (strstr(conn->oauth_issuer, WK_PREFIX) != NULL)
+	{
+		/*
+		 * Convert the URI back to an issuer identifier. (This also performs
+		 * validation of the URI format.)
+		 */
+		conn->oauth_issuer_id = issuer_from_well_known_uri(conn,
+														   conn->oauth_issuer);
+		if (!conn->oauth_issuer_id)
+			return false;		/* error message already set */
+
+		conn->oauth_discovery_uri = strdup(conn->oauth_issuer);
+		if (!conn->oauth_discovery_uri)
+		{
+			libpq_append_conn_error(conn, "out of memory");
+			return false;
+		}
+	}
+	else
+	{
+		/*
+		 * Treat oauth_issuer as an issuer identifier. We'll ask the server
+		 * for the discovery URI.
+		 */
+		conn->oauth_issuer_id = strdup(conn->oauth_issuer);
+		if (!conn->oauth_issuer_id)
+		{
+			libpq_append_conn_error(conn, "out of memory");
+			return false;
+		}
+	}
+
+	return true;
+}
+
+/*
+ * Implements the OAUTHBEARER SASL exchange (RFC 7628, Sec. 3.2).
+ *
+ * If the necessary OAuth parameters are set up on the connection, this will run
+ * the client flow asynchronously and present the resulting token to the server.
+ * Otherwise, an empty discovery response will be sent and any parameters sent
+ * back by the server will be stored for a second attempt.
+ *
+ * For a full description of the API, see libpq/sasl.h.
+ */
+static SASLStatus
+oauth_exchange(void *opaq, bool final,
+			   char *input, int inputlen,
+			   char **output, int *outputlen)
+{
+	fe_oauth_state *state = opaq;
+	PGconn	   *conn = state->conn;
+	bool		discover = false;
+
+	*output = NULL;
+	*outputlen = 0;
+
+	switch (state->step)
+	{
+		case FE_OAUTH_INIT:
+			/* We begin in the initial response phase. */
+			Assert(inputlen == -1);
+
+			if (!setup_oauth_parameters(conn))
+				return SASL_FAILED;
+
+			if (conn->oauth_token)
+			{
+				/*
+				 * A previous connection already fetched the token; we'll use
+				 * it below.
+				 */
+			}
+			else if (conn->oauth_discovery_uri)
+			{
+				/*
+				 * We don't have a token, but we have a discovery URI already
+				 * stored. Decide whether we're using a user-provided OAuth
+				 * flow or the one we have built in.
+				 */
+				if (!setup_token_request(conn, state))
+					return SASL_FAILED;
+
+				if (conn->oauth_token)
+				{
+					/*
+					 * A really smart user implementation may have already
+					 * given us the token (e.g. if there was an unexpired copy
+					 * already cached), and we can use it immediately.
+					 */
+				}
+				else
+				{
+					/*
+					 * Otherwise, we'll have to hand the connection over to
+					 * our OAuth implementation.
+					 *
+					 * This could take a while, since it generally involves a
+					 * user in the loop. To avoid consuming the server's
+					 * authentication timeout, we'll continue this handshake
+					 * to the end, so that the server can close its side of
+					 * the connection. We'll open a second connection later
+					 * once we've retrieved a token.
+					 */
+					discover = true;
+				}
+			}
+			else
+			{
+				/*
+				 * If we don't have a token, and we don't have a discovery URI
+				 * to be able to request a token, we ask the server for one
+				 * explicitly.
+				 */
+				discover = true;
+			}
+
+			/*
+			 * Generate an initial response. This either contains a token, if
+			 * we have one, or an empty discovery response which is doomed to
+			 * fail.
+			 */
+			*output = client_initial_response(conn, discover);
+			if (!*output)
+				return SASL_FAILED;
+
+			*outputlen = strlen(*output);
+			state->step = FE_OAUTH_BEARER_SENT;
+
+			if (conn->oauth_token)
+			{
+				/*
+				 * For the purposes of require_auth, our side of
+				 * authentication is done at this point; the server will
+				 * either accept the connection or send an error. Unlike
+				 * SCRAM, there is no additional server data to check upon
+				 * success.
+				 */
+				conn->client_finished_auth = true;
+			}
+
+			return SASL_CONTINUE;
+
+		case FE_OAUTH_BEARER_SENT:
+			if (final)
+			{
+				/*
+				 * OAUTHBEARER does not make use of additional data with a
+				 * successful SASL exchange, so we shouldn't get an
+				 * AuthenticationSASLFinal message.
+				 */
+				libpq_append_conn_error(conn,
+										"server sent unexpected additional OAuth data");
+				return SASL_FAILED;
+			}
+
+			/*
+			 * An error message was sent by the server. Respond with the
+			 * required dummy message (RFC 7628, sec. 3.2.3).
+			 */
+			*output = strdup(kvsep);
+			if (unlikely(!*output))
+			{
+				libpq_append_conn_error(conn, "out of memory");
+				return SASL_FAILED;
+			}
+			*outputlen = strlen(*output);	/* == 1 */
+
+			/* Grab the settings from discovery. */
+			if (!handle_oauth_sasl_error(conn, input, inputlen))
+				return SASL_FAILED;
+
+			if (conn->oauth_token)
+			{
+				/*
+				 * The server rejected our token. Continue onwards towards the
+				 * expected FATAL message, but mark our state to catch any
+				 * unexpected "success" from the server.
+				 */
+				state->step = FE_OAUTH_SERVER_ERROR;
+				return SASL_CONTINUE;
+			}
+
+			if (!conn->async_auth)
+			{
+				/*
+				 * No OAuth flow is set up yet. Did we get enough information
+				 * from the server to create one?
+				 */
+				if (!conn->oauth_discovery_uri)
+				{
+					libpq_append_conn_error(conn,
+											"server requires OAuth authentication, but no discovery metadata was provided");
+					return SASL_FAILED;
+				}
+
+				/* Yes. Set up the flow now. */
+				if (!setup_token_request(conn, state))
+					return SASL_FAILED;
+
+				if (conn->oauth_token)
+				{
+					/*
+					 * A token was available in a custom flow's cache. Skip
+					 * the asynchronous processing.
+					 */
+					goto reconnect;
+				}
+			}
+
+			/*
+			 * Time to retrieve a token. This involves a number of HTTP
+			 * connections and timed waits, so we escape the synchronous auth
+			 * processing and tell PQconnectPoll to transfer control to our
+			 * async implementation.
+			 */
+			Assert(conn->async_auth);	/* should have been set already */
+			state->step = FE_OAUTH_REQUESTING_TOKEN;
+			return SASL_ASYNC;
+
+		case FE_OAUTH_REQUESTING_TOKEN:
+
+			/*
+			 * We've returned successfully from token retrieval. Double-check
+			 * that we have what we need for the next connection.
+			 */
+			if (!conn->oauth_token)
+			{
+				Assert(false);	/* should have failed before this point! */
+				libpq_append_conn_error(conn,
+										"internal error: OAuth flow did not set a token");
+				return SASL_FAILED;
+			}
+
+			goto reconnect;
+
+		case FE_OAUTH_SERVER_ERROR:
+
+			/*
+			 * After an error, the server should send an error response to
+			 * fail the SASL handshake, which is handled in higher layers.
+			 *
+			 * If we get here, the server either sent *another* challenge
+			 * which isn't defined in the RFC, or completed the handshake
+			 * successfully after telling us it was going to fail. Neither is
+			 * acceptable.
+			 */
+			libpq_append_conn_error(conn,
+									"server sent additional OAuth data after error");
+			return SASL_FAILED;
+
+		default:
+			libpq_append_conn_error(conn, "invalid OAuth exchange state");
+			break;
+	}
+
+	Assert(false);				/* should never get here */
+	return SASL_FAILED;
+
+reconnect:
+
+	/*
+	 * Despite being a failure from the point of view of SASL, we have enough
+	 * information to restart with a new connection.
+	 */
+	libpq_append_conn_error(conn, "retrying connection with new bearer token");
+	conn->oauth_want_retry = true;
+	return SASL_FAILED;
+}
+
+static bool
+oauth_channel_bound(void *opaq)
+{
+	/* This mechanism does not support channel binding. */
+	return false;
+}
+
+/*
+ * Fully clears out any stored OAuth token. This is done proactively upon
+ * successful connection as well as during pqClosePGconn().
+ */
+void
+pqClearOAuthToken(PGconn *conn)
+{
+	if (!conn->oauth_token)
+		return;
+
+	explicit_bzero(conn->oauth_token, strlen(conn->oauth_token));
+	free(conn->oauth_token);
+	conn->oauth_token = NULL;
+}
+
+/*
+ * Returns true if the PGOAUTHDEBUG=UNSAFE flag is set in the environment.
+ */
+bool
+oauth_unsafe_debugging_enabled(void)
+{
+	const char *env = getenv("PGOAUTHDEBUG");
+
+	return (env && strcmp(env, "UNSAFE") == 0);
+}
diff --git a/src/interfaces/libpq/fe-auth-oauth.h b/src/interfaces/libpq/fe-auth-oauth.h
new file mode 100644
index 00000000000..32598721686
--- /dev/null
+++ b/src/interfaces/libpq/fe-auth-oauth.h
@@ -0,0 +1,45 @@
+/*-------------------------------------------------------------------------
+ *
+ * fe-auth-oauth.h
+ *
+ *	  Definitions for OAuth authentication implementations
+ *
+ * Portions Copyright (c) 2024, PostgreSQL Global Development Group
+ *
+ * src/interfaces/libpq/fe-auth-oauth.h
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#ifndef FE_AUTH_OAUTH_H
+#define FE_AUTH_OAUTH_H
+
+#include "libpq-fe.h"
+#include "libpq-int.h"
+
+
+enum fe_oauth_step
+{
+	FE_OAUTH_INIT,
+	FE_OAUTH_BEARER_SENT,
+	FE_OAUTH_REQUESTING_TOKEN,
+	FE_OAUTH_SERVER_ERROR,
+};
+
+typedef struct
+{
+	enum fe_oauth_step step;
+
+	PGconn	   *conn;
+	void	   *async_ctx;
+} fe_oauth_state;
+
+extern PostgresPollingStatusType pg_fe_run_oauth_flow(PGconn *conn);
+extern void pg_fe_cleanup_oauth_flow(PGconn *conn);
+extern void pqClearOAuthToken(PGconn *conn);
+extern bool oauth_unsafe_debugging_enabled(void);
+
+/* Mechanisms in fe-auth-oauth.c */
+extern const pg_fe_sasl_mech pg_oauth_mech;
+
+#endif							/* FE_AUTH_OAUTH_H */
diff --git a/src/interfaces/libpq/fe-auth.c b/src/interfaces/libpq/fe-auth.c
index 761ee8f88f7..ec7a9236044 100644
--- a/src/interfaces/libpq/fe-auth.c
+++ b/src/interfaces/libpq/fe-auth.c
@@ -40,9 +40,11 @@
 #endif
 
 #include "common/md5.h"
+#include "common/oauth-common.h"
 #include "common/scram-common.h"
 #include "fe-auth.h"
 #include "fe-auth-sasl.h"
+#include "fe-auth-oauth.h"
 #include "libpq-fe.h"
 
 #ifdef ENABLE_GSS
@@ -535,6 +537,13 @@ pg_SASL_init(PGconn *conn, int payloadlen, bool *async)
 			conn->sasl = &pg_scram_mech;
 			conn->password_needed = true;
 		}
+		else if (strcmp(mechanism_buf.data, OAUTHBEARER_NAME) == 0 &&
+				 !selected_mechanism)
+		{
+			selected_mechanism = OAUTHBEARER_NAME;
+			conn->sasl = &pg_oauth_mech;
+			conn->password_needed = false;
+		}
 	}
 
 	if (!selected_mechanism)
@@ -559,13 +568,6 @@ pg_SASL_init(PGconn *conn, int payloadlen, bool *async)
 
 		if (!allowed)
 		{
-			/*
-			 * TODO: this is dead code until a second SASL mechanism is added;
-			 * the connection can't have proceeded past check_expected_areq()
-			 * if no SASL methods are allowed.
-			 */
-			Assert(false);
-
 			libpq_append_conn_error(conn, "authentication method requirement \"%s\" failed: server requested %s authentication",
 									conn->require_auth, selected_mechanism);
 			goto error;
@@ -1580,3 +1582,23 @@ PQchangePassword(PGconn *conn, const char *user, const char *passwd)
 		}
 	}
 }
+
+PQauthDataHook_type PQauthDataHook = PQdefaultAuthDataHook;
+
+PQauthDataHook_type
+PQgetAuthDataHook(void)
+{
+	return PQauthDataHook;
+}
+
+void
+PQsetAuthDataHook(PQauthDataHook_type hook)
+{
+	PQauthDataHook = hook ? hook : PQdefaultAuthDataHook;
+}
+
+int
+PQdefaultAuthDataHook(PGauthData type, PGconn *conn, void *data)
+{
+	return 0;					/* handle nothing */
+}
diff --git a/src/interfaces/libpq/fe-auth.h b/src/interfaces/libpq/fe-auth.h
index 1d4991f8996..de98e0d20c4 100644
--- a/src/interfaces/libpq/fe-auth.h
+++ b/src/interfaces/libpq/fe-auth.h
@@ -18,6 +18,9 @@
 #include "libpq-int.h"
 
 
+extern PQauthDataHook_type PQauthDataHook;
+
+
 /* Prototypes for functions in fe-auth.c */
 extern int	pg_fe_sendauth(AuthRequest areq, int payloadlen, PGconn *conn,
 						   bool *async);
diff --git a/src/interfaces/libpq/fe-connect.c b/src/interfaces/libpq/fe-connect.c
index 85d1ca2864f..d5051f5e820 100644
--- a/src/interfaces/libpq/fe-connect.c
+++ b/src/interfaces/libpq/fe-connect.c
@@ -28,6 +28,7 @@
 #include "common/scram-common.h"
 #include "common/string.h"
 #include "fe-auth.h"
+#include "fe-auth-oauth.h"
 #include "libpq-fe.h"
 #include "libpq-int.h"
 #include "mb/pg_wchar.h"
@@ -373,6 +374,23 @@ static const internalPQconninfoOption PQconninfoOptions[] = {
 	{"scram_server_key", NULL, NULL, NULL, "SCRAM-Server-Key", "D", SCRAM_MAX_KEY_LEN * 2,
 	offsetof(struct pg_conn, scram_server_key)},
 
+	/* OAuth v2 */
+	{"oauth_issuer", NULL, NULL, NULL,
+		"OAuth-Issuer", "", 40,
+	offsetof(struct pg_conn, oauth_issuer)},
+
+	{"oauth_client_id", NULL, NULL, NULL,
+		"OAuth-Client-ID", "", 40,
+	offsetof(struct pg_conn, oauth_client_id)},
+
+	{"oauth_client_secret", NULL, NULL, NULL,
+		"OAuth-Client-Secret", "", 40,
+	offsetof(struct pg_conn, oauth_client_secret)},
+
+	{"oauth_scope", NULL, NULL, NULL,
+		"OAuth-Scope", "", 15,
+	offsetof(struct pg_conn, oauth_scope)},
+
 	/* Terminating entry --- MUST BE LAST */
 	{NULL, NULL, NULL, NULL,
 	NULL, NULL, 0}
@@ -399,6 +417,7 @@ static const PQEnvironmentOption EnvironmentOptions[] =
 static const pg_fe_sasl_mech *supported_sasl_mechs[] =
 {
 	&pg_scram_mech,
+	&pg_oauth_mech,
 };
 #define SASL_MECHANISM_COUNT lengthof(supported_sasl_mechs)
 
@@ -655,6 +674,7 @@ pqDropServerData(PGconn *conn)
 	conn->write_failed = false;
 	free(conn->write_err_msg);
 	conn->write_err_msg = NULL;
+	conn->oauth_want_retry = false;
 
 	/*
 	 * Cancel connections need to retain their be_pid and be_key across
@@ -1144,7 +1164,7 @@ static inline void
 fill_allowed_sasl_mechs(PGconn *conn)
 {
 	/*---
-	 * We only support one mechanism at the moment, so rather than deal with a
+	 * We only support two mechanisms at the moment, so rather than deal with a
 	 * linked list, conn->allowed_sasl_mechs is an array of static length. We
 	 * rely on the compile-time assertion here to keep us honest.
 	 *
@@ -1519,6 +1539,10 @@ pqConnectOptions2(PGconn *conn)
 			{
 				mech = &pg_scram_mech;
 			}
+			else if (strcmp(method, "oauth") == 0)
+			{
+				mech = &pg_oauth_mech;
+			}
 
 			/*
 			 * Final group: meta-options.
@@ -4111,7 +4135,19 @@ keep_going:						/* We will come back to here until there is
 				conn->inStart = conn->inCursor;
 
 				if (res != STATUS_OK)
+				{
+					/*
+					 * OAuth connections may perform two-step discovery, where
+					 * the first connection is a dummy.
+					 */
+					if (conn->sasl == &pg_oauth_mech && conn->oauth_want_retry)
+					{
+						need_new_connection = true;
+						goto keep_going;
+					}
+
 					goto error_return;
+				}
 
 				/*
 				 * Just make sure that any data sent by pg_fe_sendauth is
@@ -4390,6 +4426,9 @@ keep_going:						/* We will come back to here until there is
 					}
 				}
 
+				/* Don't hold onto any OAuth tokens longer than necessary. */
+				pqClearOAuthToken(conn);
+
 				/*
 				 * For non cancel requests we can release the address list
 				 * now. For cancel requests we never actually resolve
@@ -5002,6 +5041,12 @@ freePGconn(PGconn *conn)
 	free(conn->load_balance_hosts);
 	free(conn->scram_client_key);
 	free(conn->scram_server_key);
+	free(conn->oauth_issuer);
+	free(conn->oauth_issuer_id);
+	free(conn->oauth_discovery_uri);
+	free(conn->oauth_client_id);
+	free(conn->oauth_client_secret);
+	free(conn->oauth_scope);
 	termPQExpBuffer(&conn->errorMessage);
 	termPQExpBuffer(&conn->workBuffer);
 
@@ -5155,6 +5200,7 @@ pqClosePGconn(PGconn *conn)
 	conn->asyncStatus = PGASYNC_IDLE;
 	conn->xactStatus = PQTRANS_IDLE;
 	conn->pipelineStatus = PQ_PIPELINE_OFF;
+	pqClearOAuthToken(conn);
 	pqClearAsyncResult(conn);	/* deallocate result */
 	pqClearConnErrorState(conn);
 
diff --git a/src/interfaces/libpq/libpq-fe.h b/src/interfaces/libpq/libpq-fe.h
index a3491faf0c3..b7399dee58e 100644
--- a/src/interfaces/libpq/libpq-fe.h
+++ b/src/interfaces/libpq/libpq-fe.h
@@ -59,6 +59,8 @@ extern "C"
 /* Features added in PostgreSQL v18: */
 /* Indicates presence of PQfullProtocolVersion */
 #define LIBPQ_HAS_FULL_PROTOCOL_VERSION 1
+/* Indicates presence of the PQAUTHDATA_PROMPT_OAUTH_DEVICE authdata hook */
+#define LIBPQ_HAS_PROMPT_OAUTH_DEVICE 1
 
 /*
  * Option flags for PQcopyResult
@@ -186,6 +188,13 @@ typedef enum
 	PQ_PIPELINE_ABORTED
 } PGpipelineStatus;
 
+typedef enum
+{
+	PQAUTHDATA_PROMPT_OAUTH_DEVICE, /* user must visit a device-authorization
+									 * URL */
+	PQAUTHDATA_OAUTH_BEARER_TOKEN,	/* server requests an OAuth Bearer token */
+} PGauthData;
+
 /* PGconn encapsulates a connection to the backend.
  * The contents of this struct are not supposed to be known to applications.
  */
@@ -720,10 +729,86 @@ extern int	PQenv2encoding(void);
 
 /* === in fe-auth.c === */
 
+typedef struct _PGpromptOAuthDevice
+{
+	const char *verification_uri;	/* verification URI to visit */
+	const char *user_code;		/* user code to enter */
+	const char *verification_uri_complete;	/* optional combination of URI and
+											 * code, or NULL */
+	int			expires_in;		/* seconds until user code expires */
+} PGpromptOAuthDevice;
+
+/* for PGoauthBearerRequest.async() */
+#ifdef _WIN32
+#define SOCKTYPE uintptr_t		/* avoids depending on winsock2.h for SOCKET */
+#else
+#define SOCKTYPE int
+#endif
+
+typedef struct _PGoauthBearerRequest
+{
+	/* Hook inputs (constant across all calls) */
+	const char *const openid_configuration; /* OIDC discovery URI */
+	const char *const scope;	/* required scope(s), or NULL */
+
+	/* Hook outputs */
+
+	/*---------
+	 * Callback implementing a custom asynchronous OAuth flow.
+	 *
+	 * The callback may return
+	 * - PGRES_POLLING_READING/WRITING, to indicate that a socket descriptor
+	 *   has been stored in *altsock and libpq should wait until it is
+	 *   readable or writable before calling back;
+	 * - PGRES_POLLING_OK, to indicate that the flow is complete and
+	 *   request->token has been set; or
+	 * - PGRES_POLLING_FAILED, to indicate that token retrieval has failed.
+	 *
+	 * This callback is optional. If the token can be obtained without
+	 * 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.
+	 */
+	PostgresPollingStatusType (*async) (PGconn *conn,
+										struct _PGoauthBearerRequest *request,
+										SOCKTYPE * altsock);
+
+	/*
+	 * Callback to clean up custom allocations. A hook implementation may use
+	 * this to free request->token and any resources in request->user.
+	 *
+	 * This is technically optional, but highly recommended, because there is
+	 * no other indication as to when it is safe to free the token.
+	 */
+	void		(*cleanup) (PGconn *conn, struct _PGoauthBearerRequest *request);
+
+	/*
+	 * The hook should set this to the Bearer token contents for the
+	 * connection, once the flow is completed.  The token contents must remain
+	 * available to libpq until the hook's cleanup callback is called.
+	 */
+	char	   *token;
+
+	/*
+	 * Hook-defined data. libpq will not modify this pointer across calls to
+	 * the async callback, so it can be used to keep track of
+	 * application-specific state. Resources allocated here should be freed by
+	 * the cleanup callback.
+	 */
+	void	   *user;
+} PGoauthBearerRequest;
+
+#undef SOCKTYPE
+
 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);
 
+typedef int (*PQauthDataHook_type) (PGauthData type, PGconn *conn, void *data);
+extern void PQsetAuthDataHook(PQauthDataHook_type hook);
+extern PQauthDataHook_type PQgetAuthDataHook(void);
+extern int	PQdefaultAuthDataHook(PGauthData type, PGconn *conn, void *data);
+
 /* === in encnames.c === */
 
 extern int	pg_char_to_encoding(const char *name);
diff --git a/src/interfaces/libpq/libpq-int.h b/src/interfaces/libpq/libpq-int.h
index 2546f9f8a50..f36f7f19d58 100644
--- a/src/interfaces/libpq/libpq-int.h
+++ b/src/interfaces/libpq/libpq-int.h
@@ -437,6 +437,17 @@ struct pg_conn
 								 * cancel request, instead of being a normal
 								 * connection that's used for queries */
 
+	/* OAuth v2 */
+	char	   *oauth_issuer;	/* token issuer/URL */
+	char	   *oauth_issuer_id;	/* token issuer identifier */
+	char	   *oauth_discovery_uri;	/* URI of the issuer's discovery
+										 * document */
+	char	   *oauth_client_id;	/* client identifier */
+	char	   *oauth_client_secret;	/* client secret */
+	char	   *oauth_scope;	/* access token scope */
+	char	   *oauth_token;	/* access token */
+	bool		oauth_want_retry;	/* should we retry on failure? */
+
 	/* Optional file to write trace info to */
 	FILE	   *Pfdebug;
 	int			traceFlags;
@@ -505,7 +516,7 @@ struct pg_conn
 								 * the server? */
 	uint32		allowed_auth_methods;	/* bitmask of acceptable AuthRequest
 										 * codes */
-	const pg_fe_sasl_mech *allowed_sasl_mechs[1];	/* and acceptable SASL
+	const pg_fe_sasl_mech *allowed_sasl_mechs[2];	/* and acceptable SASL
 													 * mechanisms */
 	bool		client_finished_auth;	/* have we finished our half of the
 										 * authentication exchange? */
diff --git a/src/interfaces/libpq/meson.build b/src/interfaces/libpq/meson.build
index dd64d291b3e..19f4a52a97a 100644
--- a/src/interfaces/libpq/meson.build
+++ b/src/interfaces/libpq/meson.build
@@ -1,6 +1,7 @@
 # Copyright (c) 2022-2025, PostgreSQL Global Development Group
 
 libpq_sources = files(
+  'fe-auth-oauth.c',
   'fe-auth-scram.c',
   'fe-auth.c',
   'fe-cancel.c',
@@ -37,6 +38,10 @@ if gssapi.found()
   )
 endif
 
+if libcurl.found()
+  libpq_sources += files('fe-auth-oauth-curl.c')
+endif
+
 export_file = custom_target('libpq.exports',
   kwargs: gen_export_kwargs,
 )
diff --git a/src/makefiles/meson.build b/src/makefiles/meson.build
index d49b2079a44..60e13d50235 100644
--- a/src/makefiles/meson.build
+++ b/src/makefiles/meson.build
@@ -229,6 +229,7 @@ pgxs_deps = {
   'gssapi': gssapi,
   'icu': icu,
   'ldap': ldap,
+  'libcurl': libcurl,
   'libxml': libxml,
   'libxslt': libxslt,
   'llvm': llvm,
diff --git a/src/test/authentication/t/001_password.pl b/src/test/authentication/t/001_password.pl
index 1357f806b6f..4ce22ccbdf2 100644
--- a/src/test/authentication/t/001_password.pl
+++ b/src/test/authentication/t/001_password.pl
@@ -404,11 +404,11 @@ $node->connect_fails(
 $node->connect_fails(
 	"user=scram_role require_auth=!scram-sha-256",
 	"SCRAM authentication forbidden, fails with SCRAM auth",
-	expected_stderr => qr/server requested SASL authentication/);
+	expected_stderr => qr/server requested SCRAM-SHA-256 authentication/);
 $node->connect_fails(
 	"user=scram_role require_auth=!password,!md5,!scram-sha-256",
 	"multiple authentication types forbidden, fails with SCRAM auth",
-	expected_stderr => qr/server requested SASL authentication/);
+	expected_stderr => qr/server requested SCRAM-SHA-256 authentication/);
 
 # Test that bad passwords are rejected.
 $ENV{"PGPASSWORD"} = 'badpass';
@@ -465,13 +465,13 @@ $node->connect_fails(
 	"user=scram_role require_auth=!scram-sha-256",
 	"password authentication forbidden, fails with SCRAM auth",
 	expected_stderr =>
-	  qr/authentication method requirement "!scram-sha-256" failed: server requested SASL authentication/
+	  qr/authentication method requirement "!scram-sha-256" failed: server requested SCRAM-SHA-256 authentication/
 );
 $node->connect_fails(
 	"user=scram_role require_auth=!password,!md5,!scram-sha-256",
 	"multiple authentication types forbidden, fails with SCRAM auth",
 	expected_stderr =>
-	  qr/authentication method requirement "!password,!md5,!scram-sha-256" failed: server requested SASL authentication/
+	  qr/authentication method requirement "!password,!md5,!scram-sha-256" failed: server requested SCRAM-SHA-256 authentication/
 );
 
 # Test SYSTEM_USER <> NULL with parallel workers.
diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile
index c0d3cf0e14b..bdfd5f1f8de 100644
--- a/src/test/modules/Makefile
+++ b/src/test/modules/Makefile
@@ -11,6 +11,7 @@ SUBDIRS = \
 		  dummy_index_am \
 		  dummy_seclabel \
 		  libpq_pipeline \
+		  oauth_validator \
 		  plsample \
 		  spgist_name_ops \
 		  test_bloomfilter \
diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build
index 4f544a042d4..0c2ccc75a63 100644
--- a/src/test/modules/meson.build
+++ b/src/test/modules/meson.build
@@ -9,6 +9,7 @@ subdir('gin')
 subdir('injection_points')
 subdir('ldap_password_func')
 subdir('libpq_pipeline')
+subdir('oauth_validator')
 subdir('plsample')
 subdir('spgist_name_ops')
 subdir('ssl_passphrase_callback')
diff --git a/src/test/modules/oauth_validator/.gitignore b/src/test/modules/oauth_validator/.gitignore
new file mode 100644
index 00000000000..5dcb3ff9723
--- /dev/null
+++ b/src/test/modules/oauth_validator/.gitignore
@@ -0,0 +1,4 @@
+# Generated subdirectories
+/log/
+/results/
+/tmp_check/
diff --git a/src/test/modules/oauth_validator/Makefile b/src/test/modules/oauth_validator/Makefile
new file mode 100644
index 00000000000..f297ed5c968
--- /dev/null
+++ b/src/test/modules/oauth_validator/Makefile
@@ -0,0 +1,40 @@
+#-------------------------------------------------------------------------
+#
+# Makefile for src/test/modules/oauth_validator
+#
+# Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/test/modules/oauth_validator/Makefile
+#
+#-------------------------------------------------------------------------
+
+MODULES = validator fail_validator
+PGFILEDESC = "validator - test OAuth validator module"
+
+PROGRAM = oauth_hook_client
+PGAPPICON = win32
+OBJS = $(WIN32RES) oauth_hook_client.o
+
+PG_CPPFLAGS = -I$(libpq_srcdir)
+PG_LIBS_INTERNAL += $(libpq_pgport)
+
+NO_INSTALLCHECK = 1
+
+TAP_TESTS = 1
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = src/test/modules/oauth_validator
+top_builddir = ../../../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+
+export PYTHON
+export with_libcurl
+export with_python
+
+endif
diff --git a/src/test/modules/oauth_validator/README b/src/test/modules/oauth_validator/README
new file mode 100644
index 00000000000..138a8104622
--- /dev/null
+++ b/src/test/modules/oauth_validator/README
@@ -0,0 +1,13 @@
+Test programs and libraries for OAuth
+-------------------------------------
+
+This folder contains tests for the client- and server-side OAuth
+implementations. Most tests are run end-to-end to test both simultaneously. The
+tests in t/001_server use a mock OAuth authorization server, implemented jointly
+by t/OAuth/Server.pm and t/oauth_server.py, to run the libpq Device
+Authorization flow. The tests in t/002_client exercise custom OAuth flows and
+don't need an authorization server.
+
+Tests in this folder generally require 'oauth' to be present in PG_TEST_EXTRA,
+since localhost HTTP servers will be started. A Python installation is required
+to run the mock authorization server.
diff --git a/src/test/modules/oauth_validator/fail_validator.c b/src/test/modules/oauth_validator/fail_validator.c
new file mode 100644
index 00000000000..f77a3e115c6
--- /dev/null
+++ b/src/test/modules/oauth_validator/fail_validator.c
@@ -0,0 +1,42 @@
+/*-------------------------------------------------------------------------
+ *
+ * fail_validator.c
+ *	  Test module for serverside OAuth token validation callbacks, which always
+ *	  fails
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/test/modules/oauth_validator/fail_validator.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "fmgr.h"
+#include "libpq/oauth.h"
+
+PG_MODULE_MAGIC;
+
+static ValidatorModuleResult *fail_token(ValidatorModuleState *state,
+										 const char *token,
+										 const char *role);
+
+/* Callback implementations (we only need the main one) */
+static const OAuthValidatorCallbacks validator_callbacks = {
+	.validate_cb = fail_token,
+};
+
+const OAuthValidatorCallbacks *
+_PG_oauth_validator_module_init(void)
+{
+	return &validator_callbacks;
+}
+
+static ValidatorModuleResult *
+fail_token(ValidatorModuleState *state, const char *token, const char *role)
+{
+	elog(FATAL, "fail_validator: sentinel error");
+	pg_unreachable();
+}
diff --git a/src/test/modules/oauth_validator/meson.build b/src/test/modules/oauth_validator/meson.build
new file mode 100644
index 00000000000..4b78c90557c
--- /dev/null
+++ b/src/test/modules/oauth_validator/meson.build
@@ -0,0 +1,69 @@
+# Copyright (c) 2024, PostgreSQL Global Development Group
+
+validator_sources = files(
+  'validator.c',
+)
+
+if host_system == 'windows'
+  validator_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'validator',
+    '--FILEDESC', 'validator - test OAuth validator module',])
+endif
+
+validator = shared_module('validator',
+  validator_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += validator
+
+fail_validator_sources = files(
+  'fail_validator.c',
+)
+
+if host_system == 'windows'
+  fail_validator_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'fail_validator',
+    '--FILEDESC', 'fail_validator - failing OAuth validator module',])
+endif
+
+fail_validator = shared_module('fail_validator',
+  fail_validator_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += fail_validator
+
+oauth_hook_client_sources = files(
+  'oauth_hook_client.c',
+)
+
+if host_system == 'windows'
+  oauth_hook_client_sources += rc_bin_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'oauth_hook_client',
+    '--FILEDESC', 'oauth_hook_client - test program for libpq OAuth hooks',])
+endif
+
+oauth_hook_client = executable('oauth_hook_client',
+  oauth_hook_client_sources,
+  dependencies: [frontend_code, libpq],
+  kwargs: default_bin_args + {
+    'install': false,
+  },
+)
+testprep_targets += oauth_hook_client
+
+tests += {
+  'name': 'oauth_validator',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'tap': {
+    'tests': [
+      't/001_server.pl',
+      't/002_client.pl',
+    ],
+    'env': {
+      'PYTHON': python.path(),
+      'with_libcurl': libcurl.found() ? 'yes' : 'no',
+      'with_python': 'yes',
+    },
+  },
+}
diff --git a/src/test/modules/oauth_validator/oauth_hook_client.c b/src/test/modules/oauth_validator/oauth_hook_client.c
new file mode 100644
index 00000000000..fc003030ff8
--- /dev/null
+++ b/src/test/modules/oauth_validator/oauth_hook_client.c
@@ -0,0 +1,293 @@
+/*-------------------------------------------------------------------------
+ *
+ * oauth_hook_client.c
+ *		Test driver for t/002_client.pl, which verifies OAuth hook
+ *		functionality in libpq.
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ *
+ * IDENTIFICATION
+ *		src/test/modules/oauth_validator/oauth_hook_client.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres_fe.h"
+
+#include <sys/socket.h>
+
+#include "getopt_long.h"
+#include "libpq-fe.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(" --expected-scope SCOPE	fail if received scopes do not match SCOPE\n");
+	printf(" --expected-uri URI		fail if received configuration link does not match URI\n");
+	printf(" --misbehave=MODE		have the hook fail required postconditions\n"
+		   "						(MODEs: no-hook, fail-async, no-token, no-socket)\n");
+	printf(" --no-hook				don't install OAuth hooks\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(" --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_scope = NULL;
+static const char *misbehave_mode = NULL;
+static char *token = NULL;
+
+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},
+		{0}
+	};
+
+	const char *conninfo;
+	PGconn	   *conn;
+	int			c;
+
+	while ((c = getopt_long(argc, argv, "h", long_options, NULL)) != -1)
+	{
+		switch (c)
+		{
+			case 'h':
+				usage(argv);
+				return 0;
+
+			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;
+
+			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);
+
+	/* Connect. (All the actual work is in the hook.) */
+	if (stress_async)
+	{
+		/*
+		 * Perform an asynchronous connection, busy-looping on PQconnectPoll()
+		 * without actually waiting on socket events. This stresses code paths
+		 * that rely on asynchronous work to be done before continuing with
+		 * the next step in the flow.
+		 */
+		PostgresPollingStatusType res;
+
+		conn = PQconnectStart(conninfo);
+
+		do
+		{
+			res = PQconnectPoll(conn);
+		} while (res != PGRES_POLLING_FAILED && res != PGRES_POLLING_OK);
+	}
+	else
+	{
+		/* Perform a standard synchronous connection. */
+		conn = PQconnectdb(conninfo);
+	}
+
+	if (PQstatus(conn) != CONNECTION_OK)
+	{
+		fprintf(stderr, "connection to database failed: %s\n",
+				PQerrorMessage(conn));
+		PQfinish(conn);
+		return 1;
+	}
+
+	printf("connection succeeded\n");
+	PQfinish(conn);
+	return 0;
+}
+
+/*
+ * PQauthDataHook implementation. Replaces the default client flow by handling
+ * PQAUTHDATA_OAUTH_BEARER_TOKEN.
+ */
+static int
+handle_auth_data(PGauthData type, PGconn *conn, void *data)
+{
+	PGoauthBearerRequest *req = data;
+
+	if (no_hook || (type != PQAUTHDATA_OAUTH_BEARER_TOKEN))
+		return 0;
+
+	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;
+		}
+	}
+
+	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". */
+		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/001_server.pl b/src/test/modules/oauth_validator/t/001_server.pl
new file mode 100644
index 00000000000..f0b918390fd
--- /dev/null
+++ b/src/test/modules/oauth_validator/t/001_server.pl
@@ -0,0 +1,566 @@
+
+#
+# Tests the libpq builtin OAuth flow, as well as server-side HBA and validator
+# setup.
+#
+# Copyright (c) 2021-2024, PostgreSQL Global Development Group
+#
+
+use strict;
+use warnings FATAL => 'all';
+
+use JSON::PP     qw(encode_json);
+use MIME::Base64 qw(encode_base64);
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+use FindBin;
+use lib $FindBin::RealBin;
+
+use OAuth::Server;
+
+if (!$ENV{PG_TEST_EXTRA} || $ENV{PG_TEST_EXTRA} !~ /\boauth\b/)
+{
+	plan skip_all =>
+	  'Potentially unsafe test oauth not enabled in PG_TEST_EXTRA';
+}
+
+if ($ENV{with_libcurl} ne 'yes')
+{
+	plan skip_all => 'client-side OAuth not supported by this build';
+}
+
+if ($ENV{with_python} ne 'yes')
+{
+	plan skip_all => 'OAuth tests require --with-python to run';
+}
+
+my $node = PostgreSQL::Test::Cluster->new('primary');
+$node->init;
+$node->append_conf('postgresql.conf', "log_connections = on\n");
+$node->append_conf('postgresql.conf',
+	"oauth_validator_libraries = 'validator'\n");
+$node->start;
+
+$node->safe_psql('postgres', 'CREATE USER test;');
+$node->safe_psql('postgres', 'CREATE USER testalt;');
+$node->safe_psql('postgres', 'CREATE USER testparam;');
+
+# Save a background connection for later configuration changes.
+my $bgconn = $node->background_psql('postgres');
+
+my $webserver = OAuth::Server->new();
+$webserver->run();
+
+END
+{
+	my $exit_code = $?;
+
+	$webserver->stop() if defined $webserver;    # might have been SKIP'd
+
+	$? = $exit_code;
+}
+
+my $port = $webserver->port();
+my $issuer = "http://localhost:$port";
+
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test      oauth issuer="$issuer"       scope="openid postgres"
+local all testalt   oauth issuer="$issuer/.well-known/oauth-authorization-server/alternate" scope="openid postgres alt"
+local all testparam oauth issuer="$issuer/param" scope="openid postgres"
+});
+$node->reload;
+
+my $log_start = $node->wait_for_log(qr/reloading configuration files/);
+
+# Check pg_hba_file_rules() support.
+my $contents = $bgconn->query_safe(
+	qq(SELECT rule_number, auth_method, options
+		 FROM pg_hba_file_rules
+		 ORDER BY rule_number;));
+is( $contents,
+	qq{1|oauth|\{issuer=$issuer,"scope=openid postgres",validator=validator\}
+2|oauth|\{issuer=$issuer/.well-known/oauth-authorization-server/alternate,"scope=openid postgres alt",validator=validator\}
+3|oauth|\{issuer=$issuer/param,"scope=openid postgres",validator=validator\}},
+	"pg_hba_file_rules recreates OAuth HBA settings");
+
+# To test against HTTP rather than HTTPS, we need to enable PGOAUTHDEBUG. But
+# first, check to make sure the client refuses such connections by default.
+$node->connect_fails(
+	"user=test dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635",
+	"HTTPS is required without debug mode",
+	expected_stderr =>
+	  qr@OAuth discovery URI "\Q$issuer\E/.well-known/openid-configuration" must use HTTPS@
+);
+
+$ENV{PGOAUTHDEBUG} = "UNSAFE";
+
+my $user = "test";
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635",
+	"connect as test",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@,
+	log_like => [
+		qr/oauth_validator: token="9243959234", role="$user"/,
+		qr/oauth_validator: issuer="\Q$issuer\E", scope="openid postgres"/,
+		qr/connection authenticated: identity="test" method=oauth/,
+		qr/connection authorized/,
+	]);
+
+# The /alternate issuer uses slightly different parameters, along with an
+# OAuth-style discovery document.
+$user = "testalt";
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer/alternate oauth_client_id=f02c6361-0636",
+	"connect as testalt",
+	expected_stderr =>
+	  qr@Visit https://example\.org/ and enter the code: postgresuser@,
+	log_like => [
+		qr/oauth_validator: token="9243959234-alt", role="$user"/,
+		qr|oauth_validator: issuer="\Q$issuer/.well-known/oauth-authorization-server/alternate\E", scope="openid postgres alt"|,
+		qr/connection authenticated: identity="testalt" method=oauth/,
+		qr/connection authorized/,
+	]);
+
+# The issuer linked by the server must match the client's oauth_issuer setting.
+$node->connect_fails(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0636",
+	"oauth_issuer must match discovery",
+	expected_stderr =>
+	  qr@server's discovery document at \Q$issuer/.well-known/oauth-authorization-server/alternate\E \(issuer "\Q$issuer/alternate\E"\) is incompatible with oauth_issuer \(\Q$issuer\E\)@
+);
+
+# Test require_auth settings against OAUTHBEARER.
+my @cases = (
+	{ require_auth => "oauth" },
+	{ require_auth => "oauth,scram-sha-256" },
+	{ require_auth => "password,oauth" },
+	{ require_auth => "none,oauth" },
+	{ require_auth => "!scram-sha-256" },
+	{ require_auth => "!none" },
+
+	{
+		require_auth => "!oauth",
+		failure => qr/server requested OAUTHBEARER authentication/
+	},
+	{
+		require_auth => "scram-sha-256",
+		failure => qr/server requested OAUTHBEARER authentication/
+	},
+	{
+		require_auth => "!password,!oauth",
+		failure => qr/server requested OAUTHBEARER authentication/
+	},
+	{
+		require_auth => "none",
+		failure => qr/server requested SASL authentication/
+	},
+	{
+		require_auth => "!oauth,!scram-sha-256",
+		failure => qr/server requested SASL authentication/
+	});
+
+$user = "test";
+foreach my $c (@cases)
+{
+	my $connstr =
+	  "user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635 require_auth=$c->{'require_auth'}";
+
+	if (defined $c->{'failure'})
+	{
+		$node->connect_fails(
+			$connstr,
+			"require_auth=$c->{'require_auth'} fails",
+			expected_stderr => $c->{'failure'});
+	}
+	else
+	{
+		$node->connect_ok(
+			$connstr,
+			"require_auth=$c->{'require_auth'} succeeds",
+			expected_stderr =>
+			  qr@Visit https://example\.com/ and enter the code: postgresuser@
+		);
+	}
+}
+
+# Make sure the client_id and secret are correctly encoded. $vschars contains
+# every allowed character for a client_id/_secret (the "VSCHAR" class).
+# $vschars_esc is additionally backslash-escaped for inclusion in a
+# single-quoted connection string.
+my $vschars =
+  " !\"#\$%&'()*+,-./0123456789:;<=>?\@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~";
+my $vschars_esc =
+  " !\"#\$%&\\'()*+,-./0123456789:;<=>?\@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\\\]^_`abcdefghijklmnopqrstuvwxyz{|}~";
+
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id='$vschars_esc'",
+	"escapable characters: client_id",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id='$vschars_esc' oauth_client_secret='$vschars_esc'",
+	"escapable characters: client_id and secret",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+#
+# Further tests rely on support for specific behaviors in oauth_server.py. To
+# trigger these behaviors, we ask for the special issuer .../param (which is set
+# up in HBA for the testparam user) and encode magic instructions into the
+# oauth_client_id.
+#
+
+my $common_connstr =
+  "user=testparam dbname=postgres oauth_issuer=$issuer/param ";
+my $base_connstr = $common_connstr;
+
+sub connstr
+{
+	my (%params) = @_;
+
+	my $json = encode_json(\%params);
+	my $encoded = encode_base64($json, "");
+
+	return "$base_connstr oauth_client_id=$encoded";
+}
+
+# Make sure the param system works end-to-end first.
+$node->connect_ok(
+	connstr(),
+	"connect to /param",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$node->connect_ok(
+	connstr(stage => 'token', retries => 1),
+	"token retry",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(stage => 'token', retries => 2),
+	"token retry (twice)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(stage => 'all', retries => 1, interval => 2),
+	"token retry (two second interval)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(stage => 'all', retries => 1, interval => JSON::PP::null),
+	"token retry (default interval)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$node->connect_ok(
+	connstr(stage => 'all', content_type => 'application/json;charset=utf-8'),
+	"content type with charset",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(
+		stage => 'all',
+		content_type => "application/json \t;\t charset=utf-8"),
+	"content type with charset (whitespace)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(stage => 'device', uri_spelling => "verification_url"),
+	"alternative spelling of verification_uri",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$node->connect_fails(
+	connstr(stage => 'device', huge_response => JSON::PP::true),
+	"bad device authz response: overlarge JSON",
+	expected_stderr =>
+	  qr/failed to obtain device authorization: response is too large/);
+$node->connect_fails(
+	connstr(stage => 'token', huge_response => JSON::PP::true),
+	"bad token response: overlarge JSON",
+	expected_stderr =>
+	  qr/failed to obtain access token: response is too large/);
+
+$node->connect_fails(
+	connstr(stage => 'device', content_type => 'text/plain'),
+	"bad device authz response: wrong content type",
+	expected_stderr =>
+	  qr/failed to parse device authorization: unexpected content type/);
+$node->connect_fails(
+	connstr(stage => 'token', content_type => 'text/plain'),
+	"bad token response: wrong content type",
+	expected_stderr =>
+	  qr/failed to parse access token response: unexpected content type/);
+$node->connect_fails(
+	connstr(stage => 'token', content_type => 'application/jsonx'),
+	"bad token response: wrong content type (correct prefix)",
+	expected_stderr =>
+	  qr/failed to parse access token response: unexpected content type/);
+
+$node->connect_fails(
+	connstr(
+		stage => 'all',
+		interval => ~0,
+		retries => 1,
+		retry_code => "slow_down"),
+	"bad token response: server overflows the device authz interval",
+	expected_stderr =>
+	  qr/failed to obtain access token: slow_down interval overflow/);
+
+$node->connect_fails(
+	connstr(stage => 'token', error_code => "invalid_grant"),
+	"bad token response: invalid_grant, no description",
+	expected_stderr => qr/failed to obtain access token: \(invalid_grant\)/);
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_grant",
+		error_desc => "grant expired"),
+	"bad token response: expired grant",
+	expected_stderr =>
+	  qr/failed to obtain access token: grant expired \(invalid_grant\)/);
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_client",
+		error_status => 401),
+	"bad token response: client authentication failure, default description",
+	expected_stderr =>
+	  qr/failed to obtain access token: provider requires client authentication, and no oauth_client_secret is set \(invalid_client\)/
+);
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_client",
+		error_status => 401,
+		error_desc => "authn failure"),
+	"bad token response: client authentication failure, provided description",
+	expected_stderr =>
+	  qr/failed to obtain access token: authn failure \(invalid_client\)/);
+
+$node->connect_fails(
+	connstr(stage => 'token', token => ""),
+	"server rejects access: empty token",
+	expected_stderr => qr/bearer authentication failed/);
+$node->connect_fails(
+	connstr(stage => 'token', token => "****"),
+	"server rejects access: invalid token contents",
+	expected_stderr => qr/bearer authentication failed/);
+
+# Test behavior of the oauth_client_secret.
+$base_connstr = "$common_connstr oauth_client_secret=''";
+
+$node->connect_ok(
+	connstr(stage => 'all', expected_secret => ''),
+	"empty oauth_client_secret",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$base_connstr = "$common_connstr oauth_client_secret='$vschars_esc'";
+
+$node->connect_ok(
+	connstr(stage => 'all', expected_secret => $vschars),
+	"nonempty oauth_client_secret",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_client",
+		error_status => 401),
+	"bad token response: client authentication failure, default description with oauth_client_secret",
+	expected_stderr =>
+	  qr/failed to obtain access token: provider rejected the oauth_client_secret \(invalid_client\)/
+);
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_client",
+		error_status => 401,
+		error_desc => "mutual TLS required for client"),
+	"bad token response: client authentication failure, provided description with oauth_client_secret",
+	expected_stderr =>
+	  qr/failed to obtain access token: mutual TLS required for client \(invalid_client\)/
+);
+
+# Stress test: make sure our builtin flow operates correctly even if the client
+# application isn't respecting PGRES_POLLING_READING/WRITING signals returned
+# from PQconnectPoll().
+$base_connstr =
+  "$common_connstr port=" . $node->port . " host=" . $node->host;
+my @cmd = (
+	"oauth_hook_client", "--no-hook", "--stress-async",
+	connstr(stage => 'all', retries => 1, interval => 1));
+
+note "running '" . join("' '", @cmd) . "'";
+my ($stdout, $stderr) = run_command(\@cmd);
+
+like($stdout, qr/connection succeeded/, "stress-async: stdout matches");
+unlike($stderr, qr/connection to database failed/, "stress-async: stderr matches");
+
+#
+# This section of tests reconfigures the validator module itself, rather than
+# the OAuth server.
+#
+
+# Searching the logs is easier if OAuth parameter discovery isn't cluttering
+# things up; hardcode the discovery URI. (Scope is hardcoded to empty to cover
+# that case as well.)
+$common_connstr =
+  "dbname=postgres oauth_issuer=$issuer/.well-known/openid-configuration oauth_scope='' oauth_client_id=f02c6361-0635";
+
+# Misbehaving validators must fail shut.
+$bgconn->query_safe("ALTER SYSTEM SET oauth_validator.authn_id TO ''");
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+$node->connect_fails(
+	"$common_connstr user=test",
+	"validator must set authn_id",
+	expected_stderr => qr/OAuth bearer authentication failed/,
+	log_like => [
+		qr/connection authenticated: identity=""/,
+		qr/DETAIL:\s+Validator provided no identity/,
+		qr/FATAL:\s+OAuth bearer authentication failed/,
+	]);
+
+# Even if a validator authenticates the user, if the token isn't considered
+# valid, the connection fails.
+$bgconn->query_safe(
+	"ALTER SYSTEM SET oauth_validator.authn_id TO 'test\@example.org'");
+$bgconn->query_safe(
+	"ALTER SYSTEM SET oauth_validator.authorize_tokens TO false");
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+$node->connect_fails(
+	"$common_connstr user=test",
+	"validator must authorize token explicitly",
+	expected_stderr => qr/OAuth bearer authentication failed/,
+	log_like => [
+		qr/connection authenticated: identity="test\@example\.org"/,
+		qr/DETAIL:\s+Validator failed to authorize the provided token/,
+		qr/FATAL:\s+OAuth bearer authentication failed/,
+	]);
+
+#
+# Test user mapping.
+#
+
+# Allow "[email protected]" to log in under the test role.
+unlink($node->data_dir . '/pg_ident.conf');
+$node->append_conf(
+	'pg_ident.conf', qq{
+oauthmap	user\@example.com	test
+});
+
+# test and testalt use the map; testparam uses ident delegation.
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test      oauth issuer="$issuer" scope="" map=oauthmap
+local all testalt   oauth issuer="$issuer" scope="" map=oauthmap
+local all testparam oauth issuer="$issuer" scope="" delegate_ident_mapping=1
+});
+
+# To start, have the validator use the role names as authn IDs.
+$bgconn->query_safe("ALTER SYSTEM RESET oauth_validator.authn_id");
+$bgconn->query_safe("ALTER SYSTEM RESET oauth_validator.authorize_tokens");
+
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+# The test and testalt roles should no longer map correctly.
+$node->connect_fails(
+	"$common_connstr user=test",
+	"mismatched username map (test)",
+	expected_stderr => qr/OAuth bearer authentication failed/);
+$node->connect_fails(
+	"$common_connstr user=testalt",
+	"mismatched username map (testalt)",
+	expected_stderr => qr/OAuth bearer authentication failed/);
+
+# Have the validator identify the end user as [email protected].
+$bgconn->query_safe(
+	"ALTER SYSTEM SET oauth_validator.authn_id TO 'user\@example.com'");
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+# Now the test role can be logged into. (testalt still can't be mapped.)
+$node->connect_ok(
+	"$common_connstr user=test",
+	"matched username map (test)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_fails(
+	"$common_connstr user=testalt",
+	"mismatched username map (testalt)",
+	expected_stderr => qr/OAuth bearer authentication failed/);
+
+# testparam ignores the map entirely.
+$node->connect_ok(
+	"$common_connstr user=testparam",
+	"delegated ident (testparam)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$bgconn->query_safe("ALTER SYSTEM RESET oauth_validator.authn_id");
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+#
+# Test multiple validators.
+#
+
+$node->append_conf('postgresql.conf',
+	"oauth_validator_libraries = 'validator, fail_validator'\n");
+
+# With multiple validators, every HBA line must explicitly declare one.
+my $result = $node->restart(fail_ok => 1);
+is($result, 0,
+	'restart fails without explicit validators in oauth HBA entries');
+
+$log_start = $node->wait_for_log(
+	qr/authentication method "oauth" requires argument "validator" to be set/,
+	$log_start);
+
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test    oauth validator=validator      issuer="$issuer"           scope="openid postgres"
+local all testalt oauth validator=fail_validator issuer="$issuer/.well-known/oauth-authorization-server/alternate" scope="openid postgres alt"
+});
+$node->restart;
+
+$log_start = $node->wait_for_log(qr/ready to accept connections/, $log_start);
+
+# The test user should work as before.
+$user = "test";
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635",
+	"validator is used for $user",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@,
+	log_like => [qr/connection authorized/]);
+
+# testalt should be routed through the fail_validator.
+$user = "testalt";
+$node->connect_fails(
+	"user=$user dbname=postgres oauth_issuer=$issuer/.well-known/oauth-authorization-server/alternate oauth_client_id=f02c6361-0636",
+	"fail_validator is used for $user",
+	expected_stderr => qr/FATAL:\s+fail_validator: sentinel error/);
+
+$node->stop;
+
+done_testing();
diff --git a/src/test/modules/oauth_validator/t/002_client.pl b/src/test/modules/oauth_validator/t/002_client.pl
new file mode 100644
index 00000000000..95cccf90dd8
--- /dev/null
+++ b/src/test/modules/oauth_validator/t/002_client.pl
@@ -0,0 +1,154 @@
+#
+# Exercises the API for custom OAuth client flows, using the oauth_hook_client
+# test driver.
+#
+# Copyright (c) 2021-2024, PostgreSQL Global Development Group
+#
+
+use strict;
+use warnings FATAL => 'all';
+
+use JSON::PP     qw(encode_json);
+use MIME::Base64 qw(encode_base64);
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+if (!$ENV{PG_TEST_EXTRA} || $ENV{PG_TEST_EXTRA} !~ /\boauth\b/)
+{
+	plan skip_all =>
+	  'Potentially unsafe test oauth not enabled in PG_TEST_EXTRA';
+}
+
+#
+# Cluster Setup
+#
+
+my $node = PostgreSQL::Test::Cluster->new('primary');
+$node->init;
+$node->append_conf('postgresql.conf', "log_connections = on\n");
+$node->append_conf('postgresql.conf',
+	"oauth_validator_libraries = 'validator'\n");
+$node->start;
+
+$node->safe_psql('postgres', 'CREATE USER test;');
+
+# These tests don't use the builtin flow, and we don't have an authorization
+# server running, so the address used here shouldn't matter. Use an invalid IP
+# address, so if there's some cascade of errors that causes the client to
+# attempt a connection, we'll fail noisily.
+my $issuer = "https://256.256.256.256";
+my $scope = "openid postgres";
+
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test oauth issuer="$issuer" scope="$scope"
+});
+$node->reload;
+
+my ($log_start, $log_end);
+$log_start = $node->wait_for_log(qr/reloading configuration files/);
+
+$ENV{PGOAUTHDEBUG} = "UNSAFE";
+
+#
+# Tests
+#
+
+my $user = "test";
+my $base_connstr = $node->connstr() . " user=$user";
+my $common_connstr =
+  "$base_connstr oauth_issuer=$issuer oauth_client_id=myID";
+
+sub test
+{
+	my ($test_name, %params) = @_;
+
+	my $flags = [];
+	if (defined($params{flags}))
+	{
+		$flags = $params{flags};
+	}
+
+	my @cmd = ("oauth_hook_client", @{$flags}, $common_connstr);
+	note "running '" . join("' '", @cmd) . "'";
+
+	my ($stdout, $stderr) = run_command(\@cmd);
+
+	if (defined($params{expected_stdout}))
+	{
+		like($stdout, $params{expected_stdout}, "$test_name: stdout matches");
+	}
+
+	if (defined($params{expected_stderr}))
+	{
+		like($stderr, $params{expected_stderr}, "$test_name: stderr matches");
+	}
+	else
+	{
+		is($stderr, "", "$test_name: no stderr");
+	}
+}
+
+test(
+	"basic synchronous hook can provide a token",
+	flags => [
+		"--token", "my-token",
+		"--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"/, ]);
+
+if ($ENV{with_libcurl} ne 'yes')
+{
+	# libpq should help users out if no OAuth support is built in.
+	test(
+		"fails without custom hook installed",
+		flags => ["--no-hook"],
+		expected_stderr =>
+		  qr/no custom OAuth flows are available, and libpq was not built with libcurl support/
+	);
+}
+
+# connect_timeout should work if the flow doesn't respond.
+$common_connstr = "$common_connstr connect_timeout=1";
+test(
+	"connect_timeout interrupts hung client flow",
+	flags => ["--hang-forever"],
+	expected_stderr => qr/failed: timeout expired/);
+
+# Test various misbehaviors of the client hook.
+my @cases = (
+	{
+		flag => "--misbehave=no-hook",
+		expected_error =>
+		  qr/user-defined OAuth flow provided neither a token nor an async callback/,
+	},
+	{
+		flag => "--misbehave=fail-async",
+		expected_error => qr/user-defined OAuth flow failed/,
+	},
+	{
+		flag => "--misbehave=no-token",
+		expected_error => qr/user-defined OAuth flow did not provide a token/,
+	},
+	{
+		flag => "--misbehave=no-socket",
+		expected_error =>
+		  qr/user-defined OAuth flow did not provide a socket for polling/,
+	});
+
+foreach my $c (@cases)
+{
+	test(
+		"hook misbehavior: $c->{'flag'}",
+		flags => [ $c->{'flag'} ],
+		expected_stderr => $c->{'expected_error'});
+}
+
+done_testing();
diff --git a/src/test/modules/oauth_validator/t/OAuth/Server.pm b/src/test/modules/oauth_validator/t/OAuth/Server.pm
new file mode 100644
index 00000000000..f0f23d1d1a8
--- /dev/null
+++ b/src/test/modules/oauth_validator/t/OAuth/Server.pm
@@ -0,0 +1,140 @@
+
+# Copyright (c) 2024, PostgreSQL Global Development Group
+
+=pod
+
+=head1 NAME
+
+OAuth::Server - runs a mock OAuth authorization server for testing
+
+=head1 SYNOPSIS
+
+  use OAuth::Server;
+
+  my $server = OAuth::Server->new();
+  $server->run;
+
+  my $port = $server->port;
+  my $issuer = "http://localhost:$port";
+
+  # test against $issuer...
+
+  $server->stop;
+
+=head1 DESCRIPTION
+
+This is glue API between the Perl tests and the Python authorization server
+daemon implemented in t/oauth_server.py. (Python has a fairly usable HTTP server
+in its standard library, so the implementation was ported from Perl.)
+
+This authorization server does not use TLS (it implements a nonstandard, unsafe
+issuer at "http://localhost:<port>"), so libpq in particular will need to set
+PGOAUTHDEBUG=UNSAFE to be able to talk to it.
+
+=cut
+
+package OAuth::Server;
+
+use warnings;
+use strict;
+use Scalar::Util;
+use Test::More;
+
+=pod
+
+=head1 METHODS
+
+=over
+
+=item SSL::Server->new()
+
+Create a new OAuth Server object.
+
+=cut
+
+sub new
+{
+	my $class = shift;
+
+	my $self = {};
+	bless($self, $class);
+
+	return $self;
+}
+
+=pod
+
+=item $server->port()
+
+Returns the port in use by the server.
+
+=cut
+
+sub port
+{
+	my $self = shift;
+
+	return $self->{'port'};
+}
+
+=pod
+
+=item $server->run()
+
+Runs the authorization server daemon in t/oauth_server.py.
+
+=cut
+
+sub run
+{
+	my $self = shift;
+	my $port;
+
+	my $pid = open(my $read_fh, "-|", $ENV{PYTHON}, "t/oauth_server.py")
+	  or die "failed to start OAuth server: $!";
+
+	# Get the port number from the daemon. It closes stdout afterwards; that way
+	# we can slurp in the entire contents here rather than worrying about the
+	# number of bytes to read.
+	$port = do { local $/ = undef; <$read_fh> }
+	  // die "failed to read port number: $!";
+	chomp $port;
+	die "server did not advertise a valid port"
+	  unless Scalar::Util::looks_like_number($port);
+
+	$self->{'pid'} = $pid;
+	$self->{'port'} = $port;
+	$self->{'child'} = $read_fh;
+
+	note("OAuth provider (PID $pid) is listening on port $port\n");
+}
+
+=pod
+
+=item $server->stop()
+
+Sends SIGTERM to the authorization server and waits for it to exit.
+
+=cut
+
+sub stop
+{
+	my $self = shift;
+
+	note("Sending SIGTERM to OAuth provider PID: $self->{'pid'}\n");
+
+	kill(15, $self->{'pid'});
+	$self->{'pid'} = undef;
+
+	# Closing the popen() handle waits for the process to exit.
+	close($self->{'child'});
+	$self->{'child'} = undef;
+}
+
+=pod
+
+=back
+
+=cut
+
+1;
diff --git a/src/test/modules/oauth_validator/t/oauth_server.py b/src/test/modules/oauth_validator/t/oauth_server.py
new file mode 100755
index 00000000000..4faf3323d38
--- /dev/null
+++ b/src/test/modules/oauth_validator/t/oauth_server.py
@@ -0,0 +1,391 @@
+#! /usr/bin/env python3
+#
+# A mock OAuth authorization server, designed to be invoked from
+# OAuth/Server.pm. This listens on an ephemeral port number (printed to stdout
+# so that the Perl tests can contact it) and runs as a daemon until it is
+# signaled.
+#
+
+import base64
+import http.server
+import json
+import os
+import sys
+import time
+import urllib.parse
+from collections import defaultdict
+
+
+class OAuthHandler(http.server.BaseHTTPRequestHandler):
+    """
+    Core implementation of the authorization server. The API is
+    inheritance-based, with entry points at do_GET() and do_POST(). See the
+    documentation for BaseHTTPRequestHandler.
+    """
+
+    JsonObject = dict[str, object]  # TypeAlias is not available until 3.10
+
+    def _check_issuer(self):
+        """
+        Switches the behavior of the provider depending on the issuer URI.
+        """
+        self._alt_issuer = (
+            self.path.startswith("/alternate/")
+            or self.path == "/.well-known/oauth-authorization-server/alternate"
+        )
+        self._parameterized = self.path.startswith("/param/")
+
+        if self._alt_issuer:
+            # The /alternate issuer uses IETF-style .well-known URIs.
+            if self.path.startswith("/.well-known/"):
+                self.path = self.path.removesuffix("/alternate")
+            else:
+                self.path = self.path.removeprefix("/alternate")
+        elif self._parameterized:
+            self.path = self.path.removeprefix("/param")
+
+    def _check_authn(self):
+        """
+        Checks the expected value of the Authorization header, if any.
+        """
+        secret = self._get_param("expected_secret", None)
+        if secret is None:
+            return
+
+        assert "Authorization" in self.headers
+        method, creds = self.headers["Authorization"].split()
+
+        if method != "Basic":
+            raise RuntimeError(f"client used {method} auth; expected Basic")
+
+        username = urllib.parse.quote_plus(self.client_id)
+        password = urllib.parse.quote_plus(secret)
+        expected_creds = f"{username}:{password}"
+
+        if creds.encode() != base64.b64encode(expected_creds.encode()):
+            raise RuntimeError(
+                f"client sent '{creds}'; expected b64encode('{expected_creds}')"
+            )
+
+    def do_GET(self):
+        self._response_code = 200
+        self._check_issuer()
+
+        config_path = "/.well-known/openid-configuration"
+        if self._alt_issuer:
+            config_path = "/.well-known/oauth-authorization-server"
+
+        if self.path == config_path:
+            resp = self.config()
+        else:
+            self.send_error(404, "Not Found")
+            return
+
+        self._send_json(resp)
+
+    def _parse_params(self) -> dict[str, str]:
+        """
+        Parses apart the form-urlencoded request body and returns the resulting
+        dict. For use by do_POST().
+        """
+        size = int(self.headers["Content-Length"])
+        form = self.rfile.read(size)
+
+        assert self.headers["Content-Type"] == "application/x-www-form-urlencoded"
+        return urllib.parse.parse_qs(
+            form.decode("utf-8"),
+            strict_parsing=True,
+            keep_blank_values=True,
+            encoding="utf-8",
+            errors="strict",
+        )
+
+    @property
+    def client_id(self) -> str:
+        """
+        Returns the client_id sent in the POST body or the Authorization header.
+        self._parse_params() must have been called first.
+        """
+        if "client_id" in self._params:
+            return self._params["client_id"][0]
+
+        if "Authorization" not in self.headers:
+            raise RuntimeError("client did not send any client_id")
+
+        _, creds = self.headers["Authorization"].split()
+
+        decoded = base64.b64decode(creds).decode("utf-8")
+        username, _ = decoded.split(":", 1)
+
+        return urllib.parse.unquote_plus(username)
+
+    def do_POST(self):
+        self._response_code = 200
+        self._check_issuer()
+
+        self._params = self._parse_params()
+        if self._parameterized:
+            # Pull encoded test parameters out of the peer's client_id field.
+            # This is expected to be Base64-encoded JSON.
+            js = base64.b64decode(self.client_id)
+            self._test_params = json.loads(js)
+
+        self._check_authn()
+
+        if self.path == "/authorize":
+            resp = self.authorization()
+        elif self.path == "/token":
+            resp = self.token()
+        else:
+            self.send_error(404)
+            return
+
+        self._send_json(resp)
+
+    def _should_modify(self) -> bool:
+        """
+        Returns True if the client has requested a modification to this stage of
+        the exchange.
+        """
+        if not hasattr(self, "_test_params"):
+            return False
+
+        stage = self._test_params.get("stage")
+
+        return (
+            stage == "all"
+            or (
+                stage == "discovery"
+                and self.path == "/.well-known/openid-configuration"
+            )
+            or (stage == "device" and self.path == "/authorize")
+            or (stage == "token" and self.path == "/token")
+        )
+
+    def _get_param(self, name, default):
+        """
+        If the client has requested a modification to this stage (see
+        _should_modify()), this method searches the provided test parameters for
+        a key of the given name, and returns it if found. Otherwise the provided
+        default is returned.
+        """
+        if self._should_modify() and name in self._test_params:
+            return self._test_params[name]
+
+        return default
+
+    @property
+    def _content_type(self) -> str:
+        """
+        Returns "application/json" unless the test has requested something
+        different.
+        """
+        return self._get_param("content_type", "application/json")
+
+    @property
+    def _interval(self) -> int:
+        """
+        Returns 0 unless the test has requested something different.
+        """
+        return self._get_param("interval", 0)
+
+    @property
+    def _retry_code(self) -> str:
+        """
+        Returns "authorization_pending" unless the test has requested something
+        different.
+        """
+        return self._get_param("retry_code", "authorization_pending")
+
+    @property
+    def _uri_spelling(self) -> str:
+        """
+        Returns "verification_uri" unless the test has requested something
+        different.
+        """
+        return self._get_param("uri_spelling", "verification_uri")
+
+    @property
+    def _response_padding(self):
+        """
+        If the huge_response test parameter is set to True, returns a dict
+        containing a gigantic string value, which can then be folded into a JSON
+        response.
+        """
+        if not self._get_param("huge_response", False):
+            return dict()
+
+        return {"_pad_": "x" * 1024 * 1024}
+
+    @property
+    def _access_token(self):
+        """
+        The actual Bearer token sent back to the client on success. Tests may
+        override this with the "token" test parameter.
+        """
+        token = self._get_param("token", None)
+        if token is not None:
+            return token
+
+        token = "9243959234"
+        if self._alt_issuer:
+            token += "-alt"
+
+        return token
+
+    def _send_json(self, js: JsonObject) -> None:
+        """
+        Sends the provided JSON dict as an application/json response.
+        self._response_code can be modified to send JSON error responses.
+        """
+        resp = json.dumps(js).encode("ascii")
+        self.log_message("sending JSON response: %s", resp)
+
+        self.send_response(self._response_code)
+        self.send_header("Content-Type", self._content_type)
+        self.send_header("Content-Length", str(len(resp)))
+        self.end_headers()
+
+        self.wfile.write(resp)
+
+    def config(self) -> JsonObject:
+        port = self.server.socket.getsockname()[1]
+
+        issuer = f"http://localhost:{port}"
+        if self._alt_issuer:
+            issuer += "/alternate"
+        elif self._parameterized:
+            issuer += "/param"
+
+        return {
+            "issuer": issuer,
+            "token_endpoint": issuer + "/token",
+            "device_authorization_endpoint": issuer + "/authorize",
+            "response_types_supported": ["token"],
+            "subject_types_supported": ["public"],
+            "id_token_signing_alg_values_supported": ["RS256"],
+            "grant_types_supported": [
+                "authorization_code",
+                "urn:ietf:params:oauth:grant-type:device_code",
+            ],
+        }
+
+    @property
+    def _token_state(self):
+        """
+        A cached _TokenState object for the connected client (as determined by
+        the request's client_id), or a new one if it doesn't already exist.
+
+        This relies on the existence of a defaultdict attached to the server;
+        see main() below.
+        """
+        return self.server.token_state[self.client_id]
+
+    def _remove_token_state(self):
+        """
+        Removes any cached _TokenState for the current client_id. Call this
+        after the token exchange ends to get rid of unnecessary state.
+        """
+        if self.client_id in self.server.token_state:
+            del self.server.token_state[self.client_id]
+
+    def authorization(self) -> JsonObject:
+        uri = "https://example.com/"
+        if self._alt_issuer:
+            uri = "https://example.org/"
+
+        resp = {
+            "device_code": "postgres",
+            "user_code": "postgresuser",
+            self._uri_spelling: uri,
+            "expires_in": 5,
+            **self._response_padding,
+        }
+
+        interval = self._interval
+        if interval is not None:
+            resp["interval"] = interval
+            self._token_state.min_delay = interval
+        else:
+            self._token_state.min_delay = 5  # default
+
+        # Check the scope.
+        if "scope" in self._params:
+            assert self._params["scope"][0], "empty scopes should be omitted"
+
+        return resp
+
+    def token(self) -> JsonObject:
+        if err := self._get_param("error_code", None):
+            self._response_code = self._get_param("error_status", 400)
+
+            resp = {"error": err}
+            if desc := self._get_param("error_desc", ""):
+                resp["error_description"] = desc
+
+            return resp
+
+        if self._should_modify() and "retries" in self._test_params:
+            retries = self._test_params["retries"]
+
+            # Check to make sure the token interval is being respected.
+            now = time.monotonic()
+            if self._token_state.last_try is not None:
+                delay = now - self._token_state.last_try
+                assert (
+                    delay > self._token_state.min_delay
+                ), f"client waited only {delay} seconds between token requests (expected {self._token_state.min_delay})"
+
+            self._token_state.last_try = now
+
+            # If we haven't reached the required number of retries yet, return a
+            # "pending" response.
+            if self._token_state.retries < retries:
+                self._token_state.retries += 1
+
+                self._response_code = 400
+                return {"error": self._retry_code}
+
+        # Clean up any retry tracking state now that the exchange is ending.
+        self._remove_token_state()
+
+        return {
+            "access_token": self._access_token,
+            "token_type": "bearer",
+            **self._response_padding,
+        }
+
+
+def main():
+    """
+    Starts the authorization server on localhost. The ephemeral port in use will
+    be printed to stdout.
+    """
+
+    s = http.server.HTTPServer(("127.0.0.1", 0), OAuthHandler)
+
+    # Attach a "cache" dictionary to the server to allow the OAuthHandlers to
+    # track state across token requests. The use of defaultdict ensures that new
+    # entries will be created automatically.
+    class _TokenState:
+        retries = 0
+        min_delay = None
+        last_try = None
+
+    s.token_state = defaultdict(_TokenState)
+
+    # Give the parent the port number to contact (this is also the signal that
+    # we're ready to receive requests).
+    port = s.socket.getsockname()[1]
+    print(port)
+
+    # stdout is closed to allow the parent to just "read to the end".
+    stdout = sys.stdout.fileno()
+    sys.stdout.close()
+    os.close(stdout)
+
+    s.serve_forever()  # we expect our parent to send a termination signal
+
+
+if __name__ == "__main__":
+    main()
diff --git a/src/test/modules/oauth_validator/validator.c b/src/test/modules/oauth_validator/validator.c
new file mode 100644
index 00000000000..ef9bbb2866f
--- /dev/null
+++ b/src/test/modules/oauth_validator/validator.c
@@ -0,0 +1,135 @@
+/*-------------------------------------------------------------------------
+ *
+ * validator.c
+ *	  Test module for serverside OAuth token validation callbacks
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/test/modules/oauth_validator/validator.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "fmgr.h"
+#include "libpq/oauth.h"
+#include "miscadmin.h"
+#include "utils/guc.h"
+#include "utils/memutils.h"
+
+PG_MODULE_MAGIC;
+
+static void validator_startup(ValidatorModuleState *state);
+static void validator_shutdown(ValidatorModuleState *state);
+static ValidatorModuleResult *validate_token(ValidatorModuleState *state,
+											 const char *token,
+											 const char *role);
+
+/* Callback implementations (exercise all three) */
+static const OAuthValidatorCallbacks validator_callbacks = {
+	.startup_cb = validator_startup,
+	.shutdown_cb = validator_shutdown,
+	.validate_cb = validate_token
+};
+
+/* GUCs */
+static char *authn_id = NULL;
+static bool authorize_tokens = true;
+
+/*---
+ * Extension entry point. Sets up GUCs for use by tests:
+ *
+ * - oauth_validator.authn_id	Sets the user identifier to return during token
+ *								validation. Defaults to the username in the
+ *								startup packet.
+ *
+ * - oauth_validator.authorize_tokens
+ *								Sets whether to successfully validate incoming
+ *								tokens. Defaults to true.
+ */
+void
+_PG_init(void)
+{
+	DefineCustomStringVariable("oauth_validator.authn_id",
+							   "Authenticated identity to use for future connections",
+							   NULL,
+							   &authn_id,
+							   NULL,
+							   PGC_SIGHUP,
+							   0,
+							   NULL, NULL, NULL);
+	DefineCustomBoolVariable("oauth_validator.authorize_tokens",
+							 "Should tokens be marked valid?",
+							 NULL,
+							 &authorize_tokens,
+							 true,
+							 PGC_SIGHUP,
+							 0,
+							 NULL, NULL, NULL);
+
+	MarkGUCPrefixReserved("oauth_validator");
+}
+
+/*
+ * Validator module entry point.
+ */
+const OAuthValidatorCallbacks *
+_PG_oauth_validator_module_init(void)
+{
+	return &validator_callbacks;
+}
+
+#define PRIVATE_COOKIE ((void *) 13579)
+
+/*
+ * Startup callback, to set up private data for the validator.
+ */
+static void
+validator_startup(ValidatorModuleState *state)
+{
+	state->private_data = PRIVATE_COOKIE;
+}
+
+/*
+ * Shutdown callback, to tear down the validator.
+ */
+static void
+validator_shutdown(ValidatorModuleState *state)
+{
+	/* Check to make sure our private state still exists. */
+	if (state->private_data != PRIVATE_COOKIE)
+		elog(PANIC, "oauth_validator: private state cookie changed to %p in shutdown",
+			 state->private_data);
+}
+
+/*
+ * Validator implementation. Logs the incoming data and authorizes the token by
+ * default; the behavior can be modified via the module's GUC settings.
+ */
+static ValidatorModuleResult *
+validate_token(ValidatorModuleState *state, const char *token, const char *role)
+{
+	ValidatorModuleResult *res;
+
+	/* Check to make sure our private state still exists. */
+	if (state->private_data != PRIVATE_COOKIE)
+		elog(ERROR, "oauth_validator: private state cookie changed to %p in validate",
+			 state->private_data);
+
+	res = palloc(sizeof(ValidatorModuleResult));
+
+	elog(LOG, "oauth_validator: token=\"%s\", role=\"%s\"", token, role);
+	elog(LOG, "oauth_validator: issuer=\"%s\", scope=\"%s\"",
+		 MyProcPort->hba->oauth_issuer,
+		 MyProcPort->hba->oauth_scope);
+
+	res->authorized = authorize_tokens;
+	if (authn_id)
+		res->authn_id = pstrdup(authn_id);
+	else
+		res->authn_id = pstrdup(role);
+
+	return res;
+}
diff --git a/src/test/perl/PostgreSQL/Test/Cluster.pm b/src/test/perl/PostgreSQL/Test/Cluster.pm
index f521ad0b12f..ab7d7452ede 100644
--- a/src/test/perl/PostgreSQL/Test/Cluster.pm
+++ b/src/test/perl/PostgreSQL/Test/Cluster.pm
@@ -2515,6 +2515,11 @@ instead of the default.
 
 If this regular expression is set, matches it with the output generated.
 
+=item expected_stderr => B<value>
+
+If this regular expression is set, matches it against the standard error
+stream; otherwise the stderr must be empty.
+
 =item log_like => [ qr/required message/ ]
 
 =item log_unlike => [ qr/prohibited message/ ]
@@ -2558,7 +2563,20 @@ sub connect_ok
 		like($stdout, $params{expected_stdout}, "$test_name: stdout matches");
 	}
 
-	is($stderr, "", "$test_name: no stderr");
+	if (defined($params{expected_stderr}))
+	{
+		if (like($stderr, $params{expected_stderr}, "$test_name: stderr matches")
+			&& ($ret != 0))
+		{
+			# In this case (failing test but matching stderr) we'll have
+			# swallowed the output needed to debug. Put it back into the logs.
+			diag("$test_name: full stderr:\n" . $stderr);
+		}
+	}
+	else
+	{
+		is($stderr, "", "$test_name: no stderr");
+	}
 
 	$self->log_check($test_name, $log_location, %params);
 }
diff --git a/src/tools/pgindent/pgindent b/src/tools/pgindent/pgindent
index d8acce7e929..7dccf4614aa 100755
--- a/src/tools/pgindent/pgindent
+++ b/src/tools/pgindent/pgindent
@@ -242,6 +242,14 @@ sub pre_indent
 	# Protect wrapping in CATALOG()
 	$source =~ s!^(CATALOG\(.*)$!/*$1*/!gm;
 
+	# Treat a CURL_IGNORE_DEPRECATION() as braces for the purposes of
+	# indentation. (The recursive regex comes from the perlre documentation; it
+	# matches balanced parentheses as group $1 and the contents as group $2.)
+	my $curlopen = '{ /* CURL_IGNORE_DEPRECATION */';
+	my $curlclose = '} /* CURL_IGNORE_DEPRECATION */';
+	$source =~
+	  s!^[ \t]+CURL_IGNORE_DEPRECATION(\(((?:(?>[^()]+)|(?1))*)\))!$curlopen$2$curlclose!gms;
+
 	return $source;
 }
 
@@ -256,6 +264,12 @@ sub post_indent
 	$source =~ s!^/\* Open extern "C" \*/$!{!gm;
 	$source =~ s!^/\* Close extern "C" \*/$!}!gm;
 
+	# Restore the CURL_IGNORE_DEPRECATION() macro, keeping in mind that our
+	# markers may have been re-indented.
+	$source =~
+	  s!{[ \t]+/\* CURL_IGNORE_DEPRECATION \*/!CURL_IGNORE_DEPRECATION(!gm;
+	$source =~ s!}[ \t]+/\* CURL_IGNORE_DEPRECATION \*/!)!gm;
+
 	## Comments
 
 	# Undo change of dash-protected block comments
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 9a3bee93dec..f3e3592eb77 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -371,6 +371,9 @@ CState
 CTECycleClause
 CTEMaterialize
 CTESearchClause
+CURL
+CURLM
+CURLoption
 CV
 CachedExpression
 CachedPlan
@@ -1724,6 +1727,7 @@ NumericDigit
 NumericSortSupport
 NumericSumAccum
 NumericVar
+OAuthValidatorCallbacks
 OM_uint32
 OP
 OSAPerGroupState
@@ -1832,6 +1836,7 @@ PGVerbosity
 PG_Locale_Strategy
 PG_Lock_Status
 PG_init_t
+PGauthData
 PGcancel
 PGcancelConn
 PGcmdQueueEntry
@@ -1839,7 +1844,9 @@ PGconn
 PGdataValue
 PGlobjfuncs
 PGnotify
+PGoauthBearerRequest
 PGpipelineStatus
+PGpromptOAuthDevice
 PGresAttDesc
 PGresAttValue
 PGresParamDesc
@@ -1951,6 +1958,7 @@ PQArgBlock
 PQEnvironmentOption
 PQExpBuffer
 PQExpBufferData
+PQauthDataHook_type
 PQcommMethods
 PQconninfoOption
 PQnoticeProcessor
@@ -3089,6 +3097,8 @@ VacuumRelation
 VacuumStmt
 ValidIOData
 ValidateIndexState
+ValidatorModuleState
+ValidatorModuleResult
 ValuesScan
 ValuesScanState
 Var
@@ -3485,6 +3495,7 @@ explain_get_index_name_hook_type
 f_smgr
 fasthash_state
 fd_set
+fe_oauth_state
 fe_scram_state
 fe_scram_state_enum
 fetch_range_request
-- 
2.34.1



  [application/x-patch] v48-0002-fixup-Add-OAUTHBEARER-SASL-mechanism.patch (4.8K, ../../CAOYmi+mFZsExAYWy-bS9=yki_fWC+MC0MufpEZ_SycrbCAJrEg@mail.gmail.com/4-v48-0002-fixup-Add-OAUTHBEARER-SASL-mechanism.patch)
  download | inline diff:
From 9171989a75e364b3977e42c33e37bc983c2d000c Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Fri, 7 Feb 2025 14:23:40 -0800
Subject: [PATCH v48 2/8] fixup! Add OAUTHBEARER SASL mechanism

---
 doc/src/sgml/libpq.sgml                   |  6 +++---
 src/backend/libpq/auth-oauth.c            |  5 ++---
 src/interfaces/libpq/fe-auth-oauth-curl.c | 26 +++++++++++++++--------
 3 files changed, 22 insertions(+), 15 deletions(-)

diff --git a/doc/src/sgml/libpq.sgml b/doc/src/sgml/libpq.sgml
index ddfc2a27c50..3ee0a31e6b7 100644
--- a/doc/src/sgml/libpq.sgml
+++ b/doc/src/sgml/libpq.sgml
@@ -10255,9 +10255,9 @@ typedef struct _PGpromptOAuthDevice
          example, by displaying a QR code). The URL and user code should still
          be displayed to the end user in this case, because the code will be
          manually confirmed by the provider, and the URL lets users continue
-         even if they can't use the non-textual method. Review the RFC's
-         <ulink url="https://datatracker.ietf.org/doc/html/rfc8628#section-3.3.1">notes
-         on non-textual verification</ulink>.
+         even if they can't use the non-textual method. For more information,
+         see section 3.3.1 in
+         <ulink url="https://datatracker.ietf.org/doc/html/rfc8628#section-3.3.1">RFC 8628</ulink>.
         </para>
        </listitem>
       </varlistentry>
diff --git a/src/backend/libpq/auth-oauth.c b/src/backend/libpq/auth-oauth.c
index d910cbcb161..aa16977c643 100644
--- a/src/backend/libpq/auth-oauth.c
+++ b/src/backend/libpq/auth-oauth.c
@@ -490,9 +490,8 @@ generate_error_response(struct oauth_ctx *ctx, char **output, int *outputlen)
 	/*
 	 * The admin needs to set an issuer and scope for OAuth to work. There's
 	 * not really a way to hide this from the user, either, because we can't
-	 * choose a "default" issuer, so be honest in the failure message.
-	 *
-	 * TODO: see if there's a better place to fail, earlier than this.
+	 * choose a "default" issuer, so be honest in the failure message. (In
+	 * practice such configurations are rejected during HBA parsing.)
 	 */
 	if (!ctx->issuer || !ctx->scope)
 		ereport(FATAL,
diff --git a/src/interfaces/libpq/fe-auth-oauth-curl.c b/src/interfaces/libpq/fe-auth-oauth-curl.c
index 96c5096e4ca..2179bb89800 100644
--- a/src/interfaces/libpq/fe-auth-oauth-curl.c
+++ b/src/interfaces/libpq/fe-auth-oauth-curl.c
@@ -225,13 +225,14 @@ static void
 free_async_ctx(PGconn *conn, struct async_ctx *actx)
 {
 	/*
-	 * TODO: in general, none of the error cases below should ever happen if
-	 * we have no bugs above. But if we do hit them, surfacing those errors
-	 * somehow might be the only way to have a chance to debug them. What's
-	 * the best way to do that? Assertions? Spraying messages on stderr?
-	 * Bubbling an error code to the top? Appending to the connection's error
-	 * message only helps if the bug caused a connection failure; otherwise
-	 * it'll be buried...
+	 * In general, none of the error cases below should ever happen if we have
+	 * no bugs above. But if we do hit them, surfacing those errors somehow
+	 * might be the only way to have a chance to debug them.
+	 *
+	 * TODO: At some point it'd be nice to have a standard way to warn about
+	 * teardown failures. Appending to the connection's error message only
+	 * helps if the bug caused a connection failure; otherwise it'll be
+	 * buried...
 	 */
 
 	if (actx->curlm && actx->curl)
@@ -876,7 +877,7 @@ parse_json_number(const char *s)
 		 * Either the lexer screwed up or our assumption above isn't true, and
 		 * either way a developer needs to take a look.
 		 */
-		Assert(cnt == 1);
+		Assert(false);
 		return 0;
 	}
 
@@ -1121,6 +1122,7 @@ setup_multiplexer(struct async_ctx *actx)
 	actx->mux = kqueue();
 	if (actx->mux < 0)
 	{
+		/*- translator: the term "kqueue" (kernel queue) should not be translated */
 		actx_error(actx, "failed to create kqueue: %m");
 		return false;
 	}
@@ -1486,7 +1488,7 @@ debug_callback(CURL *handle, curl_infotype type, char *data, size_t size,
 
 		if (!printed_prefix)
 		{
-			appendPQExpBuffer(&buf, "%s ", prefix);
+			appendPQExpBuffer(&buf, "[libcurl] %s ", prefix);
 			printed_prefix = true;
 		}
 
@@ -1570,6 +1572,12 @@ setup_curl_handles(struct async_ctx *actx)
 	 * NB: If libcurl is not built against a friendly DNS resolver (c-ares or
 	 * threaded), setting this option prevents DNS lookups from timing out
 	 * correctly. We warn about this situation at configure time.
+	 *
+	 * TODO: Perhaps there's a clever way to warn the user about synchronous
+	 * DNS at runtime too? It's not immediately clear how to do that in a
+	 * helpful way: for many standard single-threaded use cases, the user
+	 * might not care at all, so spraying warnings to stderr would probably do
+	 * more harm than good.
 	 */
 	CHECK_SETOPT(actx, CURLOPT_NOSIGNAL, 1L, return false);
 
-- 
2.34.1



  [application/x-patch] v48-0003-fixup-Add-OAUTHBEARER-SASL-mechanism.patch (3.6K, ../../CAOYmi+mFZsExAYWy-bS9=yki_fWC+MC0MufpEZ_SycrbCAJrEg@mail.gmail.com/5-v48-0003-fixup-Add-OAUTHBEARER-SASL-mechanism.patch)
  download | inline diff:
From 1bd03e1de1010d1f99f490776a63e53f75e34ec4 Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Fri, 7 Feb 2025 14:23:40 -0800
Subject: [PATCH v48 3/8] fixup! Add OAUTHBEARER SASL mechanism

---
 doc/src/sgml/libpq.sgml            | 40 +++++++++++++++++++++++++++++-
 doc/src/sgml/oauth-validators.sgml |  9 ++++++-
 2 files changed, 47 insertions(+), 2 deletions(-)

diff --git a/doc/src/sgml/libpq.sgml b/doc/src/sgml/libpq.sgml
index 3ee0a31e6b7..e655ee20890 100644
--- a/doc/src/sgml/libpq.sgml
+++ b/doc/src/sgml/libpq.sgml
@@ -10133,8 +10133,46 @@ void PQinitSSL(int do_ssl);
   <title>OAuth Support</title>
 
   <para>
-   TODO
+   libpq implements support for the OAuth v2 Device Authorization client flow,
+   documented in
+   <ulink url="https://datatracker.ietf.org/doc/html/rfc8628">RFC 8628</ulink>,
+   which it will attempt to use by default if the server
+   <link linkend="auth-oauth">requests a bearer token</link> during
+   authentication. This flow can be utilized even if the system running the
+   client application does not have a usable web browser, for example when
+   running a client via SSH. Client applications may implement their own flows
+   instead; see <xref linkend="libpq-oauth-authdata-hooks"/>.
   </para>
+  <para>
+   The builtin flow will, by default, print a URL to visit and a user code to
+   enter there:
+<programlisting>
+$ psql 'dbname=postgres oauth_issuer=https://example.com oauth_client_id=...'
+Visit https://example.com/device and enter the code: ABCD-EFGH
+</programlisting>
+   (This prompt may be
+   <link linkend="libpq-oauth-authdata-prompt-oauth-device">customized</link>.)
+   You will then log into your OAuth provider, which will ask whether you want
+   to allow libpq and the server to perform actions on your behalf. It is always
+   a good idea to carefully review the URL and permissions displayed, to ensure
+   they match your expectations, before continuing. Do not give permissions to
+   untrusted third parties.
+  </para>
+  <para>
+   For an OAuth client flow to be usable, the connection string must at minimum
+   contain <xref linkend="libpq-connect-oauth-issuer"/> and
+   <xref linkend="libpq-connect-oauth-client-id"/>. (These settings are
+   determined by your organization's OAuth provider.) The builtin flow
+   additionally requires the OAuth authorization server to publish a device
+   authorization endpoint.
+  </para>
+
+  <note>
+   <para>
+    The builtin Device Authorization flow is not currently supported on Windows.
+    Custom client flows may still be implemented.
+   </para>
+  </note>
 
   <sect2 id="libpq-oauth-authdata-hooks">
    <title>Authdata Hooks</title>
diff --git a/doc/src/sgml/oauth-validators.sgml b/doc/src/sgml/oauth-validators.sgml
index d0bca9196d9..c8bbac7b462 100644
--- a/doc/src/sgml/oauth-validators.sgml
+++ b/doc/src/sgml/oauth-validators.sgml
@@ -41,7 +41,9 @@
   <sect2 id="oauth-validator-design-responsibilities">
    <title>Validator Responsibilities</title>
    <para>
-    TODO
+    Although different modules may take very different approaches to token
+    validation, implementations generally need to perform three separate
+    actions:
    </para>
    <variablelist>
     <varlistentry>
@@ -121,6 +123,11 @@
        </footnote>
        if users are not prompted for additional scopes.
       </para>
+      <para>
+       Even if authorization fails, a module may choose to continue to pull
+       authentication information from the token for use in auditing and
+       debugging.
+      </para>
      </listitem>
     </varlistentry>
     <varlistentry>
-- 
2.34.1



  [application/x-patch] v48-0004-fixup-Add-OAUTHBEARER-SASL-mechanism.patch (1.5K, ../../CAOYmi+mFZsExAYWy-bS9=yki_fWC+MC0MufpEZ_SycrbCAJrEg@mail.gmail.com/6-v48-0004-fixup-Add-OAUTHBEARER-SASL-mechanism.patch)
  download | inline diff:
From 0929bfbc5fc371e8f0a8ca25c74fadcd0bd9be50 Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Fri, 7 Feb 2025 15:39:35 -0800
Subject: [PATCH v48 4/8] fixup! Add OAUTHBEARER SASL mechanism

---
 src/test/modules/oauth_validator/t/001_server.pl | 11 ++++++++++-
 1 file changed, 10 insertions(+), 1 deletion(-)

diff --git a/src/test/modules/oauth_validator/t/001_server.pl b/src/test/modules/oauth_validator/t/001_server.pl
index f0b918390fd..d2dda62a2d4 100644
--- a/src/test/modules/oauth_validator/t/001_server.pl
+++ b/src/test/modules/oauth_validator/t/001_server.pl
@@ -14,12 +14,18 @@ use MIME::Base64 qw(encode_base64);
 use PostgreSQL::Test::Cluster;
 use PostgreSQL::Test::Utils;
 use Test::More;
+use Config;
 
 use FindBin;
 use lib $FindBin::RealBin;
 
 use OAuth::Server;
 
+if ($Config{osname} eq 'MSWin32')
+{
+	plan skip_all => 'OAuth server-side tests are not supported on Windows';
+}
+
 if (!$ENV{PG_TEST_EXTRA} || $ENV{PG_TEST_EXTRA} !~ /\boauth\b/)
 {
 	plan skip_all =>
@@ -402,7 +408,10 @@ note "running '" . join("' '", @cmd) . "'";
 my ($stdout, $stderr) = run_command(\@cmd);
 
 like($stdout, qr/connection succeeded/, "stress-async: stdout matches");
-unlike($stderr, qr/connection to database failed/, "stress-async: stderr matches");
+unlike(
+	$stderr,
+	qr/connection to database failed/,
+	"stress-async: stderr matches");
 
 #
 # This section of tests reconfigures the validator module itself, rather than
-- 
2.34.1



  [application/x-patch] v48-0005-fixup-Add-OAUTHBEARER-SASL-mechanism.patch (1.4K, ../../CAOYmi+mFZsExAYWy-bS9=yki_fWC+MC0MufpEZ_SycrbCAJrEg@mail.gmail.com/7-v48-0005-fixup-Add-OAUTHBEARER-SASL-mechanism.patch)
  download | inline diff:
From be882ef6eaeba0a9ea536e65a87147e40281e5a4 Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Fri, 7 Feb 2025 16:20:25 -0800
Subject: [PATCH v48 5/8] fixup! Add OAUTHBEARER SASL mechanism

---
 src/interfaces/libpq/fe-auth-oauth-curl.c | 14 +++++++++++++-
 1 file changed, 13 insertions(+), 1 deletion(-)

diff --git a/src/interfaces/libpq/fe-auth-oauth-curl.c b/src/interfaces/libpq/fe-auth-oauth-curl.c
index 2179bb89800..74323de309a 100644
--- a/src/interfaces/libpq/fe-auth-oauth-curl.c
+++ b/src/interfaces/libpq/fe-auth-oauth-curl.c
@@ -32,7 +32,19 @@
 #include "libpq-int.h"
 #include "mb/pg_wchar.h"
 
-#define MAX_OAUTH_RESPONSE_SIZE (1024 * 1024)
+/*
+ * It's generally prudent to set a maximum response size to buffer in memory,
+ * but it's less clear what size to choose. The biggest of our expected
+ * responses is the server metadata JSON, which will only continue to grow in
+ * size; the number of IANA-registered parameters in that document is up to 78
+ * as of February 2025.
+ *
+ * Even if every single parameter were to take up 2k on average (a previously
+ * common limit on the size of a URL), 256k gives us 128 parameter values before
+ * we give up. (That's almost certainly complete overkill in practice; 2-4k
+ * appears to be common among popular providers at the moment.)
+ */
+#define MAX_OAUTH_RESPONSE_SIZE (256 * 1024)
 
 /*
  * Parsed JSON Representations
-- 
2.34.1



  [application/x-patch] v48-0006-fixup-Add-OAUTHBEARER-SASL-mechanism.patch (12.2K, ../../CAOYmi+mFZsExAYWy-bS9=yki_fWC+MC0MufpEZ_SycrbCAJrEg@mail.gmail.com/8-v48-0006-fixup-Add-OAUTHBEARER-SASL-mechanism.patch)
  download | inline diff:
From 954341052b44ab0f89e679926c9b66a085d2f064 Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Thu, 6 Feb 2025 20:53:33 -0800
Subject: [PATCH v48 6/8] fixup! Add OAUTHBEARER SASL mechanism

---
 doc/src/sgml/oauth-validators.sgml            | 22 +++++----
 src/backend/libpq/auth-oauth.c                | 20 ++++++--
 src/include/libpq/oauth.h                     | 48 ++++++++++++++++++-
 .../modules/oauth_validator/fail_validator.c  | 15 ++++--
 src/test/modules/oauth_validator/validator.c  | 28 +++++++----
 5 files changed, 105 insertions(+), 28 deletions(-)

diff --git a/doc/src/sgml/oauth-validators.sgml b/doc/src/sgml/oauth-validators.sgml
index c8bbac7b462..eb8c4431c2d 100644
--- a/doc/src/sgml/oauth-validators.sgml
+++ b/doc/src/sgml/oauth-validators.sgml
@@ -297,13 +297,15 @@
    validator module a function named
    <function>_PG_oauth_validator_module_init</function> must be provided. The
    return value of the function must be a pointer to a struct of type
-   <structname>OAuthValidatorCallbacks</structname>, which contains pointers to
-   the module's token validation functions. The returned
+   <structname>OAuthValidatorCallbacks</structname>, which contains a magic
+   number and pointers to the module's token validation functions. The returned
    pointer must be of server lifetime, which is typically achieved by defining
    it as a <literal>static const</literal> variable in global scope.
 <programlisting>
 typedef struct OAuthValidatorCallbacks
 {
+    uint32        magic;            /* must be set to PG_OAUTH_VALIDATOR_MAGIC */
+
     ValidatorStartupCB startup_cb;
     ValidatorShutdownCB shutdown_cb;
     ValidatorValidateCB validate_cb;
@@ -348,14 +350,16 @@ typedef void (*ValidatorStartupCB) (ValidatorModuleState *state);
     previous calls will be available in <structfield>state->private_data</structfield>.
 
 <programlisting>
-typedef ValidatorModuleResult *(*ValidatorValidateCB) (ValidatorModuleState *state, const char *token, const char *role);
+typedef bool (*ValidatorValidateCB) (const ValidatorModuleState *state,
+                                     const char *token, const char *role,
+                                     ValidatorModuleResult *result);
 </programlisting>
 
     <replaceable>token</replaceable> will contain the bearer token to validate.
     The server has ensured that the token is well-formed syntactically, but no
     other validation has been performed.  <replaceable>role</replaceable> will
     contain the role the user has requested to log in as.  The callback must
-    return a palloc'd <literal>ValidatorModuleResult</literal> struct, which is
+    set output parameters in the <literal>result</literal> struct, which is
     defined as below:
 
 <programlisting>
@@ -375,17 +379,17 @@ typedef struct ValidatorModuleResult
     determined.
    </para>
    <para>
-    The caller assumes ownership of the returned memory allocation, the
-    validator module should not in any way access the memory after it has been
-    returned.  A validator may instead return NULL to signal an internal
-    error.
+    A validator may return <literal>false</literal> to signal an internal error,
+    in which case any result parameters are ignored and the connection fails.
+    Otherwise the validator should return <literal>true</literal> to indicate
+    that it has processed the token and made an authorization decision.
    </para>
    <para>
     The behavior after <function>validate_cb</function> returns depends on the
     specific HBA setup.  Normally, the <structfield>authn_id</structfield> user
     name must exactly match the role that the user is logging in as.  (This
     behavior may be modified with a usermap.)  But when authenticating against
-    an HBA rule with <literal>trust_validator_authz</literal> turned on, the
+    an HBA rule with <literal>delegate_ident_mapping</literal> turned on, the
     server will not perform any checks on the value of
     <structfield>authn_id</structfield> at all; in this case it is up to the
     validator to ensure that the token carries enough privileges for the user to
diff --git a/src/backend/libpq/auth-oauth.c b/src/backend/libpq/auth-oauth.c
index aa16977c643..e2b5d1ed913 100644
--- a/src/backend/libpq/auth-oauth.c
+++ b/src/backend/libpq/auth-oauth.c
@@ -656,9 +656,9 @@ validate(Port *port, const char *auth)
 				errmsg("validation of OAuth token requested without a validator loaded"));
 
 	/* Call the validation function from the validator module */
-	ret = ValidatorCallbacks->validate_cb(validator_module_state,
-										  token, port->user_name);
-	if (ret == NULL)
+	ret = palloc0(sizeof(ValidatorModuleResult));
+	if (!ValidatorCallbacks->validate_cb(validator_module_state, token,
+										 port->user_name, ret))
 	{
 		ereport(LOG, errmsg("internal error in OAuth validator module"));
 		return false;
@@ -756,8 +756,22 @@ load_validator_library(const char *libname)
 	ValidatorCallbacks = (*validator_init) ();
 	Assert(ValidatorCallbacks);
 
+	/*
+	 * Check the magic number, to protect against break-glass scenarios where
+	 * the ABI must change within a major version. load_external_function()
+	 * already checks for compatibility across major versions.
+	 */
+	if (ValidatorCallbacks->magic != PG_OAUTH_VALIDATOR_MAGIC)
+		ereport(ERROR,
+				errmsg("%s module \"%s\": magic number mismatch",
+					   "OAuth validator", libname),
+				errdetail("Server has magic number 0x%08X, module has 0x%08X.",
+						  PG_OAUTH_VALIDATOR_MAGIC, ValidatorCallbacks->magic));
+
 	/* Allocate memory for validator library private state data */
 	validator_module_state = (ValidatorModuleState *) palloc0(sizeof(ValidatorModuleState));
+	validator_module_state->sversion = PG_VERSION_NUM;
+
 	if (ValidatorCallbacks->startup_cb != NULL)
 		ValidatorCallbacks->startup_cb(validator_module_state);
 
diff --git a/src/include/libpq/oauth.h b/src/include/libpq/oauth.h
index 4fcdda74305..7e249613e10 100644
--- a/src/include/libpq/oauth.h
+++ b/src/include/libpq/oauth.h
@@ -20,26 +20,72 @@ extern PGDLLIMPORT char *oauth_validator_libraries_string;
 
 typedef struct ValidatorModuleState
 {
+	/* Holds the server's PG_VERSION_NUM. Reserved for future extensibility. */
+	int			sversion;
+
+	/*
+	 * Private data pointer for use by a validator module. This can be used to
+	 * store state for the module that will be passed to each of its
+	 * callbacks.
+	 */
 	void	   *private_data;
 } ValidatorModuleState;
 
 typedef struct ValidatorModuleResult
 {
+	/*
+	 * Should be set to true if the token carries sufficient permissions for
+	 * the bearer to connect.
+	 */
 	bool		authorized;
+
+	/*
+	 * If the token authenticates the user, this should be set to a palloc'd
+	 * string containing the SYSTEM_USER to use for HBA mapping. Consider
+	 * setting this even if result->authorized is false so that DBAs may use
+	 * the logs to match end users to token failures.
+	 *
+	 * This is required if the module is not configured for ident mapping
+	 * delegation. See the validator module documentation for details.
+	 */
 	char	   *authn_id;
 } ValidatorModuleResult;
 
+/*
+ * Validator module callbacks
+ *
+ * These callback functions should be defined by validator modules and returned
+ * via _PG_oauth_validator_module_init().  ValidatorValidateCB is the only
+ * required callback. For more information about the purpose of each callback,
+ * refer to the OAuth validator modules documentation.
+ */
 typedef void (*ValidatorStartupCB) (ValidatorModuleState *state);
 typedef void (*ValidatorShutdownCB) (ValidatorModuleState *state);
-typedef ValidatorModuleResult *(*ValidatorValidateCB) (ValidatorModuleState *state, const char *token, const char *role);
+typedef bool (*ValidatorValidateCB) (const ValidatorModuleState *state,
+									 const char *token, const char *role,
+									 ValidatorModuleResult *result);
+
+/*
+ * Identifies the compiled ABI version of the validator module. Since the server
+ * already enforces the PG_MODULE_MAGIC number for modules across major
+ * versions, this is reserved for emergency use within a stable release line.
+ * May it never need to change.
+ */
+#define PG_OAUTH_VALIDATOR_MAGIC 0x20250207
 
 typedef struct OAuthValidatorCallbacks
 {
+	uint32		magic;			/* must be set to PG_OAUTH_VALIDATOR_MAGIC */
+
 	ValidatorStartupCB startup_cb;
 	ValidatorShutdownCB shutdown_cb;
 	ValidatorValidateCB validate_cb;
 } OAuthValidatorCallbacks;
 
+/*
+ * Type of the shared library symbol _PG_oauth_validator_module_init that is
+ * looked up when loading a validator module.
+ */
 typedef const OAuthValidatorCallbacks *(*OAuthValidatorModuleInit) (void);
 extern PGDLLEXPORT const OAuthValidatorCallbacks *_PG_oauth_validator_module_init(void);
 
diff --git a/src/test/modules/oauth_validator/fail_validator.c b/src/test/modules/oauth_validator/fail_validator.c
index f77a3e115c6..7b1e69518d9 100644
--- a/src/test/modules/oauth_validator/fail_validator.c
+++ b/src/test/modules/oauth_validator/fail_validator.c
@@ -19,12 +19,15 @@
 
 PG_MODULE_MAGIC;
 
-static ValidatorModuleResult *fail_token(ValidatorModuleState *state,
-										 const char *token,
-										 const char *role);
+static bool fail_token(const ValidatorModuleState *state,
+					   const char *token,
+					   const char *role,
+					   ValidatorModuleResult *result);
 
 /* Callback implementations (we only need the main one) */
 static const OAuthValidatorCallbacks validator_callbacks = {
+	PG_OAUTH_VALIDATOR_MAGIC,
+
 	.validate_cb = fail_token,
 };
 
@@ -34,8 +37,10 @@ _PG_oauth_validator_module_init(void)
 	return &validator_callbacks;
 }
 
-static ValidatorModuleResult *
-fail_token(ValidatorModuleState *state, const char *token, const char *role)
+static bool
+fail_token(const ValidatorModuleState *state,
+		   const char *token, const char *role,
+		   ValidatorModuleResult *res)
 {
 	elog(FATAL, "fail_validator: sentinel error");
 	pg_unreachable();
diff --git a/src/test/modules/oauth_validator/validator.c b/src/test/modules/oauth_validator/validator.c
index ef9bbb2866f..e218f5c8902 100644
--- a/src/test/modules/oauth_validator/validator.c
+++ b/src/test/modules/oauth_validator/validator.c
@@ -23,12 +23,15 @@ PG_MODULE_MAGIC;
 
 static void validator_startup(ValidatorModuleState *state);
 static void validator_shutdown(ValidatorModuleState *state);
-static ValidatorModuleResult *validate_token(ValidatorModuleState *state,
-											 const char *token,
-											 const char *role);
+static bool validate_token(const ValidatorModuleState *state,
+						   const char *token,
+						   const char *role,
+						   ValidatorModuleResult *result);
 
 /* Callback implementations (exercise all three) */
 static const OAuthValidatorCallbacks validator_callbacks = {
+	PG_OAUTH_VALIDATOR_MAGIC,
+
 	.startup_cb = validator_startup,
 	.shutdown_cb = validator_shutdown,
 	.validate_cb = validate_token
@@ -89,6 +92,13 @@ _PG_oauth_validator_module_init(void)
 static void
 validator_startup(ValidatorModuleState *state)
 {
+	/*
+	 * Make sure the server is correctly setting sversion. (Real modules
+	 * should not do this; it would defeat upgrade compatibility.)
+	 */
+	if (state->sversion != PG_VERSION_NUM)
+		elog(ERROR, "oauth_validator: sversion set to %d", state->sversion);
+
 	state->private_data = PRIVATE_COOKIE;
 }
 
@@ -108,18 +118,16 @@ validator_shutdown(ValidatorModuleState *state)
  * Validator implementation. Logs the incoming data and authorizes the token by
  * default; the behavior can be modified via the module's GUC settings.
  */
-static ValidatorModuleResult *
-validate_token(ValidatorModuleState *state, const char *token, const char *role)
+static bool
+validate_token(const ValidatorModuleState *state,
+			   const char *token, const char *role,
+			   ValidatorModuleResult *res)
 {
-	ValidatorModuleResult *res;
-
 	/* Check to make sure our private state still exists. */
 	if (state->private_data != PRIVATE_COOKIE)
 		elog(ERROR, "oauth_validator: private state cookie changed to %p in validate",
 			 state->private_data);
 
-	res = palloc(sizeof(ValidatorModuleResult));
-
 	elog(LOG, "oauth_validator: token=\"%s\", role=\"%s\"", token, role);
 	elog(LOG, "oauth_validator: issuer=\"%s\", scope=\"%s\"",
 		 MyProcPort->hba->oauth_issuer,
@@ -131,5 +139,5 @@ validate_token(ValidatorModuleState *state, const char *token, const char *role)
 	else
 		res->authn_id = pstrdup(role);
 
-	return res;
+	return true;
 }
-- 
2.34.1



  [application/x-patch] v48-0007-XXX-fix-libcurl-link-error.patch (1.1K, ../../CAOYmi+mFZsExAYWy-bS9=yki_fWC+MC0MufpEZ_SycrbCAJrEg@mail.gmail.com/9-v48-0007-XXX-fix-libcurl-link-error.patch)
  download | inline diff:
From d88a2938e7e6a7299ca50e98dc04c7b3dd88fa8a Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Mon, 13 Jan 2025 12:31:59 -0800
Subject: [PATCH v48 7/8] XXX fix libcurl link error

The ftp/curl port appears to be missing a minimum version dependency on
libssh2, so the following starts showing up after upgrading to curl
8.11.1_1:

    libcurl.so.4: Undefined symbol "libssh2_session_callback_set2"

But 13.3 is EOL, so it's not clear if anyone would be interested in a
bug report, and a FreeBSD 14 Cirrus image is in progress. Hack past it
for now.
---
 .cirrus.tasks.yml | 1 +
 1 file changed, 1 insertion(+)

diff --git a/.cirrus.tasks.yml b/.cirrus.tasks.yml
index c192a077701..3afea832bc9 100644
--- a/.cirrus.tasks.yml
+++ b/.cirrus.tasks.yml
@@ -168,6 +168,7 @@ task:
     sysctl kern.corefile='/tmp/cores/%N.%P.core'
   setup_additional_packages_script: |
     pkg install -y curl
+    pkg upgrade -y libssh2 # XXX shouldn't be necessary. revisit w/ FreeBSD 14
 
   # NB: Intentionally build without -Dllvm. The freebsd image size is already
   # large enough to make VM startup slow, and even without llvm freebsd
-- 
2.34.1



  [application/x-patch] v48-0008-DO-NOT-MERGE-Add-pytest-suite-for-OAuth.patch (212.4K, ../../CAOYmi+mFZsExAYWy-bS9=yki_fWC+MC0MufpEZ_SycrbCAJrEg@mail.gmail.com/10-v48-0008-DO-NOT-MERGE-Add-pytest-suite-for-OAuth.patch)
  download | inline diff:
From 44e5cbc8ad1f3e9fcb8af3e69c807b7d31d0e8e9 Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Fri, 4 Jun 2021 09:06:38 -0700
Subject: [PATCH v48 8/8] DO NOT MERGE: Add pytest suite for OAuth

Requires Python 3. On the first run of `make installcheck` or `meson
test` the dependencies will be installed into a local virtualenv for
you. See the README for more details.

Cirrus has been updated to build OAuth support on Debian and FreeBSD.

The suite contains a --temp-instance option, analogous to pg_regress's
option of the same name, which allows an ephemeral server to be spun up
during a test run.

TODOs:
- The --tap-stream option to pytest-tap is slightly broken during test
  failures (it suppresses error information), which impedes debugging.
- pyca/cryptography is pinned at an old version. Since we use it for
  testing and not security, this isn't a critical problem yet, but it's
  not ideal. Newer versions require a Rust compiler to build, and while
  many platforms have precompiled wheels, some (FreeBSD) do not. Even
  with the Rust pieces bypassed, compilation on FreeBSD takes a while.
- The with_oauth test skip logic should probably be integrated into the
  Makefile side as well...
- See if 32-bit tests can be enabled with a 32-bit Python.
---
 .cirrus.tasks.yml                     |    6 +-
 meson.build                           |  103 +
 src/test/meson.build                  |    1 +
 src/test/python/.gitignore            |    2 +
 src/test/python/Makefile              |   38 +
 src/test/python/README                |   66 +
 src/test/python/client/__init__.py    |    0
 src/test/python/client/conftest.py    |  196 ++
 src/test/python/client/test_client.py |  186 ++
 src/test/python/client/test_oauth.py  | 2663 +++++++++++++++++++++++++
 src/test/python/conftest.py           |   34 +
 src/test/python/meson.build           |   47 +
 src/test/python/pq3.py                |  740 +++++++
 src/test/python/pytest.ini            |    4 +
 src/test/python/requirements.txt      |   11 +
 src/test/python/server/__init__.py    |    0
 src/test/python/server/conftest.py    |  141 ++
 src/test/python/server/meson.build    |   18 +
 src/test/python/server/oauthtest.c    |  119 ++
 src/test/python/server/test_oauth.py  | 1080 ++++++++++
 src/test/python/server/test_server.py |   21 +
 src/test/python/test_internals.py     |  138 ++
 src/test/python/test_pq3.py           |  574 ++++++
 src/test/python/tls.py                |  195 ++
 src/tools/make_venv                   |   56 +
 src/tools/testwrap                    |    7 +
 26 files changed, 6445 insertions(+), 1 deletion(-)
 create mode 100644 src/test/python/.gitignore
 create mode 100644 src/test/python/Makefile
 create mode 100644 src/test/python/README
 create mode 100644 src/test/python/client/__init__.py
 create mode 100644 src/test/python/client/conftest.py
 create mode 100644 src/test/python/client/test_client.py
 create mode 100644 src/test/python/client/test_oauth.py
 create mode 100644 src/test/python/conftest.py
 create mode 100644 src/test/python/meson.build
 create mode 100644 src/test/python/pq3.py
 create mode 100644 src/test/python/pytest.ini
 create mode 100644 src/test/python/requirements.txt
 create mode 100644 src/test/python/server/__init__.py
 create mode 100644 src/test/python/server/conftest.py
 create mode 100644 src/test/python/server/meson.build
 create mode 100644 src/test/python/server/oauthtest.c
 create mode 100644 src/test/python/server/test_oauth.py
 create mode 100644 src/test/python/server/test_server.py
 create mode 100644 src/test/python/test_internals.py
 create mode 100644 src/test/python/test_pq3.py
 create mode 100644 src/test/python/tls.py
 create mode 100755 src/tools/make_venv

diff --git a/.cirrus.tasks.yml b/.cirrus.tasks.yml
index 3afea832bc9..06efe5f9b0a 100644
--- a/.cirrus.tasks.yml
+++ b/.cirrus.tasks.yml
@@ -23,7 +23,7 @@ env:
   MTEST_ARGS: --print-errorlogs --no-rebuild -C build
   PGCTLTIMEOUT: 120 # avoids spurious failures during parallel tests
   TEMP_CONFIG: ${CIRRUS_WORKING_DIR}/src/tools/ci/pg_ci_base.conf
-  PG_TEST_EXTRA: kerberos ldap ssl libpq_encryption load_balance oauth
+  PG_TEST_EXTRA: kerberos ldap ssl libpq_encryption load_balance oauth python
 
 
 # What files to preserve in case tests fail
@@ -321,6 +321,7 @@ task:
     DEBIAN_FRONTEND=noninteractive apt-get -y install \
       libcurl4-openssl-dev \
       libcurl4-openssl-dev:i386 \
+      python3-venv \
 
   matrix:
     - name: Linux - Debian Bookworm - Autoconf
@@ -405,8 +406,11 @@ task:
       # can easily provide some here by running one of the sets of tests that
       # way. Newer versions of python insist on changing the LC_CTYPE away
       # from C, prevent that with PYTHONCOERCECLOCALE.
+      # XXX 32-bit Python tests are currently disabled, as the system's 64-bit
+      # Python modules can't link against libpq.
       test_world_32_script: |
         su postgres <<-EOF
+          export PG_TEST_EXTRA="${PG_TEST_EXTRA//python}"
           ulimit -c unlimited
           PYTHONCOERCECLOCALE=0 LANG=C meson test $MTEST_ARGS -C build-32 --num-processes ${TEST_JOBS}
         EOF
diff --git a/meson.build b/meson.build
index 96e5f0f6434..6e60c8d3dae 100644
--- a/meson.build
+++ b/meson.build
@@ -3458,6 +3458,9 @@ else
 endif
 
 testwrap = files('src/tools/testwrap')
+make_venv = files('src/tools/make_venv')
+
+checked_working_venv = false
 
 foreach test_dir : tests
   testwrap_base = [
@@ -3626,6 +3629,106 @@ foreach test_dir : tests
         )
       endforeach
       install_suites += test_group
+    elif kind == 'pytest'
+      venv_name = test_dir['name'] + '_venv'
+      venv_path = meson.build_root() / venv_name
+
+      # The Python tests require a working venv module. This is part of the
+      # standard library, but some platforms disable it until a separate package
+      # is installed. Those same platforms don't provide an easy way to check
+      # whether the venv command will work until the first time you try it, so
+      # we decide whether or not to enable these tests on the fly.
+      if not checked_working_venv
+        cmd = run_command(python, '-m', 'venv', venv_path, check: false)
+
+        have_working_venv = (cmd.returncode() == 0)
+        if not have_working_venv
+          warning('A working Python venv module is required to run Python tests.')
+        endif
+
+        checked_working_venv = true
+      endif
+
+      if not have_working_venv
+        continue
+      endif
+
+      # Make sure the temporary installation is in PATH (necessary both for
+      # --temp-instance and for any pip modules compiling against libpq, like
+      # psycopg2).
+      env = test_env
+      env.prepend('PATH', temp_install_bindir, test_dir['bd'])
+
+      foreach name, value : t.get('env', {})
+        env.set(name, value)
+      endforeach
+
+      reqs = files(t['requirements'])
+      test('install_' + venv_name,
+        python,
+        args: [ make_venv, '--requirements', reqs, venv_path ],
+        env: env,
+        priority: setup_tests_priority - 1,  # must run after tmp_install
+        is_parallel: false,
+        suite: ['setup'],
+        timeout: 60,  # 30s is too short for the cryptography package compile
+      )
+
+      test_group = test_dir['name']
+      test_output = test_result_dir / test_group / kind
+      test_kwargs = {
+        #'protocol': 'tap',
+        'suite': test_group,
+        'timeout': 1000,
+        'depends': test_deps,
+        'env': env,
+      } + t.get('test_kwargs', {})
+
+      if fs.is_dir(venv_path / 'Scripts')
+        # Windows virtualenv layout
+        pytest = venv_path / 'Scripts' / 'py.test'
+      else
+        pytest = venv_path / 'bin' / 'py.test'
+      endif
+
+      test_command = [
+        pytest,
+        # Avoid running these tests against an existing database.
+        '--temp-instance', test_output / 'data',
+
+        # FIXME pytest-tap's stream feature accidentally suppresses errors that
+        # are critical for debugging:
+        #     https://github.com/python-tap/pytest-tap/issues/30
+        # Don't use the meson TAP protocol for now...
+        #'--tap-stream',
+      ]
+
+      foreach pyt : t['tests']
+        # Similarly to TAP, strip ./ and .py to make the names prettier
+        pyt_p = pyt
+        if pyt_p.startswith('./')
+          pyt_p = pyt_p.split('./')[1]
+        endif
+        if pyt_p.endswith('.py')
+          pyt_p = fs.stem(pyt_p)
+        endif
+
+        testwrap_pytest = testwrap_base + [
+          '--testgroup', test_group,
+          '--testname', pyt_p,
+          '--skip-without-extra', 'python',
+        ]
+
+        test(test_group / pyt_p,
+          python,
+          kwargs: test_kwargs,
+          args: testwrap_pytest + [
+            '--', test_command,
+            test_dir['sd'] / pyt,
+          ],
+        )
+      endforeach
+      install_suites += test_group
     else
       error('unknown kind @0@ of test in @1@'.format(kind, test_dir['sd']))
     endif
diff --git a/src/test/meson.build b/src/test/meson.build
index ccc31d6a86a..236057cd99e 100644
--- a/src/test/meson.build
+++ b/src/test/meson.build
@@ -8,6 +8,7 @@ subdir('postmaster')
 subdir('recovery')
 subdir('subscription')
 subdir('modules')
+subdir('python')
 
 if ssl.found()
   subdir('ssl')
diff --git a/src/test/python/.gitignore b/src/test/python/.gitignore
new file mode 100644
index 00000000000..0e8f027b2ec
--- /dev/null
+++ b/src/test/python/.gitignore
@@ -0,0 +1,2 @@
+__pycache__/
+/venv/
diff --git a/src/test/python/Makefile b/src/test/python/Makefile
new file mode 100644
index 00000000000..b0695b6287e
--- /dev/null
+++ b/src/test/python/Makefile
@@ -0,0 +1,38 @@
+#
+# Copyright 2021 VMware, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+# Only Python 3 is supported, but if it's named something different on your
+# system you can override it with the PYTHON3 variable.
+PYTHON3 := python3
+
+# All dependencies are placed into this directory. The default is .gitignored
+# for you, but you can override it if you'd like.
+VENV := ./venv
+
+override VBIN   := $(VENV)/bin
+override PIP    := $(VBIN)/pip
+override PYTEST := $(VBIN)/py.test
+override ISORT  := $(VBIN)/isort
+override BLACK  := $(VBIN)/black
+
+.PHONY: installcheck indent
+
+installcheck: $(PYTEST)
+	$(PYTEST) -v -rs
+
+indent: $(ISORT) $(BLACK)
+	$(ISORT) --profile black *.py client/*.py server/*.py
+	$(BLACK) *.py client/*.py server/*.py
+
+$(PYTEST) $(ISORT) $(BLACK) &: requirements.txt | $(PIP)
+	$(PIP) install --force-reinstall -r $<
+
+$(PIP):
+	$(PYTHON3) -m venv $(VENV)
+
+# A convenience recipe to rebuild psycopg2 against the local libpq.
+.PHONY: rebuild-psycopg2
+rebuild-psycopg2: | $(PIP)
+	$(PIP) install --force-reinstall --no-binary :all: $(shell grep psycopg2 requirements.txt)
diff --git a/src/test/python/README b/src/test/python/README
new file mode 100644
index 00000000000..acf339a5899
--- /dev/null
+++ b/src/test/python/README
@@ -0,0 +1,66 @@
+A test suite for exercising both the libpq client and the server backend at the
+protocol level, based on pytest and Construct.
+
+WARNING! This suite takes superuser-level control of the cluster under test,
+writing to the server config, creating and destroying databases, etc. It also
+spins up various ephemeral TCP services. This is not safe for production servers
+and therefore must be explicitly opted into by setting PG_TEST_EXTRA=python in
+the environment.
+
+The test suite currently assumes that the standard PG* environment variables
+point to the database under test and are sufficient to log in a superuser on
+that system. In other words, a bare `psql` needs to Just Work before the test
+suite can do its thing. For a newly built dev cluster, typically all that I need
+to do is a
+
+    export PGDATABASE=postgres
+
+but you can adjust as needed for your setup. See also 'Advanced Usage' below.
+
+## Requirements
+
+A supported version (3.6+) of Python.
+
+The first run of
+
+    make installcheck PG_TEST_EXTRA=python
+
+will install a local virtual environment and all needed dependencies. During
+development, if libpq changes incompatibly, you can issue
+
+    $ make rebuild-psycopg2
+
+to force a rebuild of the client library.
+
+## Hacking
+
+The code style is enforced by a _very_ opinionated autoformatter. Running the
+
+    make indent
+
+recipe will invoke it for you automatically. Don't fight the tool; part of the
+zen is in knowing that if the formatter makes your code ugly, there's probably a
+cleaner way to write your code.
+
+## Advanced Usage
+
+The Makefile is there for convenience, but you don't have to use it. Activate
+the virtualenv to be able to use pytest directly:
+
+    $ export PG_TEST_EXTRA=python
+    $ source venv/bin/activate
+    $ py.test -k oauth
+    ...
+    $ py.test ./server/test_server.py
+    ...
+    $ deactivate  # puts the PATH et al back the way it was before
+
+To make quick smoke tests possible, slow tests have been marked explicitly. You
+can skip them by saying e.g.
+
+    $ py.test -m 'not slow'
+
+If you'd rather not test against an existing server, you can have the suite spin
+up a temporary one using whatever pg_ctl it finds in PATH:
+
+    $ py.test --temp-instance=./tmp_check
diff --git a/src/test/python/client/__init__.py b/src/test/python/client/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/src/test/python/client/conftest.py b/src/test/python/client/conftest.py
new file mode 100644
index 00000000000..20e72a404aa
--- /dev/null
+++ b/src/test/python/client/conftest.py
@@ -0,0 +1,196 @@
+#
+# Copyright 2021 VMware, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import contextlib
+import datetime
+import functools
+import ipaddress
+import os
+import socket
+import sys
+import threading
+
+import psycopg2
+import psycopg2.extras
+import pytest
+from cryptography import x509
+from cryptography.hazmat.primitives import hashes, serialization
+from cryptography.hazmat.primitives.asymmetric import rsa
+from cryptography.x509.oid import NameOID
+
+import pq3
+
+BLOCKING_TIMEOUT = 2  # the number of seconds to wait for blocking calls
+
+
[email protected]
+def server_socket(unused_tcp_port_factory):
+    """
+    Returns a listening socket bound to an ephemeral port.
+    """
+    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
+        s.bind(("127.0.0.1", unused_tcp_port_factory()))
+        s.listen(1)
+        s.settimeout(BLOCKING_TIMEOUT)
+        yield s
+
+
+class ClientHandshake(threading.Thread):
+    """
+    A thread that connects to a local Postgres server using psycopg2. Once the
+    opening handshake completes, the connection will be immediately closed.
+    """
+
+    def __init__(self, *, port, **kwargs):
+        super().__init__()
+
+        kwargs["port"] = port
+        self._kwargs = kwargs
+
+        self.exception = None
+
+    def run(self):
+        try:
+            conn = psycopg2.connect(host="127.0.0.1", **self._kwargs)
+            with contextlib.closing(conn):
+                self._pump_async(conn)
+        except Exception as e:
+            self.exception = e
+
+    def check_completed(self, timeout=BLOCKING_TIMEOUT):
+        """
+        Joins the client thread. Raises an exception if the thread could not be
+        joined, or if it threw an exception itself. (The exception will be
+        cleared, so future calls to check_completed will succeed.)
+        """
+        self.join(timeout)
+
+        if self.is_alive():
+            raise TimeoutError("client thread did not handshake within the timeout")
+        elif self.exception:
+            e = self.exception
+            self.exception = None
+            raise e
+
+    def _pump_async(self, conn):
+        """
+        Polls a psycopg2 connection until it's completed. (Synchronous
+        connections will work here too; they'll just immediately return OK.)
+        """
+        psycopg2.extras.wait_select(conn)
+
+
[email protected]
+def accept(server_socket):
+    """
+    Returns a factory function that, when called, returns a pair (sock, client)
+    where sock is a server socket that has accepted a connection from client,
+    and client is an instance of ClientHandshake. Clients will complete their
+    handshakes and cleanly disconnect.
+
+    The default connstring options may be extended or overridden by passing
+    arbitrary keyword arguments. Keep in mind that you generally should not
+    override the host or port, since they point to the local test server.
+
+    For situations where a client needs to connect more than once to complete a
+    handshake, the accept function may be called more than once. (The client
+    returned for subsequent calls will always be the same client that was
+    returned for the first call.)
+
+    Tests must either complete the handshake so that the client thread can be
+    automatically joined during teardown, or else call client.check_completed()
+    and manually handle any expected errors.
+    """
+    _, port = server_socket.getsockname()
+
+    client = None
+    default_opts = dict(
+        port=port,
+        user=pq3.pguser(),
+        sslmode="disable",
+    )
+
+    def factory(**kwargs):
+        nonlocal client
+
+        if client is None:
+            opts = dict(default_opts)
+            opts.update(kwargs)
+
+            # The server_socket is already listening, so the client thread can
+            # be safely started; it'll block on the connection until we accept.
+            client = ClientHandshake(**opts)
+            client.start()
+
+        sock, _ = server_socket.accept()
+        sock.settimeout(BLOCKING_TIMEOUT)
+        return sock, client
+
+    yield factory
+
+    if client is not None:
+        client.check_completed()
+
+
[email protected]
+def conn(accept):
+    """
+    Returns an accepted, wrapped pq3 connection to a psycopg2 client. The socket
+    will be closed when the test finishes, and the client will be checked for a
+    cleanly completed handshake.
+    """
+    sock, client = accept()
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            yield conn
+
+
[email protected](scope="session")
+def certpair(tmp_path_factory):
+    """
+    Yields a (cert, key) pair of file paths that can be used by a TLS server.
+    The certificate is issued for "localhost" and its standard IPv4/6 addresses.
+    """
+
+    tmpdir = tmp_path_factory.mktemp("certs")
+    now = datetime.datetime.now(datetime.timezone.utc)
+
+    # https://cryptography.io/en/latest/x509/tutorial/#creating-a-self-signed-certificate
+    key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
+
+    subject = issuer = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "localhost")])
+    altNames = [
+        x509.DNSName("localhost"),
+        x509.IPAddress(ipaddress.IPv4Address("127.0.0.1")),
+        x509.IPAddress(ipaddress.IPv6Address("::1")),
+    ]
+    cert = (
+        x509.CertificateBuilder()
+        .subject_name(subject)
+        .issuer_name(issuer)
+        .public_key(key.public_key())
+        .serial_number(x509.random_serial_number())
+        .not_valid_before(now)
+        .not_valid_after(now + datetime.timedelta(minutes=10))
+        .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
+        .add_extension(x509.SubjectAlternativeName(altNames), critical=False)
+    ).sign(key, hashes.SHA256())
+
+    # Writing the key with mode 0600 lets us use this from the server side, too.
+    keypath = str(tmpdir / "key.pem")
+    with open(keypath, "wb", opener=functools.partial(os.open, mode=0o600)) as f:
+        f.write(
+            key.private_bytes(
+                encoding=serialization.Encoding.PEM,
+                format=serialization.PrivateFormat.PKCS8,
+                encryption_algorithm=serialization.NoEncryption(),
+            )
+        )
+
+    certpath = str(tmpdir / "cert.pem")
+    with open(certpath, "wb") as f:
+        f.write(cert.public_bytes(serialization.Encoding.PEM))
+
+    return certpath, keypath
diff --git a/src/test/python/client/test_client.py b/src/test/python/client/test_client.py
new file mode 100644
index 00000000000..8372376ede4
--- /dev/null
+++ b/src/test/python/client/test_client.py
@@ -0,0 +1,186 @@
+#
+# Copyright 2021 VMware, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import base64
+import sys
+
+import psycopg2
+import pytest
+from cryptography.hazmat.primitives import hashes, hmac
+
+import pq3
+
+from .test_oauth import alt_patterns
+
+
+def finish_handshake(conn):
+    """
+    Sends the AuthenticationOK message and the standard opening salvo of server
+    messages, then asserts that the client immediately sends a Terminate message
+    to close the connection cleanly.
+    """
+    pq3.send(conn, pq3.types.AuthnRequest, type=pq3.authn.OK)
+    pq3.send(conn, pq3.types.ParameterStatus, name=b"client_encoding", value=b"UTF-8")
+    pq3.send(conn, pq3.types.ParameterStatus, name=b"DateStyle", value=b"ISO, MDY")
+    pq3.send(conn, pq3.types.ReadyForQuery, status=b"I")
+
+    pkt = pq3.recv1(conn)
+    assert pkt.type == pq3.types.Terminate
+
+
+def test_handshake(conn):
+    startup = pq3.recv1(conn, cls=pq3.Startup)
+    assert startup.proto == pq3.protocol(3, 0)
+
+    finish_handshake(conn)
+
+
+def test_aborted_connection(accept):
+    """
+    Make sure the client correctly reports an early close during handshakes.
+    """
+    sock, client = accept()
+    sock.close()
+
+    expected = alt_patterns(
+        "server closed the connection unexpectedly",
+        # On some platforms, ECONNABORTED gets set instead.
+        "Software caused connection abort",
+    )
+    with pytest.raises(psycopg2.OperationalError, match=expected):
+        client.check_completed()
+
+
+#
+# SCRAM-SHA-256 (see RFC 5802: https://tools.ietf.org/html/rfc5802)
+#
+
+
[email protected]
+def password():
+    """
+    Returns a password for use by both client and server.
+    """
+    # TODO: parameterize this with passwords that require SASLprep.
+    return "secret"
+
+
[email protected]
+def pwconn(accept, password):
+    """
+    Like the conn fixture, but uses a password in the connection.
+    """
+    sock, client = accept(password=password)
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            yield conn
+
+
+def sha256(data):
+    """The H(str) function from Section 2.2."""
+    digest = hashes.Hash(hashes.SHA256())
+    digest.update(data)
+    return digest.finalize()
+
+
+def hmac_256(key, data):
+    """The HMAC(key, str) function from Section 2.2."""
+    h = hmac.HMAC(key, hashes.SHA256())
+    h.update(data)
+    return h.finalize()
+
+
+def xor(a, b):
+    """The XOR operation from Section 2.2."""
+    res = bytearray(a)
+    for i, byte in enumerate(b):
+        res[i] ^= byte
+    return bytes(res)
+
+
+def h_i(data, salt, i):
+    """The Hi(str, salt, i) function from Section 2.2."""
+    assert i > 0
+
+    acc = hmac_256(data, salt + b"\x00\x00\x00\x01")
+    last = acc
+    i -= 1
+
+    while i:
+        u = hmac_256(data, last)
+        acc = xor(acc, u)
+
+        last = u
+        i -= 1
+
+    return acc
+
+
+def test_scram(pwconn, password):
+    startup = pq3.recv1(pwconn, cls=pq3.Startup)
+    assert startup.proto == pq3.protocol(3, 0)
+
+    pq3.send(
+        pwconn,
+        pq3.types.AuthnRequest,
+        type=pq3.authn.SASL,
+        body=[b"SCRAM-SHA-256", b""],
+    )
+
+    # Get the client-first-message.
+    pkt = pq3.recv1(pwconn)
+    assert pkt.type == pq3.types.PasswordMessage
+
+    initial = pq3.SASLInitialResponse.parse(pkt.payload)
+    assert initial.name == b"SCRAM-SHA-256"
+
+    c_bind, authzid, c_name, c_nonce = initial.data.split(b",")
+    assert c_bind == b"n"  # no channel bindings on a plaintext connection
+    assert authzid == b""  # we don't support authzid currently
+    assert c_name == b"n="  # libpq doesn't honor the GS2 username
+    assert c_nonce.startswith(b"r=")
+
+    # Send the server-first-message.
+    salt = b"12345"
+    iterations = 2
+
+    s_nonce = c_nonce + b"somenonce"
+    s_salt = b"s=" + base64.b64encode(salt)
+    s_iterations = b"i=%d" % iterations
+
+    msg = b",".join([s_nonce, s_salt, s_iterations])
+    pq3.send(pwconn, pq3.types.AuthnRequest, type=pq3.authn.SASLContinue, body=msg)
+
+    # Get the client-final-message.
+    pkt = pq3.recv1(pwconn)
+    assert pkt.type == pq3.types.PasswordMessage
+
+    c_bind_final, c_nonce_final, c_proof = pkt.payload.split(b",")
+    assert c_bind_final == b"c=" + base64.b64encode(c_bind + b"," + authzid + b",")
+    assert c_nonce_final == s_nonce
+
+    # Calculate what the client proof should be.
+    salted_password = h_i(password.encode("ascii"), salt, iterations)
+    client_key = hmac_256(salted_password, b"Client Key")
+    stored_key = sha256(client_key)
+
+    auth_message = b",".join(
+        [c_name, c_nonce, s_nonce, s_salt, s_iterations, c_bind_final, c_nonce_final]
+    )
+    client_signature = hmac_256(stored_key, auth_message)
+    client_proof = xor(client_key, client_signature)
+
+    expected = b"p=" + base64.b64encode(client_proof)
+    assert c_proof == expected
+
+    # Send the correct server signature.
+    server_key = hmac_256(salted_password, b"Server Key")
+    server_signature = hmac_256(server_key, auth_message)
+
+    s_verify = b"v=" + base64.b64encode(server_signature)
+    pq3.send(pwconn, pq3.types.AuthnRequest, type=pq3.authn.SASLFinal, body=s_verify)
+
+    # Done!
+    finish_handshake(pwconn)
diff --git a/src/test/python/client/test_oauth.py b/src/test/python/client/test_oauth.py
new file mode 100644
index 00000000000..e0117bfc894
--- /dev/null
+++ b/src/test/python/client/test_oauth.py
@@ -0,0 +1,2663 @@
+#
+# Copyright 2021 VMware, Inc.
+# Portions Copyright 2023 Timescale, Inc.
+# Portions Copyright 2024 PostgreSQL Global Development Group
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import base64
+import collections
+import contextlib
+import ctypes
+import http.server
+import json
+import logging
+import os
+import platform
+import secrets
+import socket
+import ssl
+import sys
+import threading
+import time
+import traceback
+import types
+import urllib.parse
+from numbers import Number
+
+import psycopg2
+import pytest
+
+import pq3
+
+from .conftest import BLOCKING_TIMEOUT
+
+# The client tests need libpq to have been compiled with OAuth support; skip
+# them otherwise.
+pytestmark = pytest.mark.skipif(
+    os.getenv("with_libcurl") != "yes",
+    reason="OAuth client tests require --with-libcurl support",
+)
+
+if platform.system() == "Darwin":
+    libpq = ctypes.cdll.LoadLibrary("libpq.5.dylib")
+elif platform.system() == "Windows":
+    pass  # TODO
+else:
+    libpq = ctypes.cdll.LoadLibrary("libpq.so.5")
+
+
+def finish_handshake(conn):
+    """
+    Sends the AuthenticationOK message and the standard opening salvo of server
+    messages, then asserts that the client immediately sends a Terminate message
+    to close the connection cleanly.
+    """
+    pq3.send(conn, pq3.types.AuthnRequest, type=pq3.authn.OK)
+    pq3.send(conn, pq3.types.ParameterStatus, name=b"client_encoding", value=b"UTF-8")
+    pq3.send(conn, pq3.types.ParameterStatus, name=b"DateStyle", value=b"ISO, MDY")
+    pq3.send(conn, pq3.types.ReadyForQuery, status=b"I")
+
+    pkt = pq3.recv1(conn)
+    assert pkt.type == pq3.types.Terminate
+
+
+#
+# OAUTHBEARER (see RFC 7628: https://tools.ietf.org/html/rfc7628)
+#
+
+
+def start_oauth_handshake(conn):
+    """
+    Negotiates an OAUTHBEARER SASL challenge. Returns the client's initial
+    response data.
+    """
+    startup = pq3.recv1(conn, cls=pq3.Startup)
+    assert startup.proto == pq3.protocol(3, 0)
+
+    pq3.send(
+        conn, pq3.types.AuthnRequest, type=pq3.authn.SASL, body=[b"OAUTHBEARER", b""]
+    )
+
+    pkt = pq3.recv1(conn)
+    assert pkt.type == pq3.types.PasswordMessage
+
+    initial = pq3.SASLInitialResponse.parse(pkt.payload)
+    assert initial.name == b"OAUTHBEARER"
+
+    return initial.data
+
+
+def get_auth_value(initial):
+    """
+    Finds the auth value (e.g. "Bearer somedata..." in the client's initial SASL
+    response.
+    """
+    kvpairs = initial.split(b"\x01")
+    assert kvpairs[0] == b"n,,"  # no channel binding or authzid
+    assert kvpairs[2] == b""  # ends with an empty kvpair
+    assert kvpairs[3] == b""  # ...and there's nothing after it
+    assert len(kvpairs) == 4
+
+    key, value = kvpairs[1].split(b"=", 2)
+    assert key == b"auth"
+
+    return value
+
+
+def fail_oauth_handshake(conn, sasl_resp, *, errmsg="doesn't matter"):
+    """
+    Sends a failure response via the OAUTHBEARER mechanism, consumes the
+    client's dummy response, and issues a FATAL error to end the exchange.
+
+    sasl_resp is a dictionary which will be serialized as the OAUTHBEARER JSON
+    response. If provided, errmsg is used in the FATAL ErrorResponse.
+    """
+    resp = json.dumps(sasl_resp)
+    pq3.send(
+        conn,
+        pq3.types.AuthnRequest,
+        type=pq3.authn.SASLContinue,
+        body=resp.encode("utf-8"),
+    )
+
+    # Per RFC, the client is required to send a dummy ^A response.
+    pkt = pq3.recv1(conn)
+    assert pkt.type == pq3.types.PasswordMessage
+    assert pkt.payload == b"\x01"
+
+    # Now fail the SASL exchange.
+    pq3.send(
+        conn,
+        pq3.types.ErrorResponse,
+        fields=[
+            b"SFATAL",
+            b"C28000",
+            b"M" + errmsg.encode("utf-8"),
+            b"",
+        ],
+    )
+
+
+def handle_discovery_connection(sock, discovery=None, *, response=None):
+    """
+    Helper for all tests that expect an initial discovery connection from the
+    client. The provided discovery URI will be used in a standard error response
+    from the server (or response may be set, to provide a custom dictionary),
+    and the SASL exchange will be failed.
+
+    By default, the client is expected to complete the entire handshake. Set
+    finish to False if the client should immediately disconnect when it receives
+    the error response.
+    """
+    if response is None:
+        response = {"status": "invalid_token"}
+        if discovery is not None:
+            response["openid-configuration"] = discovery
+
+    with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+        # Initiate a handshake.
+        initial = start_oauth_handshake(conn)
+
+        # For discovery, the client should send an empty auth header. See RFC
+        # 7628, Sec. 4.3.
+        auth = get_auth_value(initial)
+        assert auth == b""
+
+        # The discovery handshake is doomed to fail.
+        fail_oauth_handshake(conn, response)
+
+
+class RawResponse(str):
+    """
+    Returned by registered endpoint callbacks to take full control of the
+    response. Usually, return values are converted to JSON; a RawResponse body
+    will be passed to the client as-is, allowing endpoint implementations to
+    issue invalid JSON.
+    """
+
+    pass
+
+
+class RawBytes(bytes):
+    """
+    Like RawResponse, but bypasses the UTF-8 encoding step as well, allowing
+    implementations to issue invalid encodings.
+    """
+
+    pass
+
+
+class OpenIDProvider(threading.Thread):
+    """
+    A thread that runs a mock OpenID provider server on an SSL-enabled socket.
+    """
+
+    def __init__(self, ssl_socket):
+        super().__init__()
+
+        self.exception = None
+
+        _, port = ssl_socket.getsockname()
+
+        oauth = self._OAuthState()
+        oauth.host = f"localhost:{port}"
+        oauth.issuer = f"https://localhost:{port}"
+
+        # The following endpoints are required to be advertised by providers,
+        # even though our chosen client implementation does not actually make
+        # use of them.
+        oauth.register_endpoint(
+            "authorization_endpoint", "POST", "/authorize", self._authorization_handler
+        )
+        oauth.register_endpoint("jwks_uri", "GET", "/keys", self._jwks_handler)
+
+        self.server = self._HTTPSServer(ssl_socket, self._Handler)
+        self.server.oauth = oauth
+
+    def run(self):
+        try:
+            # XXX socketserver.serve_forever() has a serious architectural
+            # issue: its select loop wakes up every `poll_interval` seconds to
+            # see if the server is shutting down. The default, 500 ms, only lets
+            # us run two tests every second. But the faster we go, the more CPU
+            # we burn unnecessarily...
+            self.server.serve_forever(poll_interval=0.01)
+        except Exception as e:
+            self.exception = e
+
+    def stop(self, timeout=BLOCKING_TIMEOUT):
+        """
+        Shuts down the server and joins its thread. Raises an exception if the
+        thread could not be joined, or if it threw an exception itself. Must
+        only be called once, after start().
+        """
+        self.server.shutdown()
+        self.join(timeout)
+
+        if self.is_alive():
+            raise TimeoutError("client thread did not handshake within the timeout")
+        elif self.exception:
+            e = self.exception
+            raise e
+
+    class _OAuthState(object):
+        def __init__(self):
+            self.endpoint_paths = {}
+            self._endpoints = {}
+
+            # Provide a standard discovery document by default; tests can
+            # override it.
+            self.register_endpoint(
+                None,
+                "GET",
+                "/.well-known/openid-configuration",
+                self._default_discovery_handler,
+            )
+
+            # Default content type unless overridden.
+            self.content_type = "application/json"
+
+        @property
+        def discovery_uri(self):
+            return f"{self.issuer}/.well-known/openid-configuration"
+
+        def register_endpoint(self, name, method, path, func):
+            if method not in self._endpoints:
+                self._endpoints[method] = {}
+
+            self._endpoints[method][path] = func
+
+            if name is not None:
+                self.endpoint_paths[name] = path
+
+        def endpoint(self, method, path):
+            if method not in self._endpoints:
+                return None
+
+            return self._endpoints[method].get(path)
+
+        def _default_discovery_handler(self, headers, params):
+            doc = {
+                "issuer": self.issuer,
+                "response_types_supported": ["token"],
+                "subject_types_supported": ["public"],
+                "id_token_signing_alg_values_supported": ["RS256"],
+                "grant_types_supported": [
+                    "authorization_code",
+                    "urn:ietf:params:oauth:grant-type:device_code",
+                ],
+            }
+
+            for name, path in self.endpoint_paths.items():
+                doc[name] = self.issuer + path
+
+            return 200, doc
+
+    class _HTTPSServer(http.server.HTTPServer):
+        def __init__(self, ssl_socket, handler_cls):
+            # Attach the SSL socket to the server. We don't bind/activate since
+            # the socket is already listening.
+            super().__init__(None, handler_cls, bind_and_activate=False)
+            self.socket = ssl_socket
+            self.server_address = self.socket.getsockname()
+
+        def shutdown_request(self, request):
+            # Cleanly unwrap the SSL socket before shutting down the connection;
+            # otherwise careful clients will complain about truncation.
+            try:
+                request = request.unwrap()
+            except (ssl.SSLEOFError, ConnectionResetError, BrokenPipeError):
+                # The client already closed (or aborted) the connection without
+                # a clean shutdown. This is seen on some platforms during tests
+                # that break the HTTP protocol. Just return and have the server
+                # close the socket.
+                return
+            except ssl.SSLError as err:
+                # FIXME OpenSSL 3.4 introduced an incompatibility with Python's
+                # TLS error handling, resulting in a bogus "[SYS] unknown error"
+                # on some platforms. Hopefully this is fixed in 2025's set of
+                # maintenance releases and this case can be removed.
+                #
+                #     https://github.com/python/cpython/issues/127257
+                #
+                if "[SYS] unknown error" in str(err):
+                    return
+                raise
+
+            super().shutdown_request(request)
+
+        def handle_error(self, request, addr):
+            self.shutdown_request(request)
+            raise
+
+    @staticmethod
+    def _jwks_handler(headers, params):
+        return 200, {"keys": []}
+
+    @staticmethod
+    def _authorization_handler(headers, params):
+        # We don't actually want this to be called during these tests -- we
+        # should be using the device authorization endpoint instead.
+        assert (
+            False
+        ), "authorization handler called instead of device authorization handler"
+
+    class _Handler(http.server.BaseHTTPRequestHandler):
+        timeout = BLOCKING_TIMEOUT
+
+        def _handle(self, *, params=None, handler=None):
+            oauth = self.server.oauth
+            assert self.headers["Host"] == oauth.host
+
+            # XXX: BaseHTTPRequestHandler collapses leading slashes in the path
+            # to work around an open redirection vuln (gh-87389) in
+            # SimpleHTTPServer. But we're not using SimpleHTTPServer, and we
+            # want to test repeating leading slashes, so that's not very
+            # helpful. Put them back.
+            orig_path = self.raw_requestline.split()[1]
+            orig_path = str(orig_path, "iso-8859-1")
+            assert orig_path.endswith(self.path)  # sanity check
+            self.path = orig_path
+
+            if handler is None:
+                handler = oauth.endpoint(self.command, self.path)
+                assert (
+                    handler is not None
+                ), f"no registered endpoint for {self.command} {self.path}"
+
+            result = handler(self.headers, params)
+
+            if len(result) == 2:
+                headers = {"Content-Type": oauth.content_type}
+                code, resp = result
+            else:
+                code, headers, resp = result
+
+            self.send_response(code)
+            for h, v in headers.items():
+                self.send_header(h, v)
+            self.end_headers()
+
+            if resp is not None:
+                if not isinstance(resp, RawBytes):
+                    if not isinstance(resp, RawResponse):
+                        resp = json.dumps(resp)
+                    resp = resp.encode("utf-8")
+                self.wfile.write(resp)
+
+            self.close_connection = True
+
+        def do_GET(self):
+            self._handle()
+
+        def _request_body(self):
+            length = self.headers["Content-Length"]
+
+            # Handle only an explicit content-length.
+            assert length is not None
+            length = int(length)
+
+            return self.rfile.read(length).decode("utf-8")
+
+        def do_POST(self):
+            assert self.headers["Content-Type"] == "application/x-www-form-urlencoded"
+
+            body = self._request_body()
+            if body:
+                # parse_qs() is understandably fairly lax when it comes to
+                # acceptable characters, but we're stricter. Spaces must be
+                # encoded, and they must use the '+' encoding rather than "%20".
+                assert " " not in body
+                assert "%20" not in body
+
+                params = urllib.parse.parse_qs(
+                    body,
+                    keep_blank_values=True,
+                    strict_parsing=True,
+                    encoding="utf-8",
+                    errors="strict",
+                )
+            else:
+                params = {}
+
+            self._handle(params=params)
+
+
[email protected](autouse=True)
+def enable_client_oauth_debugging(monkeypatch):
+    """
+    HTTP providers aren't allowed by default; enable them via envvar.
+    """
+    monkeypatch.setenv("PGOAUTHDEBUG", "UNSAFE")
+
+
[email protected](autouse=True)
+def trust_certpair_in_client(monkeypatch, certpair):
+    """
+    Set a trusted CA file for OAuth client connections.
+    """
+    monkeypatch.setenv("PGOAUTHCAFILE", certpair[0])
+
+
[email protected](scope="session")
+def ssl_socket(certpair):
+    """
+    A listening server-side socket for SSL connections, using the certpair
+    fixture.
+    """
+    sock = socket.create_server(("", 0))
+
+    # The TLS connections we're making are incredibly sensitive to delayed ACKs
+    # from the client. (Without TCP_NODELAY, test performance degrades 4-5x.)
+    sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
+
+    with contextlib.closing(sock):
+        # Wrap the server socket for TLS.
+        ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
+        ctx.load_cert_chain(*certpair)
+
+        yield ctx.wrap_socket(sock, server_side=True)
+
+
[email protected]
+def openid_provider(ssl_socket):
+    """
+    A fixture that returns the OAuth state of a running OpenID provider server. The
+    server will be stopped when the fixture is torn down.
+    """
+    thread = OpenIDProvider(ssl_socket)
+    thread.start()
+
+    try:
+        yield thread.server.oauth
+    finally:
+        thread.stop()
+
+
+#
+# PQAuthDataHook implementation, matching libpq.h
+#
+
+
+PQAUTHDATA_PROMPT_OAUTH_DEVICE = 0
+PQAUTHDATA_OAUTH_BEARER_TOKEN = 1
+
+PGRES_POLLING_FAILED = 0
+PGRES_POLLING_READING = 1
+PGRES_POLLING_WRITING = 2
+PGRES_POLLING_OK = 3
+
+
+class PGPromptOAuthDevice(ctypes.Structure):
+    _fields_ = [
+        ("verification_uri", ctypes.c_char_p),
+        ("user_code", ctypes.c_char_p),
+        ("verification_uri_complete", ctypes.c_char_p),
+        ("expires_in", ctypes.c_int),
+    ]
+
+
+class PGOAuthBearerRequest(ctypes.Structure):
+    pass
+
+
+PGOAuthBearerRequest._fields_ = [
+    ("openid_configuration", ctypes.c_char_p),
+    ("scope", ctypes.c_char_p),
+    (
+        "async_",
+        ctypes.CFUNCTYPE(
+            ctypes.c_int,
+            ctypes.c_void_p,
+            ctypes.POINTER(PGOAuthBearerRequest),
+            ctypes.POINTER(ctypes.c_int),
+        ),
+    ),
+    (
+        "cleanup",
+        ctypes.CFUNCTYPE(None, ctypes.c_void_p, ctypes.POINTER(PGOAuthBearerRequest)),
+    ),
+    ("token", ctypes.c_char_p),
+    ("user", ctypes.c_void_p),
+]
+
+
[email protected]
+def auth_data_cb():
+    """
+    Tracks calls to the libpq authdata hook. The yielded object contains a calls
+    member that records the data sent to the hook. If a test needs to perform
+    custom actions during a call, it can set the yielded object's impl callback;
+    beware that the callback takes place on a different thread.
+
+    This is done differently from the other callback implementations on purpose.
+    For the others, we can declare test-specific callbacks and have them perform
+    direct assertions on the data they receive. But that won't work for a C
+    callback, because there's no way for us to bubble up the assertion through
+    libpq. Instead, this mock-style approach is taken, where we just record the
+    calls and let the test examine them later.
+    """
+
+    class _Call:
+        pass
+
+    class _cb(object):
+        def __init__(self):
+            self.calls = []
+
+    cb = _cb()
+    cb.impl = None
+
+    # The callback will occur on a different thread, so protect the cb object.
+    cb_lock = threading.Lock()
+
+    @ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_byte, ctypes.c_void_p, ctypes.c_void_p)
+    def auth_data_cb(typ, pgconn, data):
+        handle_by_default = 0  # does an implementation have to be provided?
+
+        if typ == PQAUTHDATA_PROMPT_OAUTH_DEVICE:
+            cls = PGPromptOAuthDevice
+            handle_by_default = 1
+        elif typ == PQAUTHDATA_OAUTH_BEARER_TOKEN:
+            cls = PGOAuthBearerRequest
+        else:
+            return 0
+
+        call = _Call()
+        call.type = typ
+
+        # The lifetime of the underlying data being pointed to doesn't
+        # necessarily match the lifetime of the Python object, so we can't
+        # reference a Structure's fields after returning. Explicitly copy the
+        # contents over, field by field.
+        data = ctypes.cast(data, ctypes.POINTER(cls))
+        for name, _ in cls._fields_:
+            setattr(call, name, getattr(data.contents, name))
+
+        with cb_lock:
+            cb.calls.append(call)
+
+        if cb.impl:
+            # Pass control back to the test.
+            try:
+                return cb.impl(typ, pgconn, data.contents)
+            except Exception:
+                # This can't escape into the C stack, but we can fail the flow
+                # and hope the traceback gives us enough detail.
+                logging.error(
+                    "Exception during authdata hook callback:\n"
+                    + traceback.format_exc()
+                )
+                return -1
+
+        return handle_by_default
+
+    libpq.PQsetAuthDataHook(auth_data_cb)
+    try:
+        yield cb
+    finally:
+        # The callback is about to go out of scope, so make sure libpq is
+        # disconnected from it. (We wouldn't want to accidentally influence
+        # later tests anyway.)
+        libpq.PQsetAuthDataHook(None)
+
+
[email protected](
+    "success, abnormal_failure",
+    [
+        pytest.param(True, False, id="success"),
+        pytest.param(False, False, id="normal failure"),
+        pytest.param(False, True, id="abnormal failure"),
+    ],
+)
[email protected]("secret", [None, "", "hunter2"])
[email protected]("scope", [None, "", "openid email"])
[email protected]("retries", [0, 1])
[email protected](
+    "content_type",
+    [
+        pytest.param("application/json", id="standard"),
+        pytest.param("application/json;charset=utf-8", id="charset"),
+        pytest.param("application/json \t;\t charset=utf-8", id="charset (whitespace)"),
+    ],
+)
[email protected]("uri_spelling", ["verification_url", "verification_uri"])
[email protected](
+    "asynchronous",
+    [
+        pytest.param(False, id="synchronous"),
+        pytest.param(True, id="asynchronous"),
+    ],
+)
+def test_oauth_with_explicit_discovery_uri(
+    accept,
+    openid_provider,
+    asynchronous,
+    uri_spelling,
+    content_type,
+    retries,
+    scope,
+    secret,
+    auth_data_cb,
+    success,
+    abnormal_failure,
+):
+    client_id = secrets.token_hex()
+    openid_provider.content_type = content_type
+
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id=client_id,
+        oauth_client_secret=secret,
+        oauth_scope=scope,
+        async_=asynchronous,
+    )
+
+    device_code = secrets.token_hex()
+    user_code = f"{secrets.token_hex(2)}-{secrets.token_hex(2)}"
+    verification_url = "https://example.com/device"
+
+    access_token = secrets.token_urlsafe()
+
+    def check_client_authn(headers, params):
+        if secret is None:
+            assert "Authorization" not in headers
+            assert params["client_id"] == [client_id]
+            return
+
+        # Require the client to use Basic authn; request-body credentials are
+        # NOT RECOMMENDED (RFC 6749, Sec. 2.3.1).
+        assert "Authorization" in headers
+        assert "client_id" not in params
+
+        method, creds = headers["Authorization"].split()
+        assert method == "Basic"
+
+        expected = f"{client_id}:{secret}"
+        assert base64.b64decode(creds) == expected.encode("ascii")
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        check_client_authn(headers, params)
+
+        if scope:
+            assert params["scope"] == [scope]
+        else:
+            assert "scope" not in params
+
+        resp = {
+            "device_code": device_code,
+            "user_code": user_code,
+            "interval": 0,
+            uri_spelling: verification_url,
+            "expires_in": 5,
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    attempts = 0
+    retry_lock = threading.Lock()
+
+    def token_endpoint(headers, params):
+        check_client_authn(headers, params)
+
+        assert params["grant_type"] == ["urn:ietf:params:oauth:grant-type:device_code"]
+        assert params["device_code"] == [device_code]
+
+        now = time.monotonic()
+
+        with retry_lock:
+            nonlocal attempts
+
+            # If the test wants to force the client to retry, return an
+            # authorization_pending response and decrement the retry count.
+            if attempts < retries:
+                attempts += 1
+                return 400, {"error": "authorization_pending"}
+
+        # Successfully finish the request by sending the access bearer token.
+        resp = {
+            "access_token": access_token,
+            "token_type": "bearer",
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    # First connection is a discovery request, which should result in the above
+    # endpoints being called.
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    # Client should reconnect.
+    sock, _ = accept()
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            initial = start_oauth_handshake(conn)
+
+            # Validate and accept the token.
+            auth = get_auth_value(initial)
+            assert auth == f"Bearer {access_token}".encode("ascii")
+
+            if success:
+                finish_handshake(conn)
+
+            elif abnormal_failure:
+                # Send an empty error response, which should result in a
+                # mechanism-level failure in the client. This test ensures that
+                # the client doesn't try a third connection for this case.
+                expected_error = "server sent error response without a status"
+                fail_oauth_handshake(conn, {})
+
+            else:
+                # Simulate token validation failure.
+                resp = {
+                    "status": "invalid_token",
+                    "openid-configuration": openid_provider.discovery_uri,
+                }
+                expected_error = "test token validation failure"
+                fail_oauth_handshake(conn, resp, errmsg=expected_error)
+
+    if retries:
+        # Finally, make sure that the client prompted the user once with the
+        # expected authorization URL and user code.
+        assert len(auth_data_cb.calls) == 2
+
+        # First call should have been for a custom flow, which we ignored.
+        assert auth_data_cb.calls[0].type == PQAUTHDATA_OAUTH_BEARER_TOKEN
+
+        # Second call is for our user prompt.
+        call = auth_data_cb.calls[1]
+        assert call.type == PQAUTHDATA_PROMPT_OAUTH_DEVICE
+        assert call.verification_uri.decode() == verification_url
+        assert call.user_code.decode() == user_code
+        assert call.verification_uri_complete is None
+        assert call.expires_in == 5
+
+    if not success:
+        # The client should not try to connect again.
+        with pytest.raises(psycopg2.OperationalError, match=expected_error):
+            client.check_completed()
+
+
[email protected](
+    "server_discovery",
+    [
+        pytest.param(True, id="server discovery"),
+        pytest.param(False, id="direct discovery"),
+    ],
+)
[email protected](
+    "issuer, path",
+    [
+        pytest.param(
+            "{issuer}",
+            "/.well-known/oauth-authorization-server",
+            id="oauth",
+        ),
+        pytest.param(
+            "{issuer}/alt",
+            "/.well-known/oauth-authorization-server/alt",
+            id="oauth with path, IETF style",
+        ),
+        pytest.param(
+            "{issuer}/alt",
+            "/alt/.well-known/oauth-authorization-server",
+            id="oauth with path, broken OIDC style",
+        ),
+        pytest.param(
+            "{issuer}/alt",
+            "/alt/.well-known/openid-configuration",
+            id="openid with path, OIDC style",
+        ),
+        pytest.param(
+            "{issuer}/alt",
+            "/.well-known/openid-configuration/alt",
+            id="openid with path, IETF style",
+        ),
+        pytest.param(
+            "{issuer}/",
+            "//.well-known/openid-configuration",
+            id="empty path segment, OIDC style",
+        ),
+        pytest.param(
+            "{issuer}/",
+            "/.well-known/openid-configuration/",
+            id="empty path segment, IETF style",
+        ),
+    ],
+)
+def test_alternate_well_known_paths(
+    accept, openid_provider, issuer, path, server_discovery
+):
+    issuer = issuer.format(issuer=openid_provider.issuer)
+    discovery_uri = openid_provider.issuer + path
+
+    client_id = secrets.token_hex()
+    access_token = secrets.token_urlsafe()
+
+    def discovery_handler(*args):
+        """
+        Pass-through implementation of the discovery handler. Modifies the
+        default document to contain this test's issuer identifier.
+        """
+        code, doc = openid_provider._default_discovery_handler(*args)
+        doc["issuer"] = issuer
+        return code, doc
+
+    openid_provider.register_endpoint(None, "GET", path, discovery_handler)
+
+    def authorization_endpoint(headers, params):
+        resp = {
+            "device_code": "12345",
+            "user_code": "ABCDE",
+            "interval": 0,
+            "verification_url": "https://example.com/device",
+            "expires_in": 5,
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    def token_endpoint(headers, params):
+        # Successfully finish the request by sending the access bearer token.
+        resp = {
+            "access_token": access_token,
+            "token_type": "bearer",
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    kwargs = dict(oauth_client_id=client_id)
+    if server_discovery:
+        kwargs.update(oauth_issuer=issuer)
+    else:
+        kwargs.update(oauth_issuer=discovery_uri)
+
+    sock, client = accept(**kwargs)
+
+    with sock:
+        handle_discovery_connection(sock, discovery_uri)
+
+    # Expect the client to connect again.
+    sock, _ = accept()
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            initial = start_oauth_handshake(conn)
+
+            # Validate the token.
+            auth = get_auth_value(initial)
+            assert auth == f"Bearer {access_token}".encode("ascii")
+
+            finish_handshake(conn)
+
+
[email protected](
+    "server_discovery",
+    [
+        pytest.param(True, id="server discovery"),
+        pytest.param(False, id="direct discovery"),
+    ],
+)
[email protected](
+    "issuer, path, expected_error",
+    [
+        pytest.param(
+            "{issuer}",
+            "/.well-known/oauth-authorization-server/",
+            None,
+            id="extra empty segment (no path)",
+        ),
+        pytest.param(
+            "{issuer}/path",
+            "/.well-known/oauth-authorization-server/path/",
+            None,
+            id="extra empty segment (with path)",
+        ),
+        pytest.param(
+            "{issuer}",
+            "?/.well-known/oauth-authorization-server",
+            r'OAuth discovery URI ".*" must not contain query or fragment components',
+            id="query",
+        ),
+        pytest.param(
+            "{issuer}",
+            "#/.well-known/oauth-authorization-server",
+            r'OAuth discovery URI ".*" must not contain query or fragment components',
+            id="fragment",
+        ),
+        pytest.param(
+            "{issuer}/sub/path",
+            "/sub/.well-known/oauth-authorization-server/path",
+            r'OAuth discovery URI ".*" uses an invalid format',
+            id="sandwiched prefix",
+        ),
+        pytest.param(
+            "{issuer}/path",
+            "/path/openid-configuration",
+            r'OAuth discovery URI ".*" is not a .well-known URI',
+            id="not .well-known",
+        ),
+        pytest.param(
+            "{issuer}",
+            "https://.well-known/oauth-authorization-server",
+            r'OAuth discovery URI ".*" is not a .well-known URI',
+            id=".well-known prefix buried in the authority",
+        ),
+        pytest.param(
+            "{issuer}",
+            "/.well-known/oauth-protected-resource",
+            r'OAuth discovery URI ".*" uses an unsupported .well-known suffix',
+            id="unknown well-known suffix",
+        ),
+        pytest.param(
+            "{issuer}/path",
+            "/path/.well-known/openid-configuration-2",
+            r'OAuth discovery URI ".*" uses an unsupported .well-known suffix',
+            id="unknown well-known suffix, OIDC style",
+        ),
+        pytest.param(
+            "{issuer}/path",
+            "/.well-known/oauth-authorization-server-2/path",
+            r'OAuth discovery URI ".*" uses an unsupported .well-known suffix',
+            id="unknown well-known suffix, IETF style",
+        ),
+        pytest.param(
+            "{issuer}",
+            "file:///.well-known/oauth-authorization-server",
+            r'OAuth discovery URI ".*" must use HTTPS',
+            id="unsupported scheme",
+        ),
+    ],
+)
+def test_bad_well_known_paths(
+    accept, openid_provider, issuer, path, expected_error, server_discovery
+):
+    if not server_discovery and "/.well-known/" not in path:
+        # An oauth_issuer without a /.well-known/ path segment is just a normal
+        # issuer identifier, so this isn't an interesting test.
+        pytest.skip("not interesting: direct discovery requires .well-known")
+
+    issuer = issuer.format(issuer=openid_provider.issuer)
+    discovery_uri = urllib.parse.urljoin(openid_provider.issuer, path)
+
+    client_id = secrets.token_hex()
+
+    def discovery_handler(*args):
+        """
+        Pass-through implementation of the discovery handler. Modifies the
+        default document to contain this test's issuer identifier.
+        """
+        code, doc = openid_provider._default_discovery_handler(*args)
+        doc["issuer"] = issuer
+        return code, doc
+
+    openid_provider.register_endpoint(None, "GET", path, discovery_handler)
+
+    def fail(*args):
+        """
+        No other endpoints should be contacted; fail if the client tries.
+        """
+        assert False, "endpoint unexpectedly called"
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", fail
+    )
+    openid_provider.register_endpoint("token_endpoint", "POST", "/token", fail)
+
+    kwargs = dict(oauth_client_id=client_id)
+    if server_discovery:
+        kwargs.update(oauth_issuer=issuer)
+    else:
+        kwargs.update(oauth_issuer=discovery_uri)
+
+    sock, client = accept(**kwargs)
+    with sock:
+        if expected_error and not server_discovery:
+            # If the client already knows the URL, it should disconnect as soon
+            # as it realizes it's not valid.
+            expect_disconnected_handshake(sock)
+        else:
+            # Otherwise, it should complete the connection.
+            handle_discovery_connection(sock, discovery_uri)
+
+    # The client should not reconnect.
+
+    if expected_error is None:
+        if server_discovery:
+            expected_error = rf"server's discovery document at {discovery_uri} \(issuer \".*\"\) is incompatible with oauth_issuer \({issuer}\)"
+        else:
+            expected_error = rf"the issuer identifier \({issuer}\) does not match oauth_issuer \(.*\)"
+
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
+def expect_disconnected_handshake(sock):
+    """
+    Helper for any tests that expect the client to disconnect immediately after
+    being sent the OAUTHBEARER SASL method. Generally speaking, this requires
+    the client to have an oauth_issuer set so that it doesn't try to go through
+    discovery.
+    """
+    with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+        # Initiate a handshake.
+        startup = pq3.recv1(conn, cls=pq3.Startup)
+        assert startup.proto == pq3.protocol(3, 0)
+
+        pq3.send(
+            conn,
+            pq3.types.AuthnRequest,
+            type=pq3.authn.SASL,
+            body=[b"OAUTHBEARER", b""],
+        )
+
+        # The client should disconnect at this point.
+        assert not conn.read(1), "client sent unexpected data"
+
+
[email protected](
+    "missing",
+    [
+        pytest.param(["oauth_issuer"], id="missing oauth_issuer"),
+        pytest.param(["oauth_client_id"], id="missing oauth_client_id"),
+        pytest.param(["oauth_client_id", "oauth_issuer"], id="missing both"),
+    ],
+)
+def test_oauth_requires_issuer_and_client_id(accept, openid_provider, missing):
+    params = dict(
+        oauth_issuer=openid_provider.issuer,
+        oauth_client_id="some-id",
+    )
+
+    # Remove required parameters. This should cause a client error after the
+    # server asks for OAUTHBEARER and the client tries to contact the issuer.
+    for k in missing:
+        del params[k]
+
+    sock, client = accept(**params)
+    with sock:
+        expect_disconnected_handshake(sock)
+
+    expected_error = "oauth_issuer and oauth_client_id are not both set"
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
+# See https://datatracker.ietf.org/doc/html/rfc6749#appendix-A for character
+# class definitions.
+all_vschars = "".join([chr(c) for c in range(0x20, 0x7F)])
+all_nqchars = "".join([chr(c) for c in range(0x21, 0x7F) if c not in (0x22, 0x5C)])
+
+
[email protected]("client_id", ["", ":", " + ", r'+=&"\/~', all_vschars])
[email protected]("secret", [None, "", ":", " + ", r'+=&"\/~', all_vschars])
[email protected]("device_code", ["", " + ", r'+=&"\/~', all_vschars])
[email protected]("scope", ["&", r"+=&/", all_nqchars])
+def test_url_encoding(accept, openid_provider, client_id, secret, device_code, scope):
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id=client_id,
+        oauth_client_secret=secret,
+        oauth_scope=scope,
+    )
+
+    user_code = f"{secrets.token_hex(2)}-{secrets.token_hex(2)}"
+    verification_url = "https://example.com/device"
+
+    access_token = secrets.token_urlsafe()
+
+    def check_client_authn(headers, params):
+        if secret is None:
+            assert "Authorization" not in headers
+            assert params["client_id"] == [client_id]
+            return
+
+        # Require the client to use Basic authn; request-body credentials are
+        # NOT RECOMMENDED (RFC 6749, Sec. 2.3.1).
+        assert "Authorization" in headers
+        assert "client_id" not in params
+
+        method, creds = headers["Authorization"].split()
+        assert method == "Basic"
+
+        decoded = base64.b64decode(creds).decode("utf-8")
+        username, password = decoded.split(":", 1)
+
+        expected_username = urllib.parse.quote_plus(client_id)
+        expected_password = urllib.parse.quote_plus(secret)
+
+        assert [username, password] == [expected_username, expected_password]
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        check_client_authn(headers, params)
+
+        if scope:
+            assert params["scope"] == [scope]
+        else:
+            assert "scope" not in params
+
+        resp = {
+            "device_code": device_code,
+            "user_code": user_code,
+            "interval": 0,
+            "verification_url": verification_url,
+            "expires_in": 5,
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    def token_endpoint(headers, params):
+        check_client_authn(headers, params)
+
+        assert params["grant_type"] == ["urn:ietf:params:oauth:grant-type:device_code"]
+        assert params["device_code"] == [device_code]
+
+        # Successfully finish the request by sending the access bearer token.
+        resp = {
+            "access_token": access_token,
+            "token_type": "bearer",
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    # First connection is a discovery request, which should result in the above
+    # endpoints being called.
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    # Second connection sends the token.
+    sock, _ = accept()
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            initial = start_oauth_handshake(conn)
+
+            # Validate and accept the token.
+            auth = get_auth_value(initial)
+            assert auth == f"Bearer {access_token}".encode("ascii")
+
+            finish_handshake(conn)
+
+
[email protected]
[email protected]("error_code", ["authorization_pending", "slow_down"])
[email protected]("retries", [1, 2])
[email protected]("omit_interval", [True, False])
+def test_oauth_retry_interval(
+    accept, openid_provider, omit_interval, retries, error_code
+):
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id="some-id",
+    )
+
+    expected_retry_interval = 5 if omit_interval else 1
+    access_token = secrets.token_urlsafe()
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        resp = {
+            "device_code": "my-device-code",
+            "user_code": "my-user-code",
+            "verification_uri": "https://example.com",
+            "expires_in": 5,
+        }
+
+        if not omit_interval:
+            resp["interval"] = expected_retry_interval
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    attempts = 0
+    last_retry = None
+    retry_lock = threading.Lock()
+    token_sent = threading.Event()
+
+    def token_endpoint(headers, params):
+        now = time.monotonic()
+
+        with retry_lock:
+            nonlocal attempts, last_retry, expected_retry_interval
+
+            # Make sure the retry interval is being respected by the client.
+            if last_retry is not None:
+                interval = now - last_retry
+                assert interval >= expected_retry_interval
+
+            last_retry = now
+
+            # If the test wants to force the client to retry, return the desired
+            # error response and decrement the retry count.
+            if attempts < retries:
+                attempts += 1
+
+                # A slow_down code requires the client to additionally increase
+                # its interval by five seconds.
+                if error_code == "slow_down":
+                    expected_retry_interval += 5
+
+                return 400, {"error": error_code}
+
+        # Successfully finish the request by sending the access bearer token,
+        # and signal the main thread to continue.
+        resp = {
+            "access_token": access_token,
+            "token_type": "bearer",
+        }
+        token_sent.set()
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    # First connection is a discovery request, which should result in the above
+    # endpoints being called.
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    # At this point the client is talking to the authorization server. Wait for
+    # that to succeed so we don't run into the accept() timeout.
+    token_sent.wait()
+
+    # Client should reconnect and send the token.
+    sock, _ = accept()
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            initial = start_oauth_handshake(conn)
+
+            # Validate and accept the token.
+            auth = get_auth_value(initial)
+            assert auth == f"Bearer {access_token}".encode("ascii")
+
+            finish_handshake(conn)
+
+
[email protected]
+def self_pipe():
+    """
+    Yields a pipe fd pair.
+    """
+
+    class _Pipe:
+        pass
+
+    p = _Pipe()
+    p.readfd, p.writefd = os.pipe()
+
+    try:
+        yield p
+    finally:
+        os.close(p.readfd)
+        os.close(p.writefd)
+
+
[email protected]("scope", [None, "", "openid email"])
[email protected](
+    "retries",
+    [
+        -1,  # no async callback
+        0,  # async callback immediately returns token
+        1,  # async callback waits on altsock once
+        2,  # async callback waits on altsock twice
+    ],
+)
[email protected](
+    "asynchronous",
+    [
+        pytest.param(False, id="synchronous"),
+        pytest.param(True, id="asynchronous"),
+    ],
+)
+def test_user_defined_flow(
+    accept, auth_data_cb, self_pipe, scope, retries, asynchronous
+):
+    issuer = "http://localhost"
+    discovery_uri = issuer + "/.well-known/openid-configuration"
+    access_token = secrets.token_urlsafe()
+
+    sock, client = accept(
+        oauth_issuer=discovery_uri,
+        oauth_client_id="some-id",
+        oauth_scope=scope,
+        async_=asynchronous,
+    )
+
+    # Track callbacks.
+    attempts = 0
+    wakeup_called = False
+    cleanup_calls = 0
+    lock = threading.Lock()
+
+    def wakeup():
+        """Writes a byte to the wakeup pipe."""
+        nonlocal wakeup_called
+        with lock:
+            wakeup_called = True
+            os.write(self_pipe.writefd, b"\0")
+
+    def get_token(pgconn, request, p_altsock):
+        """
+        Async token callback. While attempts < retries, libpq will be instructed
+        to wait on the self_pipe. When attempts == retries, the token will be
+        set.
+
+        Note that assertions and exceptions raised here are allowed but not very
+        helpful, since they can't bubble through the libpq stack to be collected
+        by the test suite. Try not to rely too heavily on them.
+        """
+        # Make sure libpq passed our user data through.
+        assert request.user == 42
+
+        with lock:
+            nonlocal attempts, wakeup_called
+
+            if attempts:
+                # If we've already started the timer, we shouldn't get a
+                # call back before it trips.
+                assert wakeup_called, "authdata hook was called before the timer"
+
+                # Drain the wakeup byte.
+                os.read(self_pipe.readfd, 1)
+
+            if attempts < retries:
+                attempts += 1
+
+                # Wake up the client in a little bit of time.
+                wakeup_called = False
+                threading.Timer(0.1, wakeup).start()
+
+                # Tell libpq to wait on the other end of the wakeup pipe.
+                p_altsock[0] = self_pipe.readfd
+                return PGRES_POLLING_READING
+
+        # Done!
+        request.token = access_token.encode()
+        return PGRES_POLLING_OK
+
+    @ctypes.CFUNCTYPE(
+        ctypes.c_int,
+        ctypes.c_void_p,
+        ctypes.POINTER(PGOAuthBearerRequest),
+        ctypes.POINTER(ctypes.c_int),
+    )
+    def get_token_wrapper(pgconn, p_request, p_altsock):
+        """
+        Translation layer between C and Python for the async callback.
+        Assertions and exceptions will be swallowed at the boundary, so make
+        sure they don't escape here.
+        """
+        try:
+            return get_token(pgconn, p_request.contents, p_altsock)
+        except Exception:
+            logging.error("Exception during async callback:\n" + traceback.format_exc())
+            return PGRES_POLLING_FAILED
+
+    @ctypes.CFUNCTYPE(None, ctypes.c_void_p, ctypes.POINTER(PGOAuthBearerRequest))
+    def cleanup(pgconn, p_request):
+        """
+        Should be called exactly once per connection.
+        """
+        nonlocal cleanup_calls
+        with lock:
+            cleanup_calls += 1
+
+    def bearer_hook(typ, pgconn, request):
+        """
+        Implementation of the PQAuthDataHook, which either sets up an async
+        callback or returns the token directly, depending on the value of
+        retries.
+
+        As above, try not to rely too much on assertions/exceptions here.
+        """
+        assert typ == PQAUTHDATA_OAUTH_BEARER_TOKEN
+        request.cleanup = cleanup
+
+        if retries < 0:
+            # Special case: return a token immediately without a callback.
+            request.token = access_token.encode()
+            return 1
+
+        # Tell libpq to call us back.
+        request.async_ = get_token_wrapper
+        request.user = ctypes.c_void_p(42)  # will be checked in the callback
+        return 1
+
+    auth_data_cb.impl = bearer_hook
+
+    # Now drive the server side.
+    if retries >= 0:
+        # First connection is a discovery request, which should result in the
+        # hook being invoked.
+        with sock:
+            handle_discovery_connection(sock, discovery_uri)
+
+        # Client should reconnect to send the token.
+        sock, _ = accept()
+
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            # Initiate a handshake, which should result in our custom callback
+            # being invoked to fetch the token.
+            initial = start_oauth_handshake(conn)
+
+            # Validate and accept the token.
+            auth = get_auth_value(initial)
+            assert auth == f"Bearer {access_token}".encode("ascii")
+
+            finish_handshake(conn)
+
+    # Check the data provided to the hook.
+    assert len(auth_data_cb.calls) == 1
+
+    call = auth_data_cb.calls[0]
+    assert call.type == PQAUTHDATA_OAUTH_BEARER_TOKEN
+    assert call.openid_configuration.decode() == discovery_uri
+    assert call.scope == (None if scope is None else scope.encode())
+
+    # Make sure we clean up after ourselves when the connection is finished.
+    client.check_completed()
+    assert cleanup_calls == 1
+
+
+def alt_patterns(*patterns):
+    """
+    Just combines multiple alternative regexes into one. It's not very efficient
+    but IMO it's easier to read and maintain.
+    """
+    pat = ""
+
+    for p in patterns:
+        if pat:
+            pat += "|"
+        pat += f"({p})"
+
+    return pat
+
+
[email protected](
+    "failure_mode, error_pattern",
+    [
+        pytest.param(
+            (
+                401,
+                {
+                    "error": "invalid_client",
+                    "error_description": "client authentication failed",
+                },
+            ),
+            r"failed to obtain device authorization: client authentication failed \(invalid_client\)",
+            id="authentication failure with description",
+        ),
+        pytest.param(
+            (400, {"error": "invalid_request"}),
+            r"failed to obtain device authorization: \(invalid_request\)",
+            id="invalid request without description",
+        ),
+        pytest.param(
+            (400, {"error": "invalid_request", "padding": "x" * 256 * 1024}),
+            r"failed to obtain device authorization: response is too large",
+            id="gigantic authz response",
+        ),
+        pytest.param(
+            (400, {}),
+            r'failed to parse token error response: field "error" is missing',
+            id="broken error response",
+        ),
+        pytest.param(
+            (401, {"error": "invalid_client"}),
+            r"failed to obtain device authorization: provider requires client authentication, and no oauth_client_secret is set \(invalid_client\)",
+            id="failed authentication without description",
+        ),
+        pytest.param(
+            (200, RawResponse(r'{ "interval": 3.5.8 }')),
+            r"failed to parse device authorization: Token .* is invalid",
+            id="non-numeric interval",
+        ),
+        pytest.param(
+            (200, RawResponse(r'{ "interval": 08 }')),
+            r"failed to parse device authorization: Token .* is invalid",
+            id="invalid numeric interval",
+        ),
+    ],
+)
+def test_oauth_device_authorization_failures(
+    accept, openid_provider, failure_mode, error_pattern
+):
+    client_id = secrets.token_hex()
+
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id=client_id,
+    )
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        return failure_mode
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    def token_endpoint(headers, params):
+        assert False, "token endpoint was invoked unexpectedly"
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    # Now make sure the client correctly failed.
+    with pytest.raises(psycopg2.OperationalError, match=error_pattern):
+        client.check_completed()
+
+
+Missing = object()  # sentinel for test_oauth_device_authorization_bad_json()
+
+
[email protected](
+    "bad_value",
+    [
+        pytest.param({"device_code": 3}, id="object"),
+        pytest.param([1, 2, 3], id="array"),
+        pytest.param("some string", id="string"),
+        pytest.param(4, id="numeric"),
+        pytest.param(False, id="boolean"),
+        pytest.param(None, id="null"),
+        pytest.param(Missing, id="missing"),
+    ],
+)
[email protected](
+    "field_name,ok_type,required",
+    [
+        ("device_code", str, True),
+        ("user_code", str, True),
+        ("verification_uri", str, True),
+        ("interval", int, False),
+    ],
+)
+def test_oauth_device_authorization_bad_json_schema(
+    accept, openid_provider, field_name, ok_type, required, bad_value
+):
+    # To make the test matrix easy, just skip the tests that aren't actually
+    # interesting (field of the correct type, missing optional field).
+    if bad_value is Missing and not required:
+        pytest.skip("not interesting: optional field")
+    elif type(bad_value) == ok_type:  # not isinstance(), because bool is an int
+        pytest.skip("not interesting: correct type")
+
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id=secrets.token_hex(),
+    )
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        # Begin with an acceptable base response...
+        resp = {
+            "device_code": "my-device-code",
+            "user_code": "my-user-code",
+            "interval": 0,
+            "verification_uri": "https://example.com",
+            "expires_in": 5,
+        }
+
+        # ...then tweak it so the client fails.
+        if bad_value is Missing:
+            del resp[field_name]
+        else:
+            resp[field_name] = bad_value
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    def token_endpoint(headers, params):
+        assert False, "token endpoint was invoked unexpectedly"
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    # Now make sure the client correctly failed.
+    if bad_value is Missing:
+        error_pattern = f'field "{field_name}" is missing'
+    elif ok_type == str:
+        error_pattern = f'field "{field_name}" must be a string'
+    elif ok_type == int:
+        error_pattern = f'field "{field_name}" must be a number'
+    else:
+        assert False, "update error_pattern for new failure mode"
+
+    with pytest.raises(psycopg2.OperationalError, match=error_pattern):
+        client.check_completed()
+
+
[email protected](
+    "failure_mode, error_pattern",
+    [
+        pytest.param(
+            (
+                400,
+                {
+                    "error": "expired_token",
+                    "error_description": "the device code has expired",
+                },
+            ),
+            r"failed to obtain access token: the device code has expired \(expired_token\)",
+            id="expired token with description",
+        ),
+        pytest.param(
+            (400, {"error": "access_denied"}),
+            r"failed to obtain access token: \(access_denied\)",
+            id="access denied without description",
+        ),
+        pytest.param(
+            (400, {"error": "access_denied", "padding": "x" * 256 * 1024}),
+            r"failed to obtain access token: response is too large",
+            id="gigantic token response",
+        ),
+        pytest.param(
+            (400, {}),
+            r'failed to parse token error response: field "error" is missing',
+            id="empty error response",
+        ),
+        pytest.param(
+            (401, {"error": "invalid_client"}),
+            r"failed to obtain access token: provider requires client authentication, and no oauth_client_secret is set \(invalid_client\)",
+            id="authentication failure without description",
+        ),
+        pytest.param(
+            (200, {}, {}),
+            r"failed to parse access token response: no content type was provided",
+            id="missing content type",
+        ),
+        pytest.param(
+            (200, {"Content-Type": "text/plain"}, {}),
+            r"failed to parse access token response: unexpected content type",
+            id="wrong content type",
+        ),
+        pytest.param(
+            (200, {"Content-Type": "application/jsonx"}, {}),
+            r"failed to parse access token response: unexpected content type",
+            id="wrong content type (correct prefix)",
+        ),
+    ],
+)
[email protected]("retries", [0, 1])
+def test_oauth_token_failures(
+    accept, openid_provider, retries, failure_mode, error_pattern
+):
+    client_id = secrets.token_hex()
+
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id=client_id,
+    )
+
+    device_code = secrets.token_hex()
+    user_code = f"{secrets.token_hex(2)}-{secrets.token_hex(2)}"
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        assert params["client_id"] == [client_id]
+
+        resp = {
+            "device_code": device_code,
+            "user_code": user_code,
+            "interval": 0,
+            "verification_uri": "https://example.com/device",
+            "expires_in": 5,
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    retry_lock = threading.Lock()
+    final_sent = False
+
+    def token_endpoint(headers, params):
+        with retry_lock:
+            nonlocal retries, final_sent
+
+            # If the test wants to force the client to retry, return an
+            # authorization_pending response and decrement the retry count.
+            if retries > 0:
+                retries -= 1
+                return 400, {"error": "authorization_pending"}
+
+            # We should only return our failure_mode response once; any further
+            # requests indicate that the client isn't correctly bailing out.
+            assert not final_sent, "client continued after token error"
+
+            final_sent = True
+
+        return failure_mode
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    # Now make sure the client correctly failed.
+    with pytest.raises(psycopg2.OperationalError, match=error_pattern):
+        client.check_completed()
+
+
[email protected](
+    "bad_value",
+    [
+        pytest.param({"device_code": 3}, id="object"),
+        pytest.param([1, 2, 3], id="array"),
+        pytest.param("some string", id="string"),
+        pytest.param(4, id="numeric"),
+        pytest.param(False, id="boolean"),
+        pytest.param(None, id="null"),
+        pytest.param(Missing, id="missing"),
+    ],
+)
[email protected](
+    "field_name,ok_type,required",
+    [
+        ("access_token", str, True),
+        ("token_type", str, True),
+    ],
+)
+def test_oauth_token_bad_json_schema(
+    accept, openid_provider, field_name, ok_type, required, bad_value
+):
+    # To make the test matrix easy, just skip the tests that aren't actually
+    # interesting (field of the correct type, missing optional field).
+    if bad_value is Missing and not required:
+        pytest.skip("not interesting: optional field")
+    elif type(bad_value) == ok_type:  # not isinstance(), because bool is an int
+        pytest.skip("not interesting: correct type")
+
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id=secrets.token_hex(),
+    )
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        resp = {
+            "device_code": "my-device-code",
+            "user_code": "my-user-code",
+            "interval": 0,
+            "verification_uri": "https://example.com",
+            "expires_in": 5,
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    def token_endpoint(headers, params):
+        # Begin with an acceptable base response...
+        resp = {
+            "access_token": secrets.token_urlsafe(),
+            "token_type": "bearer",
+        }
+
+        # ...then tweak it so the client fails.
+        if bad_value is Missing:
+            del resp[field_name]
+        else:
+            resp[field_name] = bad_value
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    # Now make sure the client correctly failed.
+    error_pattern = "failed to parse access token response: "
+    if bad_value is Missing:
+        error_pattern += f'field "{field_name}" is missing'
+    elif ok_type == str:
+        error_pattern += f'field "{field_name}" must be a string'
+    elif ok_type == int:
+        error_pattern += f'field "{field_name}" must be a number'
+    else:
+        assert False, "update error_pattern for new failure mode"
+
+    with pytest.raises(psycopg2.OperationalError, match=error_pattern):
+        client.check_completed()
+
+
[email protected]("success", [True, False])
[email protected]("scope", [None, "openid email"])
[email protected](
+    "base_response",
+    [
+        {"status": "invalid_token"},
+        {"extra_object": {"key": "value"}, "status": "invalid_token"},
+        {"extra_object": {"status": 1}, "status": "invalid_token"},
+    ],
+)
+def test_oauth_discovery(accept, openid_provider, base_response, scope, success):
+    sock, client = accept(
+        oauth_issuer=openid_provider.issuer,
+        oauth_client_id=secrets.token_hex(),
+    )
+
+    device_code = secrets.token_hex()
+    user_code = f"{secrets.token_hex(2)}-{secrets.token_hex(2)}"
+    verification_url = "https://example.com/device"
+
+    access_token = secrets.token_urlsafe()
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        if scope:
+            assert params["scope"] == [scope]
+        else:
+            assert "scope" not in params
+
+        resp = {
+            "device_code": device_code,
+            "user_code": user_code,
+            "interval": 0,
+            "verification_uri": verification_url,
+            "expires_in": 5,
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    def token_endpoint(headers, params):
+        assert params["grant_type"] == ["urn:ietf:params:oauth:grant-type:device_code"]
+        assert params["device_code"] == [device_code]
+
+        # Successfully finish the request by sending the access bearer token.
+        resp = {
+            "access_token": access_token,
+            "token_type": "bearer",
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    # Construct the response to use when failing the SASL exchange. Return a
+    # link to the discovery document, pointing to the test provider server.
+    fail_resp = {
+        **base_response,
+        "openid-configuration": openid_provider.discovery_uri,
+    }
+
+    if scope:
+        fail_resp["scope"] = scope
+
+    with sock:
+        handle_discovery_connection(sock, response=fail_resp)
+
+    # The client will connect to us a second time, using the parameters we sent
+    # it.
+    sock, _ = accept()
+
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            initial = start_oauth_handshake(conn)
+
+            # Validate the token.
+            auth = get_auth_value(initial)
+            assert auth == f"Bearer {access_token}".encode("ascii")
+
+            if success:
+                finish_handshake(conn)
+
+            else:
+                # Simulate token validation failure.
+                expected_error = "test token validation failure"
+                fail_oauth_handshake(conn, fail_resp, errmsg=expected_error)
+
+    if not success:
+        # The client should not try to connect again.
+        with pytest.raises(psycopg2.OperationalError, match=expected_error):
+            client.check_completed()
+
+
[email protected](
+    "response,expected_error",
+    [
+        pytest.param(
+            "abcde",
+            'Token "abcde" is invalid',
+            id="bad JSON: invalid syntax",
+        ),
+        pytest.param(
+            b"\xFF\xFF\xFF\xFF",
+            "server's error response is not valid UTF-8",
+            id="bad JSON: invalid encoding",
+        ),
+        pytest.param(
+            '"abcde"',
+            "top-level element must be an object",
+            id="bad JSON: top-level element is a string",
+        ),
+        pytest.param(
+            "[]",
+            "top-level element must be an object",
+            id="bad JSON: top-level element is an array",
+        ),
+        pytest.param(
+            "{}",
+            "server sent error response without a status",
+            id="bad JSON: no status member",
+        ),
+        pytest.param(
+            '{ "status": null }',
+            'field "status" must be a string',
+            id="bad JSON: null status member",
+        ),
+        pytest.param(
+            '{ "status": 0 }',
+            'field "status" must be a string',
+            id="bad JSON: int status member",
+        ),
+        pytest.param(
+            '{ "status": [ "bad" ] }',
+            'field "status" must be a string',
+            id="bad JSON: array status member",
+        ),
+        pytest.param(
+            '{ "status": { "bad": "bad" } }',
+            'field "status" must be a string',
+            id="bad JSON: object status member",
+        ),
+        pytest.param(
+            '{ "nested": { "status": "bad" } }',
+            "server sent error response without a status",
+            id="bad JSON: nested status",
+        ),
+        pytest.param(
+            '{ "status": "invalid_token" ',
+            "The input string ended unexpectedly",
+            id="bad JSON: unterminated object",
+        ),
+        pytest.param(
+            '{ "status": "invalid_token" } { }',
+            'Expected end of input, but found "{"',
+            id="bad JSON: trailing data",
+        ),
+        pytest.param(
+            '{ "status": "invalid_token", "openid-configuration": 1 }',
+            'field "openid-configuration" must be a string',
+            id="bad JSON: int openid-configuration member",
+        ),
+        pytest.param(
+            '{ "status": "invalid_token", "openid-configuration": 1 }',
+            'field "openid-configuration" must be a string',
+            id="bad JSON: int openid-configuration member",
+        ),
+        pytest.param(
+            '{ "status": "invalid_token", "openid-configuration": "", "openid-configuration": "" }',
+            'field "openid-configuration" is duplicated',
+            id="bad JSON: duplicated field",
+        ),
+        pytest.param(
+            '{ "status": "invalid_token", "scope": 1 }',
+            'field "scope" must be a string',
+            id="bad JSON: int scope member",
+        ),
+    ],
+)
+def test_oauth_discovery_server_error(accept, response, expected_error):
+    sock, client = accept(
+        oauth_issuer="https://example.com",
+        oauth_client_id=secrets.token_hex(),
+    )
+
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            initial = start_oauth_handshake(conn)
+
+            if isinstance(response, str):
+                response = response.encode("utf-8")
+
+            # Fail the SASL exchange with an invalid JSON response.
+            pq3.send(
+                conn,
+                pq3.types.AuthnRequest,
+                type=pq3.authn.SASLContinue,
+                body=response,
+            )
+
+            # The client should disconnect, so the socket is closed here. (If
+            # the client doesn't disconnect, it will report a different error
+            # below and the test will fail.)
+
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
+# All of these tests are expected to fail before libpq tries to actually attempt
+# a connection to any endpoint. To avoid hitting the network in the event that a
+# test fails, an invalid IPv4 address (256.256.256.256) is used as a hostname.
[email protected](
+    "bad_response,expected_error",
+    [
+        pytest.param(
+            (200, {"Content-Type": "text/plain"}, {}),
+            r'failed to parse OpenID discovery document: unexpected content type: "text/plain"',
+            id="not JSON",
+        ),
+        pytest.param(
+            (200, {}, {}),
+            r"failed to parse OpenID discovery document: no content type was provided",
+            id="no Content-Type",
+        ),
+        pytest.param(
+            (204, {}, None),
+            r"failed to fetch OpenID discovery document: unexpected response code 204",
+            id="no content",
+        ),
+        pytest.param(
+            (301, {"Location": "https://localhost/"}, None),
+            r"failed to fetch OpenID discovery document: unexpected response code 301",
+            id="redirection",
+        ),
+        pytest.param(
+            (404, {}),
+            r"failed to fetch OpenID discovery document: unexpected response code 404",
+            id="not found",
+        ),
+        pytest.param(
+            (200, RawResponse("blah\x00blah")),
+            r"failed to parse OpenID discovery document: response contains embedded NULLs",
+            id="NULL bytes in document",
+        ),
+        pytest.param(
+            (200, RawBytes(b"blah\xFFblah")),
+            r"failed to parse OpenID discovery document: response is not valid UTF-8",
+            id="document is not UTF-8",
+        ),
+        pytest.param(
+            (200, 123),
+            r"failed to parse OpenID discovery document: top-level element must be an object",
+            id="scalar at top level",
+        ),
+        pytest.param(
+            (200, []),
+            r"failed to parse OpenID discovery document: top-level element must be an object",
+            id="array at top level",
+        ),
+        pytest.param(
+            (200, RawResponse("{")),
+            r"failed to parse OpenID discovery document.* input string ended unexpectedly",
+            id="unclosed object",
+        ),
+        pytest.param(
+            (200, RawResponse(r'{ "hello": ] }')),
+            r"failed to parse OpenID discovery document.* Expected JSON value",
+            id="bad array",
+        ),
+        pytest.param(
+            (200, {"issuer": 123}),
+            r'failed to parse OpenID discovery document: field "issuer" must be a string',
+            id="non-string issuer",
+        ),
+        pytest.param(
+            (200, {"issuer": ["something"]}),
+            r'failed to parse OpenID discovery document: field "issuer" must be a string',
+            id="issuer array",
+        ),
+        pytest.param(
+            (200, {"issuer": {}}),
+            r'failed to parse OpenID discovery document: field "issuer" must be a string',
+            id="issuer object",
+        ),
+        pytest.param(
+            (200, {"grant_types_supported": 123}),
+            r'failed to parse OpenID discovery document: field "grant_types_supported" must be an array of strings',
+            id="numeric grant types field",
+        ),
+        pytest.param(
+            (
+                200,
+                {
+                    "grant_types_supported": "urn:ietf:params:oauth:grant-type:device_code"
+                },
+            ),
+            r'failed to parse OpenID discovery document: field "grant_types_supported" must be an array of strings',
+            id="string grant types field",
+        ),
+        pytest.param(
+            (200, {"grant_types_supported": {}}),
+            r'failed to parse OpenID discovery document: field "grant_types_supported" must be an array of strings',
+            id="object grant types field",
+        ),
+        pytest.param(
+            (200, {"grant_types_supported": [123]}),
+            r'failed to parse OpenID discovery document: field "grant_types_supported" must be an array of strings',
+            id="non-string grant types",
+        ),
+        pytest.param(
+            (200, {"grant_types_supported": ["something", 123]}),
+            r'failed to parse OpenID discovery document: field "grant_types_supported" must be an array of strings',
+            id="non-string grant types later in the list",
+        ),
+        pytest.param(
+            (200, {"grant_types_supported": ["something", {}]}),
+            r'failed to parse OpenID discovery document: field "grant_types_supported" must be an array of strings',
+            id="object grant types later in the list",
+        ),
+        pytest.param(
+            (200, {"grant_types_supported": ["something", ["something"]]}),
+            r'failed to parse OpenID discovery document: field "grant_types_supported" must be an array of strings',
+            id="embedded array grant types later in the list",
+        ),
+        pytest.param(
+            (
+                200,
+                {
+                    "grant_types_supported": ["something"],
+                    "token_endpoint": "https://256.256.256.256/",
+                    "issuer": 123,
+                },
+            ),
+            r'failed to parse OpenID discovery document: field "issuer" must be a string',
+            id="non-string issuer after other valid fields",
+        ),
+        pytest.param(
+            (
+                200,
+                {
+                    "ignored": {"grant_types_supported": 123, "token_endpoint": 123},
+                    "issuer": 123,
+                },
+            ),
+            r'failed to parse OpenID discovery document: field "issuer" must be a string',
+            id="non-string issuer after other ignored fields",
+        ),
+        pytest.param(
+            (200, {"token_endpoint": "https://256.256.256.256/"}),
+            r'failed to parse OpenID discovery document: field "issuer" is missing',
+            id="missing issuer",
+        ),
+        pytest.param(
+            (200, {"issuer": "{issuer}"}),
+            r'failed to parse OpenID discovery document: field "token_endpoint" is missing',
+            id="missing token endpoint",
+        ),
+        pytest.param(
+            (
+                200,
+                {
+                    "issuer": "{issuer}",
+                    "token_endpoint": "https://256.256.256.256/token",
+                    "grant_types_supported": [
+                        "urn:ietf:params:oauth:grant-type:device_code"
+                    ],
+                },
+            ),
+            r'cannot run OAuth device authorization: issuer "https://.*" does not provide a device authorization endpoint',
+            id="missing device_authorization_endpoint",
+        ),
+        pytest.param(
+            (
+                200,
+                {
+                    "issuer": "{issuer}",
+                    "token_endpoint": "https://256.256.256.256/token",
+                    "grant_types_supported": [
+                        "urn:ietf:params:oauth:grant-type:device_code"
+                    ],
+                    "device_authorization_endpoint": "https://256.256.256.256/dev",
+                    "filler": "x" * 256 * 1024,
+                },
+            ),
+            r"failed to fetch OpenID discovery document: response is too large",
+            id="gigantic discovery response",
+        ),
+        pytest.param(
+            (
+                200,
+                {
+                    "issuer": "{issuer}/path",
+                    "token_endpoint": "https://256.256.256.256/token",
+                    "grant_types_supported": [
+                        "urn:ietf:params:oauth:grant-type:device_code"
+                    ],
+                    "device_authorization_endpoint": "https://256.256.256.256/dev",
+                },
+            ),
+            r"failed to parse OpenID discovery document: the issuer identifier \(https://.*/path\) does not match oauth_issuer \(https://.*\)",
+            id="mismatched issuer identifier",
+        ),
+        pytest.param(
+            (
+                200,
+                RawResponse(
+                    """{
+                        "issuer": "https://256.256.256.256/path",
+                        "token_endpoint": "https://256.256.256.256/token",
+                        "grant_types_supported": [
+                            "urn:ietf:params:oauth:grant-type:device_code"
+                        ],
+                        "device_authorization_endpoint": "https://256.256.256.256/dev",
+                        "device_authorization_endpoint": "https://256.256.256.256/dev"
+                    }"""
+                ),
+            ),
+            r'failed to parse OpenID discovery document: field "device_authorization_endpoint" is duplicated',
+            id="duplicated field",
+        ),
+        #
+        # Exercise HTTP-level failures by breaking the protocol. Note that the
+        # error messages here are implementation-dependent.
+        #
+        pytest.param(
+            (1000, {}),
+            r"failed to fetch OpenID discovery document: Unsupported protocol \(.*\)",
+            id="invalid HTTP response code",
+        ),
+        pytest.param(
+            (200, {"Content-Length": -1}, {}),
+            r"failed to fetch OpenID discovery document: Weird server reply \(.*Content-Length.*\)",
+            id="bad HTTP Content-Length",
+        ),
+    ],
+)
+def test_oauth_discovery_provider_failure(
+    accept, openid_provider, bad_response, expected_error
+):
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id=secrets.token_hex(),
+    )
+
+    def failing_discovery_handler(headers, params):
+        try:
+            # Insert the correct issuer value if the test wants to.
+            resp = bad_response[1]
+            iss = resp["issuer"]
+            resp["issuer"] = iss.format(issuer=openid_provider.issuer)
+        except (AttributeError, KeyError, TypeError):
+            pass
+
+        return bad_response
+
+    openid_provider.register_endpoint(
+        None,
+        "GET",
+        "/.well-known/openid-configuration",
+        failing_discovery_handler,
+    )
+
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
[email protected](
+    "sasl_err,resp_type,resp_payload,expected_error",
+    [
+        pytest.param(
+            {"status": "invalid_request"},
+            pq3.types.ErrorResponse,
+            dict(
+                fields=[b"SFATAL", b"C28000", b"Mexpected error message", b""],
+            ),
+            "server rejected OAuth bearer token: invalid_request",
+            id="standard server error: invalid_request",
+        ),
+        pytest.param(
+            {"status": "invalid_token"},
+            pq3.types.ErrorResponse,
+            dict(
+                fields=[b"SFATAL", b"C28000", b"Mexpected error message", b""],
+            ),
+            "expected error message",
+            id="standard server error: invalid_token without discovery URI",
+        ),
+        pytest.param(
+            {"status": "invalid_token", "openid-configuration": ""},
+            pq3.types.AuthnRequest,
+            dict(type=pq3.authn.SASLContinue, body=b""),
+            "server sent additional OAuth data",
+            id="broken server: additional challenge after error",
+        ),
+        pytest.param(
+            {"status": "invalid_token", "openid-configuration": ""},
+            pq3.types.AuthnRequest,
+            dict(type=pq3.authn.SASLFinal),
+            "server sent additional OAuth data",
+            id="broken server: SASL success after error",
+        ),
+        pytest.param(
+            {"status": "invalid_token", "openid-configuration": ""},
+            pq3.types.AuthnRequest,
+            dict(type=pq3.authn.SASL, body=[b"OAUTHBEARER", b""]),
+            "duplicate SASL authentication request",
+            id="broken server: SASL reinitialization after error",
+        ),
+    ],
+)
+def test_oauth_server_error(
+    accept, auth_data_cb, sasl_err, resp_type, resp_payload, expected_error
+):
+    wkuri = f"https://256.256.256.256/.well-known/openid-configuration"
+    sock, client = accept(
+        oauth_issuer=wkuri,
+        oauth_client_id="some-id",
+    )
+
+    def bearer_hook(typ, pgconn, request):
+        """
+        Implementation of the PQAuthDataHook, which returns a token directly so
+        we don't need an openid_provider instance.
+        """
+        assert typ == PQAUTHDATA_OAUTH_BEARER_TOKEN
+        request.token = secrets.token_urlsafe().encode()
+        return 1
+
+    auth_data_cb.impl = bearer_hook
+
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            start_oauth_handshake(conn)
+
+            # Ignore the client data. Return an error "challenge".
+            if "openid-configuration" in sasl_err:
+                sasl_err["openid-configuration"] = wkuri
+
+            resp = json.dumps(sasl_err)
+            resp = resp.encode("utf-8")
+
+            pq3.send(
+                conn, pq3.types.AuthnRequest, type=pq3.authn.SASLContinue, body=resp
+            )
+
+            # Per RFC, the client is required to send a dummy ^A response.
+            pkt = pq3.recv1(conn)
+            assert pkt.type == pq3.types.PasswordMessage
+            assert pkt.payload == b"\x01"
+
+            # Now fail the SASL exchange (in either a valid way, or an
+            # invalid one, depending on the test).
+            pq3.send(conn, resp_type, **resp_payload)
+
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
+def test_oauth_interval_overflow(accept, openid_provider):
+    """
+    A really badly behaved server could send a huge interval and then
+    immediately tell us to slow_down; ensure we handle this without breaking.
+    """
+    # (should be equivalent to the INT_MAX in limits.h)
+    int_max = ctypes.c_uint(-1).value // 2
+
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id=secrets.token_hex(),
+    )
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        resp = {
+            "device_code": "my-device-code",
+            "user_code": "my-user-code",
+            "verification_uri": "https://example.com",
+            "expires_in": 5,
+            "interval": int_max,
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    def token_endpoint(headers, params):
+        return 400, {"error": "slow_down"}
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    with sock:
+        handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+    expected_error = "slow_down interval overflow"
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
+def test_oauth_refuses_http(accept, openid_provider, monkeypatch):
+    """
+    HTTP must be refused without PGOAUTHDEBUG.
+    """
+    monkeypatch.delenv("PGOAUTHDEBUG")
+
+    def to_http(uri):
+        """Swaps out a URI's scheme for http."""
+        parts = urllib.parse.urlparse(uri)
+        parts = parts._replace(scheme="http")
+        return urllib.parse.urlunparse(parts)
+
+    sock, client = accept(
+        oauth_issuer=to_http(openid_provider.issuer),
+        oauth_client_id=secrets.token_hex(),
+    )
+
+    # No provider callbacks necessary; we should fail immediately.
+
+    with sock:
+        handle_discovery_connection(sock, to_http(openid_provider.discovery_uri))
+
+    expected_error = r'OAuth discovery URI ".*" must use HTTPS'
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
[email protected]("auth_type", [pq3.authn.OK, pq3.authn.SASLFinal])
+def test_discovery_incorrectly_permits_connection(accept, auth_type):
+    """
+    Incorrectly responds to a client's discovery request with AuthenticationOK
+    or AuthenticationSASLFinal. require_auth=oauth should catch the former, and
+    the mechanism itself should catch the latter.
+    """
+    issuer = "https://256.256.256.256"
+    sock, client = accept(
+        oauth_issuer=issuer,
+        oauth_client_id=secrets.token_hex(),
+        require_auth="oauth",
+    )
+
+    with sock:
+        with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+            initial = start_oauth_handshake(conn)
+
+            auth = get_auth_value(initial)
+            assert auth == b""
+
+            # Incorrectly log the client in. It should immediately disconnect.
+            pq3.send(conn, pq3.types.AuthnRequest, type=auth_type)
+            assert not conn.read(1), "client sent unexpected data"
+
+    if auth_type == pq3.authn.OK:
+        expected_error = "server did not complete authentication"
+    else:
+        expected_error = "server sent unexpected additional OAuth data"
+
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
+def test_no_discovery_url_provided(accept):
+    """
+    Tests what happens when the client doesn't know who to contact and the
+    server doesn't tell it.
+    """
+    issuer = "https://256.256.256.256"
+    sock, client = accept(
+        oauth_issuer=issuer,
+        oauth_client_id=secrets.token_hex(),
+    )
+
+    with sock:
+        handle_discovery_connection(sock, discovery=None)
+
+    expected_error = "no discovery metadata was provided"
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
+
+
[email protected]("change_between_connections", [False, True])
+def test_discovery_url_changes(accept, openid_provider, change_between_connections):
+    """
+    Ensures that the client complains if the server agrees on the issuer, but
+    disagrees on the discovery URL to be used.
+    """
+
+    # Set up our provider callbacks.
+    # NOTE that these callbacks will be called on a background thread. Don't do
+    # any unprotected state mutation here.
+
+    def authorization_endpoint(headers, params):
+        resp = {
+            "device_code": "DEV",
+            "user_code": "USER",
+            "interval": 0,
+            "verification_uri": "https://example.org",
+            "expires_in": 5,
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "device_authorization_endpoint", "POST", "/device", authorization_endpoint
+    )
+
+    def token_endpoint(headers, params):
+        resp = {
+            "access_token": secrets.token_urlsafe(),
+            "token_type": "bearer",
+        }
+
+        return 200, resp
+
+    openid_provider.register_endpoint(
+        "token_endpoint", "POST", "/token", token_endpoint
+    )
+
+    # Have the client connect.
+    sock, client = accept(
+        oauth_issuer=openid_provider.discovery_uri,
+        oauth_client_id="some-id",
+    )
+
+    other_wkuri = f"{openid_provider.issuer}/.well-known/oauth-authorization-server"
+
+    if not change_between_connections:
+        # Immediately respond with the wrong URL.
+        with sock:
+            handle_discovery_connection(sock, other_wkuri)
+
+    else:
+        # First connection; use the right URL to begin with.
+        with sock:
+            handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+        # Second connection. Reject the token and switch the URL.
+        sock, _ = accept()
+        with sock:
+            with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+                initial = start_oauth_handshake(conn)
+                get_auth_value(initial)
+
+                # Ignore the token; fail with a different discovery URL.
+                resp = {
+                    "status": "invalid_token",
+                    "openid-configuration": other_wkuri,
+                }
+                fail_oauth_handshake(conn, resp)
+
+    expected_error = rf"server's discovery document has moved to {other_wkuri} \(previous location was {openid_provider.discovery_uri}\)"
+    with pytest.raises(psycopg2.OperationalError, match=expected_error):
+        client.check_completed()
diff --git a/src/test/python/conftest.py b/src/test/python/conftest.py
new file mode 100644
index 00000000000..1a73865ee47
--- /dev/null
+++ b/src/test/python/conftest.py
@@ -0,0 +1,34 @@
+#
+# Copyright 2023 Timescale, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import os
+
+import pytest
+
+
+def pytest_addoption(parser):
+    """
+    Adds custom command line options to py.test. We add one to signal temporary
+    Postgres instance creation for the server tests.
+
+    Per pytest documentation, this must live in the top level test directory.
+    """
+    parser.addoption(
+        "--temp-instance",
+        metavar="DIR",
+        help="create a temporary Postgres instance in DIR",
+    )
+
+
[email protected](scope="session", autouse=True)
+def _check_PG_TEST_EXTRA(request):
+    """
+    Automatically skips the whole suite if PG_TEST_EXTRA doesn't contain
+    'python'. pytestmark doesn't seem to work in a top-level conftest.py, so
+    I've made this an autoused fixture instead.
+    """
+    extra_tests = os.getenv("PG_TEST_EXTRA", "").split()
+    if "python" not in extra_tests:
+        pytest.skip("Potentially unsafe test 'python' not enabled in PG_TEST_EXTRA")
diff --git a/src/test/python/meson.build b/src/test/python/meson.build
new file mode 100644
index 00000000000..e137df852ef
--- /dev/null
+++ b/src/test/python/meson.build
@@ -0,0 +1,47 @@
+# Copyright (c) 2023, PostgreSQL Global Development Group
+
+subdir('server')
+
+pytest_env = {
+  'with_libcurl': libcurl.found() ? 'yes' : 'no',
+
+  # Point to the default database; the tests will create their own databases as
+  # needed.
+  'PGDATABASE': 'postgres',
+
+  # Avoid the need for a Rust compiler on platforms without prebuilt wheels for
+  # pyca/cryptography.
+  'CRYPTOGRAPHY_DONT_BUILD_RUST': '1',
+}
+
+# Some modules (psycopg2) need OpenSSL at compile time; for platforms where we
+# might have multiple implementations installed (macOS+brew), try to use the
+# same one that libpq is using.
+if ssl.found()
+  pytest_incdir = ssl.get_variable(pkgconfig: 'includedir', default_value: '')
+  if pytest_incdir != ''
+    pytest_env += { 'CPPFLAGS': '-I@0@'.format(pytest_incdir) }
+  endif
+
+  pytest_libdir = ssl.get_variable(pkgconfig: 'libdir', default_value: '')
+  if pytest_libdir != ''
+    pytest_env += { 'LDFLAGS': '-L@0@'.format(pytest_libdir) }
+  endif
+endif
+
+tests += {
+  'name': 'python',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'pytest': {
+	'requirements': meson.current_source_dir() / 'requirements.txt',
+    'tests': [
+      './client',
+      './server',
+      './test_internals.py',
+      './test_pq3.py',
+    ],
+    'env': pytest_env,
+    'test_kwargs': {'priority': 50}, # python tests are slow, start early
+  },
+}
diff --git a/src/test/python/pq3.py b/src/test/python/pq3.py
new file mode 100644
index 00000000000..ef809e288af
--- /dev/null
+++ b/src/test/python/pq3.py
@@ -0,0 +1,740 @@
+#
+# Copyright 2021 VMware, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import contextlib
+import getpass
+import io
+import os
+import platform
+import ssl
+import sys
+import textwrap
+
+from construct import *
+
+import tls
+
+
+def protocol(major, minor):
+    """
+    Returns the protocol version, in integer format, corresponding to the given
+    major and minor version numbers.
+    """
+    return (major << 16) | minor
+
+
+# Startup
+
+StringList = GreedyRange(NullTerminated(GreedyBytes))
+
+
+class KeyValueAdapter(Adapter):
+    """
+    Turns a key-value store into a null-terminated list of null-terminated
+    strings, as presented on the wire in the startup packet.
+    """
+
+    def _encode(self, obj, context, path):
+        if isinstance(obj, list):
+            return obj
+
+        l = []
+
+        for k, v in obj.items():
+            if isinstance(k, str):
+                k = k.encode("utf-8")
+            l.append(k)
+
+            if isinstance(v, str):
+                v = v.encode("utf-8")
+            l.append(v)
+
+        l.append(b"")
+        return l
+
+    def _decode(self, obj, context, path):
+        # TODO: turn a list back into a dict
+        return obj
+
+
+KeyValues = KeyValueAdapter(StringList)
+
+_startup_payload = Switch(
+    this.proto,
+    {
+        protocol(3, 0): KeyValues,
+    },
+    default=GreedyBytes,
+)
+
+
+def _default_protocol(this):
+    try:
+        if isinstance(this.payload, (list, dict)):
+            return protocol(3, 0)
+    except AttributeError:
+        pass  # no payload passed during build
+
+    return 0
+
+
+def _startup_payload_len(this):
+    """
+    The payload field has a fixed size based on the length of the packet. But
+    if the caller hasn't supplied an explicit length at build time, we have to
+    build the payload to figure out how long it is, which requires us to know
+    the length first... This function exists solely to break the cycle.
+    """
+    assert this._building, "_startup_payload_len() cannot be called during parsing"
+
+    try:
+        payload = this.payload
+    except AttributeError:
+        return 0  # no payload
+
+    if isinstance(payload, bytes):
+        # already serialized; just use the given length
+        return len(payload)
+
+    try:
+        proto = this.proto
+    except AttributeError:
+        proto = _default_protocol(this)
+
+    data = _startup_payload.build(payload, proto=proto)
+    return len(data)
+
+
+Startup = Struct(
+    "len" / Default(Int32sb, lambda this: _startup_payload_len(this) + 8),
+    "proto" / Default(Hex(Int32sb), _default_protocol),
+    "payload" / FixedSized(this.len - 8, Default(_startup_payload, b"")),
+)
+
+# Pq3
+
+
+# Adapted from construct.core.EnumIntegerString
+class EnumNamedByte:
+    def __init__(self, val, name):
+        self._val = val
+        self._name = name
+
+    def __int__(self):
+        return ord(self._val)
+
+    def __str__(self):
+        return "(enum) %s %r" % (self._name, self._val)
+
+    def __repr__(self):
+        return "EnumNamedByte(%r)" % self._val
+
+    def __eq__(self, other):
+        if isinstance(other, EnumNamedByte):
+            other = other._val
+        if not isinstance(other, bytes):
+            return NotImplemented
+
+        return self._val == other
+
+    def __hash__(self):
+        return hash(self._val)
+
+
+# Adapted from construct.core.Enum
+class ByteEnum(Adapter):
+    def __init__(self, **mapping):
+        super(ByteEnum, self).__init__(Byte)
+        self.namemapping = {k: EnumNamedByte(v, k) for k, v in mapping.items()}
+        self.decmapping = {v: EnumNamedByte(v, k) for k, v in mapping.items()}
+
+    def __getattr__(self, name):
+        if name in self.namemapping:
+            return self.decmapping[self.namemapping[name]]
+        raise AttributeError
+
+    def _decode(self, obj, context, path):
+        b = bytes([obj])
+        try:
+            return self.decmapping[b]
+        except KeyError:
+            return EnumNamedByte(b, "(unknown)")
+
+    def _encode(self, obj, context, path):
+        if isinstance(obj, int):
+            return obj
+        elif isinstance(obj, bytes):
+            return ord(obj)
+        return int(obj)
+
+
+types = ByteEnum(
+    ErrorResponse=b"E",
+    ReadyForQuery=b"Z",
+    Query=b"Q",
+    EmptyQueryResponse=b"I",
+    AuthnRequest=b"R",
+    PasswordMessage=b"p",
+    BackendKeyData=b"K",
+    CommandComplete=b"C",
+    ParameterStatus=b"S",
+    DataRow=b"D",
+    Terminate=b"X",
+)
+
+
+authn = Enum(
+    Int32ub,
+    OK=0,
+    SASL=10,
+    SASLContinue=11,
+    SASLFinal=12,
+)
+
+
+_authn_body = Switch(
+    this.type,
+    {
+        authn.OK: Terminated,
+        authn.SASL: StringList,
+    },
+    default=GreedyBytes,
+)
+
+
+def _data_len(this):
+    assert this._building, "_data_len() cannot be called during parsing"
+
+    if not hasattr(this, "data") or this.data is None:
+        return -1
+
+    return len(this.data)
+
+
+# The protocol reuses the PasswordMessage for several authentication response
+# types, and there's no good way to figure out which is which without keeping
+# state for the entire stream. So this is a separate Construct that can be
+# explicitly parsed/built by code that knows it's needed.
+SASLInitialResponse = Struct(
+    "name" / NullTerminated(GreedyBytes),
+    "len" / Default(Int32sb, lambda this: _data_len(this)),
+    "data"
+    / IfThenElse(
+        # Allow tests to explicitly pass an incorrect length during testing, by
+        # not enforcing a FixedSized during build. (The len calculation above
+        # defaults to the correct size.)
+        this._building,
+        Optional(GreedyBytes),
+        If(this.len != -1, Default(FixedSized(this.len, GreedyBytes), b"")),
+    ),
+    Terminated,  # make sure the entire response is consumed
+)
+
+
+_column = FocusedSeq(
+    "data",
+    "len" / Default(Int32sb, lambda this: _data_len(this)),
+    "data" / If(this.len != -1, FixedSized(this.len, GreedyBytes)),
+)
+
+
+_payload_map = {
+    types.ErrorResponse: Struct("fields" / StringList),
+    types.ReadyForQuery: Struct("status" / Bytes(1)),
+    types.Query: Struct("query" / NullTerminated(GreedyBytes)),
+    types.EmptyQueryResponse: Terminated,
+    types.AuthnRequest: Struct("type" / authn, "body" / Default(_authn_body, b"")),
+    types.BackendKeyData: Struct("pid" / Int32ub, "key" / Hex(Int32ub)),
+    types.CommandComplete: Struct("tag" / NullTerminated(GreedyBytes)),
+    types.ParameterStatus: Struct(
+        "name" / NullTerminated(GreedyBytes), "value" / NullTerminated(GreedyBytes)
+    ),
+    types.DataRow: Struct("columns" / Default(PrefixedArray(Int16sb, _column), b"")),
+    types.Terminate: Terminated,
+}
+
+
+_payload = FocusedSeq(
+    "_payload",
+    "_payload"
+    / Switch(
+        this._.type,
+        _payload_map,
+        default=GreedyBytes,
+    ),
+    Terminated,  # make sure every payload consumes the entire packet
+)
+
+
+def _payload_len(this):
+    """
+    See _startup_payload_len() for an explanation.
+    """
+    assert this._building, "_payload_len() cannot be called during parsing"
+
+    try:
+        payload = this.payload
+    except AttributeError:
+        return 0  # no payload
+
+    if isinstance(payload, bytes):
+        # already serialized; just use the given length
+        return len(payload)
+
+    data = _payload.build(payload, type=this.type)
+    return len(data)
+
+
+Pq3 = Struct(
+    "type" / types,
+    "len" / Default(Int32ub, lambda this: _payload_len(this) + 4),
+    "payload"
+    / IfThenElse(
+        # Allow tests to explicitly pass an incorrect length during testing, by
+        # not enforcing a FixedSized during build. (The len calculation above
+        # defaults to the correct size.)
+        this._building,
+        Optional(_payload),
+        FixedSized(this.len - 4, Default(_payload, b"")),
+    ),
+)
+
+
+# Environment
+
+
+def pghost():
+    return os.environ.get("PGHOST", default="localhost")
+
+
+def pgport():
+    return int(os.environ.get("PGPORT", default=5432))
+
+
+def pguser():
+    try:
+        return os.environ["PGUSER"]
+    except KeyError:
+        if platform.system() == "Windows":
+            # libpq defaults to GetUserName() on Windows.
+            return os.getlogin()
+        return getpass.getuser()
+
+
+def pgdatabase():
+    return os.environ.get("PGDATABASE", default="postgres")
+
+
+# Connections
+
+
+def _hexdump_translation_map():
+    """
+    For hexdumps. Translates any unprintable or non-ASCII bytes into '.'.
+    """
+    input = bytearray()
+
+    for i in range(128):
+        c = chr(i)
+
+        if not c.isprintable():
+            input += bytes([i])
+
+    input += bytes(range(128, 256))
+
+    return bytes.maketrans(input, b"." * len(input))
+
+
+class _DebugStream(object):
+    """
+    Wraps a file-like object and adds hexdumps of the read and write data. Call
+    end_packet() on a _DebugStream to write the accumulated hexdumps to the
+    output stream, along with the packet that was sent.
+    """
+
+    _translation_map = _hexdump_translation_map()
+
+    def __init__(self, stream, out=sys.stdout):
+        """
+        Creates a new _DebugStream wrapping the given stream (which must have
+        been created by wrap()). All attributes not provided by the _DebugStream
+        are delegated to the wrapped stream. out is the text stream to which
+        hexdumps are written.
+        """
+        self.raw = stream
+        self._out = out
+        self._rbuf = io.BytesIO()
+        self._wbuf = io.BytesIO()
+
+    def __getattr__(self, name):
+        return getattr(self.raw, name)
+
+    def __setattr__(self, name, value):
+        if name in ("raw", "_out", "_rbuf", "_wbuf"):
+            return object.__setattr__(self, name, value)
+
+        setattr(self.raw, name, value)
+
+    def read(self, *args, **kwargs):
+        buf = self.raw.read(*args, **kwargs)
+
+        self._rbuf.write(buf)
+        return buf
+
+    def write(self, b):
+        self._wbuf.write(b)
+        return self.raw.write(b)
+
+    def recv(self, *args):
+        buf = self.raw.recv(*args)
+
+        self._rbuf.write(buf)
+        return buf
+
+    def _flush(self, buf, prefix):
+        width = 16
+        hexwidth = width * 3 - 1
+
+        count = 0
+        buf.seek(0)
+
+        while True:
+            line = buf.read(16)
+
+            if not line:
+                if count:
+                    self._out.write("\n")  # separate the output block with a newline
+                return
+
+            self._out.write("%s %04X:\t" % (prefix, count))
+            self._out.write("%*s\t" % (-hexwidth, line.hex(" ")))
+            self._out.write(line.translate(self._translation_map).decode("ascii"))
+            self._out.write("\n")
+
+            count += 16
+
+    def print_debug(self, obj, *, prefix=""):
+        contents = ""
+        if obj is not None:
+            contents = str(obj)
+
+        for line in contents.splitlines():
+            self._out.write("%s%s\n" % (prefix, line))
+
+        self._out.write("\n")
+
+    def flush_debug(self, *, prefix=""):
+        self._flush(self._rbuf, prefix + "<")
+        self._rbuf = io.BytesIO()
+
+        self._flush(self._wbuf, prefix + ">")
+        self._wbuf = io.BytesIO()
+
+    def end_packet(self, pkt, *, read=False, prefix="", indent="  "):
+        """
+        Marks the end of a logical "packet" of data. A string representation of
+        pkt will be printed, and the debug buffers will be flushed with an
+        indent. All lines can be optionally prefixed.
+
+        If read is True, the packet representation is written after the debug
+        buffers; otherwise the default of False (meaning write) causes the
+        packet representation to be dumped first. This is meant to capture the
+        logical flow of layer translation.
+        """
+        write = not read
+
+        if write:
+            self.print_debug(pkt, prefix=prefix + "> ")
+
+        self.flush_debug(prefix=prefix + indent)
+
+        if read:
+            self.print_debug(pkt, prefix=prefix + "< ")
+
+
[email protected]
+def wrap(socket, *, debug_stream=None):
+    """
+    Transforms a raw socket into a connection that can be used for Construct
+    building and parsing. The return value is a context manager and can be used
+    in a with statement.
+    """
+    # It is critical that buffering be disabled here, so that we can still
+    # manipulate the raw socket without desyncing the stream.
+    with socket.makefile("rwb", buffering=0) as sfile:
+        # Expose the original socket's recv() on the SocketIO object we return.
+        def recv(self, *args):
+            return socket.recv(*args)
+
+        sfile.recv = recv.__get__(sfile)
+
+        conn = sfile
+        if debug_stream:
+            conn = _DebugStream(conn, debug_stream)
+
+        try:
+            yield conn
+        finally:
+            if debug_stream:
+                conn.flush_debug(prefix="? ")
+
+
+def _send(stream, cls, obj):
+    debugging = hasattr(stream, "flush_debug")
+    out = io.BytesIO()
+
+    # Ideally we would build directly to the passed stream, but because we need
+    # to reparse the generated output for the debugging case, build to an
+    # intermediate BytesIO and send it instead.
+    cls.build_stream(obj, out)
+    buf = out.getvalue()
+
+    stream.write(buf)
+    if debugging:
+        pkt = cls.parse(buf)
+        stream.end_packet(pkt)
+
+    stream.flush()
+
+
+def send(stream, packet_type, payload_data=None, **payloadkw):
+    """
+    Sends a packet on the given pq3 connection. type is the pq3.types member
+    that should be assigned to the packet. If payload_data is given, it will be
+    used as the packet payload; otherwise the key/value pairs in payloadkw will
+    be the payload contents.
+    """
+    data = payloadkw
+
+    if payload_data is not None:
+        if payloadkw:
+            raise ValueError(
+                "payload_data and payload keywords may not be used simultaneously"
+            )
+
+        data = payload_data
+
+    _send(stream, Pq3, dict(type=packet_type, payload=data))
+
+
+def send_startup(stream, proto=None, **kwargs):
+    """
+    Sends a startup packet on the given pq3 connection. In most cases you should
+    use the handshake functions instead, which will do this for you.
+
+    By default, a protocol version 3 packet will be sent. This can be overridden
+    with the proto parameter.
+    """
+    pkt = {}
+
+    if proto is not None:
+        pkt["proto"] = proto
+    if kwargs:
+        pkt["payload"] = kwargs
+
+    _send(stream, Startup, pkt)
+
+
+def recv1(stream, *, cls=Pq3):
+    """
+    Receives a single pq3 packet from the given stream and returns it.
+    """
+    resp = cls.parse_stream(stream)
+
+    debugging = hasattr(stream, "flush_debug")
+    if debugging:
+        stream.end_packet(resp, read=True)
+
+    return resp
+
+
+def handshake(stream, **kwargs):
+    """
+    Performs a libpq v3 startup handshake. kwargs should contain the key/value
+    parameters to send to the server in the startup packet.
+    """
+    # Send our startup parameters.
+    send_startup(stream, **kwargs)
+
+    # Receive and dump packets until the server indicates it's ready for our
+    # first query.
+    while True:
+        resp = recv1(stream)
+        if resp is None:
+            raise RuntimeError("server closed connection during handshake")
+
+        if resp.type == types.ReadyForQuery:
+            return
+        elif resp.type == types.ErrorResponse:
+            raise RuntimeError(
+                f"received error response from peer: {resp.payload.fields!r}"
+            )
+
+
+# TLS
+
+
+class _TLSStream(object):
+    """
+    A file-like object that performs TLS encryption/decryption on a wrapped
+    stream. Differs from ssl.SSLSocket in that we have full visibility and
+    control over the TLS layer.
+    """
+
+    def __init__(self, stream, context):
+        self._stream = stream
+        self._debugging = hasattr(stream, "flush_debug")
+
+        self._in = ssl.MemoryBIO()
+        self._out = ssl.MemoryBIO()
+        self._ssl = context.wrap_bio(self._in, self._out)
+
+    def handshake(self):
+        try:
+            self._pump(lambda: self._ssl.do_handshake())
+        finally:
+            self._flush_debug(prefix="? ")
+
+    def read(self, *args):
+        return self._pump(lambda: self._ssl.read(*args))
+
+    def write(self, *args):
+        return self._pump(lambda: self._ssl.write(*args))
+
+    def _decode(self, buf):
+        """
+        Attempts to decode a buffer of TLS data into a packet representation
+        that can be printed.
+
+        TODO: handle buffers (and record fragments) that don't align with packet
+        boundaries.
+        """
+        end = len(buf)
+        bio = io.BytesIO(buf)
+
+        ret = io.StringIO()
+
+        while bio.tell() < end:
+            record = tls.Plaintext.parse_stream(bio)
+
+            if ret.tell() > 0:
+                ret.write("\n")
+            ret.write("[Record] ")
+            ret.write(str(record))
+            ret.write("\n")
+
+            if record.type == tls.ContentType.handshake:
+                record_cls = tls.Handshake
+            else:
+                continue
+
+            innerlen = len(record.fragment)
+            inner = io.BytesIO(record.fragment)
+
+            while inner.tell() < innerlen:
+                msg = record_cls.parse_stream(inner)
+
+                indented = "[Message] " + str(msg)
+                indented = textwrap.indent(indented, "    ")
+
+                ret.write("\n")
+                ret.write(indented)
+                ret.write("\n")
+
+        return ret.getvalue()
+
+    def flush(self):
+        if not self._out.pending:
+            self._stream.flush()
+            return
+
+        buf = self._out.read()
+        self._stream.write(buf)
+
+        if self._debugging:
+            pkt = self._decode(buf)
+            self._stream.end_packet(pkt, prefix="  ")
+
+        self._stream.flush()
+
+    def _pump(self, operation):
+        while True:
+            try:
+                return operation()
+            except (ssl.SSLWantReadError, ssl.SSLWantWriteError) as e:
+                want = e
+            self._read_write(want)
+
+    def _recv(self, maxsize):
+        buf = self._stream.recv(4096)
+        if not buf:
+            self._in.write_eof()
+            return
+
+        self._in.write(buf)
+
+        if not self._debugging:
+            return
+
+        pkt = self._decode(buf)
+        self._stream.end_packet(pkt, read=True, prefix="  ")
+
+    def _read_write(self, want):
+        # XXX This needs work. So many corner cases yet to handle. For one,
+        # doing blocking writes in flush may lead to distributed deadlock if the
+        # peer is already blocking on its writes.
+
+        if isinstance(want, ssl.SSLWantWriteError):
+            assert self._out.pending, "SSL backend wants write without data"
+
+        self.flush()
+
+        if isinstance(want, ssl.SSLWantReadError):
+            self._recv(4096)
+
+    def _flush_debug(self, prefix):
+        if not self._debugging:
+            return
+
+        self._stream.flush_debug(prefix=prefix)
+
+
[email protected]
+def tls_handshake(stream, context):
+    """
+    Performs a TLS handshake over the given stream (which must have been created
+    via a call to wrap()), and returns a new stream which transparently tunnels
+    data over the TLS connection.
+
+    If the passed stream has debugging enabled, the returned stream will also
+    have debugging, using the same output IO.
+    """
+    debugging = hasattr(stream, "flush_debug")
+
+    # Send our startup parameters.
+    send_startup(stream, proto=protocol(1234, 5679))
+
+    # Look at the SSL response.
+    resp = stream.read(1)
+    if debugging:
+        stream.flush_debug(prefix="  ")
+
+    if resp == b"N":
+        raise RuntimeError("server does not support SSLRequest")
+    if resp != b"S":
+        raise RuntimeError(f"unexpected response of type {resp!r} during TLS startup")
+
+    tls = _TLSStream(stream, context)
+    tls.handshake()
+
+    if debugging:
+        tls = _DebugStream(tls, stream._out)
+
+    try:
+        yield tls
+        # TODO: teardown/unwrap the connection?
+    finally:
+        if debugging:
+            tls.flush_debug(prefix="? ")
diff --git a/src/test/python/pytest.ini b/src/test/python/pytest.ini
new file mode 100644
index 00000000000..ab7a6e7fb96
--- /dev/null
+++ b/src/test/python/pytest.ini
@@ -0,0 +1,4 @@
+[pytest]
+
+markers =
+    slow: mark test as slow
diff --git a/src/test/python/requirements.txt b/src/test/python/requirements.txt
new file mode 100644
index 00000000000..0dfcffb83e0
--- /dev/null
+++ b/src/test/python/requirements.txt
@@ -0,0 +1,11 @@
+black
+# cryptography 35.x and later add many platform/toolchain restrictions, beware
+cryptography~=3.4.8
+# TODO: figure out why 2.10.70 broke things
+# (probably https://github.com/construct/construct/pull/1015)
+construct==2.10.69
+isort~=5.6
+# TODO: update to psycopg[c] 3.1
+psycopg2~=2.9.7
+pytest~=7.3
+pytest-asyncio~=0.21.0
diff --git a/src/test/python/server/__init__.py b/src/test/python/server/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/src/test/python/server/conftest.py b/src/test/python/server/conftest.py
new file mode 100644
index 00000000000..42af80c73ee
--- /dev/null
+++ b/src/test/python/server/conftest.py
@@ -0,0 +1,141 @@
+#
+# Portions Copyright 2021 VMware, Inc.
+# Portions Copyright 2023 Timescale, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import collections
+import contextlib
+import os
+import shutil
+import socket
+import subprocess
+import sys
+
+import pytest
+
+import pq3
+
+BLOCKING_TIMEOUT = 2  # the number of seconds to wait for blocking calls
+
+
+def cleanup_prior_instance(datadir):
+    """
+    Clean up an existing data directory, but make sure it actually looks like a
+    data directory first. (Empty folders will remain untouched, since initdb can
+    populate them.)
+    """
+    required_entries = set(["base", "PG_VERSION", "postgresql.conf"])
+    empty = True
+
+    try:
+        with os.scandir(datadir) as entries:
+            for e in entries:
+                empty = False
+                required_entries.discard(e.name)
+
+    except FileNotFoundError:
+        return  # nothing to clean up
+
+    if empty:
+        return  # initdb can handle an empty datadir
+
+    if required_entries:
+        pytest.fail(
+            f"--temp-instance directory \"{datadir}\" is not empty and doesn't look like a data directory (missing {', '.join(required_entries)})"
+        )
+
+    # Okay, seems safe enough now.
+    shutil.rmtree(datadir)
+
+
[email protected](scope="session")
+def postgres_instance(pytestconfig, unused_tcp_port_factory):
+    """
+    If --temp-instance has been passed to pytest, this fixture runs a temporary
+    Postgres instance on an available port. Otherwise, the fixture will attempt
+    to contact a running Postgres server on (PGHOST, PGPORT); dependent tests
+    will be skipped if the connection fails.
+
+    Yields a (host, port) tuple for connecting to the server.
+    """
+    PGInstance = collections.namedtuple("PGInstance", ["addr", "temporary"])
+
+    datadir = pytestconfig.getoption("temp_instance")
+    if datadir:
+        # We were told to create a temporary instance. Use pg_ctl to set it up
+        # on an unused port.
+        cleanup_prior_instance(datadir)
+        subprocess.run(["pg_ctl", "-D", datadir, "init"], check=True)
+
+        # The CI looks for *.log files to upload, so the file name here isn't
+        # completely arbitrary.
+        log = os.path.join(datadir, "postmaster.log")
+        port = unused_tcp_port_factory()
+
+        subprocess.run(
+            [
+                "pg_ctl",
+                "-D",
+                datadir,
+                "-l",
+                log,
+                "-o",
+                " ".join(
+                    [
+                        f"-c port={port}",
+                        "-c listen_addresses=localhost",
+                        "-c log_connections=on",
+                        "-c session_preload_libraries=oauthtest",
+                        "-c oauth_validator_libraries=oauthtest",
+                    ]
+                ),
+                "start",
+            ],
+            check=True,
+        )
+
+        yield ("localhost", port)
+
+        subprocess.run(["pg_ctl", "-D", datadir, "stop"], check=True)
+
+    else:
+        # Try to contact an already running server; skip the suite if we can't
+        # find one.
+        addr = (pq3.pghost(), pq3.pgport())
+
+        try:
+            with socket.create_connection(addr, timeout=BLOCKING_TIMEOUT):
+                pass
+        except ConnectionError as e:
+            pytest.skip(f"unable to connect to Postgres server at {addr}: {e}")
+
+        yield addr
+
+
[email protected]
+def connect(postgres_instance):
+    """
+    A factory fixture that, when called, returns a socket connected to a
+    Postgres server, wrapped in a pq3 connection. Dependent tests will be
+    skipped if no server is available.
+    """
+    addr = postgres_instance
+
+    # Set up an ExitStack to handle safe cleanup of all of the moving pieces.
+    with contextlib.ExitStack() as stack:
+
+        def conn_factory():
+            sock = socket.create_connection(addr, timeout=BLOCKING_TIMEOUT)
+
+            # Have ExitStack close our socket.
+            stack.enter_context(sock)
+
+            # Wrap the connection in a pq3 layer and have ExitStack clean it up
+            # too.
+            wrap_ctx = pq3.wrap(sock, debug_stream=sys.stdout)
+            conn = stack.enter_context(wrap_ctx)
+
+            return conn
+
+        yield conn_factory
diff --git a/src/test/python/server/meson.build b/src/test/python/server/meson.build
new file mode 100644
index 00000000000..85534b9cc99
--- /dev/null
+++ b/src/test/python/server/meson.build
@@ -0,0 +1,18 @@
+# Copyright (c) 2024, PostgreSQL Global Development Group
+
+oauthtest_sources = files(
+  'oauthtest.c',
+)
+
+if host_system == 'windows'
+  oauthtest_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'oauthtest',
+    '--FILEDESC', 'passthrough module to validate OAuth tests',
+  ])
+endif
+
+oauthtest = shared_module('oauthtest',
+  oauthtest_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += oauthtest
diff --git a/src/test/python/server/oauthtest.c b/src/test/python/server/oauthtest.c
new file mode 100644
index 00000000000..cb7f20f4022
--- /dev/null
+++ b/src/test/python/server/oauthtest.c
@@ -0,0 +1,119 @@
+/*-------------------------------------------------------------------------
+ *
+ * oauthtest.c
+ *	  Test module for serverside OAuth token validation callbacks
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/test/python/server/oauthtest.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "fmgr.h"
+#include "libpq/oauth.h"
+#include "utils/guc.h"
+#include "utils/memutils.h"
+
+PG_MODULE_MAGIC;
+
+static void test_startup(ValidatorModuleState *state);
+static void test_shutdown(ValidatorModuleState *state);
+static bool test_validate(const ValidatorModuleState *state,
+						  const char *token,
+						  const char *role,
+						  ValidatorModuleResult *result);
+
+static const OAuthValidatorCallbacks callbacks = {
+	PG_OAUTH_VALIDATOR_MAGIC,
+
+	.startup_cb = test_startup,
+	.shutdown_cb = test_shutdown,
+	.validate_cb = test_validate,
+};
+
+static char *expected_bearer = "";
+static bool set_authn_id = false;
+static char *authn_id = "";
+static bool reflect_role = false;
+
+void
+_PG_init(void)
+{
+	DefineCustomStringVariable("oauthtest.expected_bearer",
+							   "Expected Bearer token for future connections",
+							   NULL,
+							   &expected_bearer,
+							   "",
+							   PGC_SIGHUP,
+							   0,
+							   NULL, NULL, NULL);
+
+	DefineCustomBoolVariable("oauthtest.set_authn_id",
+							 "Whether to set an authenticated identity",
+							 NULL,
+							 &set_authn_id,
+							 false,
+							 PGC_SIGHUP,
+							 0,
+							 NULL, NULL, NULL);
+	DefineCustomStringVariable("oauthtest.authn_id",
+							   "Authenticated identity to use for future connections",
+							   NULL,
+							   &authn_id,
+							   "",
+							   PGC_SIGHUP,
+							   0,
+							   NULL, NULL, NULL);
+
+	DefineCustomBoolVariable("oauthtest.reflect_role",
+							 "Ignore the bearer token; use the requested role as the authn_id",
+							 NULL,
+							 &reflect_role,
+							 false,
+							 PGC_SIGHUP,
+							 0,
+							 NULL, NULL, NULL);
+
+	MarkGUCPrefixReserved("oauthtest");
+}
+
+const OAuthValidatorCallbacks *
+_PG_oauth_validator_module_init(void)
+{
+	return &callbacks;
+}
+
+static void
+test_startup(ValidatorModuleState *state)
+{
+}
+
+static void
+test_shutdown(ValidatorModuleState *state)
+{
+}
+
+static bool
+test_validate(const ValidatorModuleState *state,
+			  const char *token, const char *role,
+			  ValidatorModuleResult *res)
+{
+	if (reflect_role)
+	{
+		res->authorized = true;
+		res->authn_id = pstrdup(role);
+	}
+	else
+	{
+		if (*expected_bearer && strcmp(token, expected_bearer) == 0)
+			res->authorized = true;
+		if (set_authn_id)
+			res->authn_id = pstrdup(authn_id);
+	}
+
+	return true;
+}
diff --git a/src/test/python/server/test_oauth.py b/src/test/python/server/test_oauth.py
new file mode 100644
index 00000000000..2839343ffa1
--- /dev/null
+++ b/src/test/python/server/test_oauth.py
@@ -0,0 +1,1080 @@
+#
+# Copyright 2021 VMware, Inc.
+# Portions Copyright 2023 Timescale, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import base64
+import contextlib
+import json
+import os
+import pathlib
+import platform
+import secrets
+import shlex
+import shutil
+import socket
+import struct
+from multiprocessing import shared_memory
+
+import psycopg2
+import pytest
+from construct import Container
+from psycopg2 import sql
+
+import pq3
+
+from .conftest import BLOCKING_TIMEOUT
+
+MAX_SASL_MESSAGE_LENGTH = 65535
+
+INVALID_AUTHORIZATION_ERRCODE = b"28000"
+PROTOCOL_VIOLATION_ERRCODE = b"08P01"
+FEATURE_NOT_SUPPORTED_ERRCODE = b"0A000"
+
+SHARED_MEM_NAME = "oauth-pytest"
+MAX_UINT16 = 2**16 - 1
+
+
[email protected]
+def prepend_file(path, lines, *, suffix=".bak"):
+    """
+    A context manager that prepends a file on disk with the desired lines of
+    text. When the context manager is exited, the file will be restored to its
+    original contents.
+    """
+    # First make a backup of the original file.
+    bak = path + suffix
+    shutil.copy2(path, bak)
+
+    try:
+        # Write the new lines, followed by the original file content.
+        with open(path, "w") as new, open(bak, "r") as orig:
+            new.writelines(lines)
+            shutil.copyfileobj(orig, new)
+
+        # Return control to the calling code.
+        yield
+
+    finally:
+        # Put the backup back into place.
+        os.replace(bak, path)
+
+
[email protected](scope="module")
+def oauth_ctx(postgres_instance):
+    """
+    Creates a database and user that use the oauth auth method. The context
+    object contains the dbname and user attributes as strings to be used during
+    connection, as well as the issuer and scope that have been set in the HBA
+    configuration.
+
+    This fixture assumes that the standard PG* environment variables point to a
+    server running on a local machine, and that the PGUSER has rights to create
+    databases and roles.
+    """
+    id = secrets.token_hex(4)
+
+    class Context:
+        dbname = "oauth_test_" + id
+
+        user = "oauth_user_" + id
+        punct_user = "oauth_\"'? ;&!_user_" + id  # username w/ punctuation
+        map_user = "oauth_map_user_" + id
+        authz_user = "oauth_authz_user_" + id
+
+        issuer = "https://example.com/" + id
+        scope = "openid " + id
+
+    ctx = Context()
+    hba_lines = [
+        f'host {ctx.dbname} {ctx.map_user}   samehost oauth issuer="{ctx.issuer}" scope="{ctx.scope}" map=oauth\n',
+        f'host {ctx.dbname} {ctx.authz_user} samehost oauth issuer="{ctx.issuer}" scope="{ctx.scope}" delegate_ident_mapping=1\n',
+        f'host {ctx.dbname} all              samehost oauth issuer="{ctx.issuer}" scope="{ctx.scope}"\n',
+    ]
+    ident_lines = [r"oauth /^(.*)@example\.com$ \1"]
+
+    if platform.system() == "Windows":
+        # XXX why is 'samehost' not behaving as expected on Windows?
+        for l in list(hba_lines):
+            hba_lines.append(l.replace("samehost", "::1/128"))
+
+    host, port = postgres_instance
+    conn = psycopg2.connect(host=host, port=port)
+    conn.autocommit = True
+
+    with contextlib.closing(conn):
+        c = conn.cursor()
+
+        # Create our roles and database.
+        user = sql.Identifier(ctx.user)
+        punct_user = sql.Identifier(ctx.punct_user)
+        map_user = sql.Identifier(ctx.map_user)
+        authz_user = sql.Identifier(ctx.authz_user)
+        dbname = sql.Identifier(ctx.dbname)
+
+        c.execute(sql.SQL("CREATE ROLE {} LOGIN;").format(user))
+        c.execute(sql.SQL("CREATE ROLE {} LOGIN;").format(punct_user))
+        c.execute(sql.SQL("CREATE ROLE {} LOGIN;").format(map_user))
+        c.execute(sql.SQL("CREATE ROLE {} LOGIN;").format(authz_user))
+        c.execute(sql.SQL("CREATE DATABASE {};").format(dbname))
+
+        # Replace pg_hba and pg_ident.
+        c.execute("SHOW hba_file;")
+        hba = c.fetchone()[0]
+
+        c.execute("SHOW ident_file;")
+        ident = c.fetchone()[0]
+
+        with prepend_file(hba, hba_lines), prepend_file(ident, ident_lines):
+            c.execute("SELECT pg_reload_conf();")
+
+            # Use the new database and user.
+            yield ctx
+
+        # Put things back the way they were.
+        c.execute("SELECT pg_reload_conf();")
+
+        c.execute(sql.SQL("DROP DATABASE {};").format(dbname))
+        c.execute(sql.SQL("DROP ROLE {};").format(authz_user))
+        c.execute(sql.SQL("DROP ROLE {};").format(map_user))
+        c.execute(sql.SQL("DROP ROLE {};").format(punct_user))
+        c.execute(sql.SQL("DROP ROLE {};").format(user))
+
+
[email protected]()
+def conn(oauth_ctx, connect):
+    """
+    A convenience wrapper for connect(). The main purpose of this fixture is to
+    make sure oauth_ctx runs its setup code before the connection is made.
+    """
+    return connect()
+
+
+def bearer_token(*, size=16):
+    """
+    Generates a Bearer token using secrets.token_urlsafe(). The generated token
+    size in bytes may be specified; if unset, a small 16-byte token will be
+    generated.
+    """
+
+    if size % 4:
+        raise ValueError(f"requested token size {size} is not a multiple of 4")
+
+    token = secrets.token_urlsafe(size // 4 * 3)
+    assert len(token) == size
+
+    return token
+
+
+def begin_oauth_handshake(conn, oauth_ctx, *, user=None):
+    if user is None:
+        user = oauth_ctx.authz_user
+
+    pq3.send_startup(conn, user=user, database=oauth_ctx.dbname)
+
+    resp = pq3.recv1(conn)
+    assert resp.type == pq3.types.AuthnRequest
+
+    # The server should advertise exactly one mechanism.
+    assert resp.payload.type == pq3.authn.SASL
+    assert resp.payload.body == [b"OAUTHBEARER", b""]
+
+
+def send_initial_response(conn, *, auth=None, bearer=None):
+    """
+    Sends the OAUTHBEARER initial response on the connection, using the given
+    bearer token. Alternatively to a bearer token, the initial response's auth
+    field may be explicitly specified to test corner cases.
+    """
+    if bearer is not None and auth is not None:
+        raise ValueError("exactly one of the auth and bearer kwargs must be set")
+
+    if bearer is not None:
+        auth = b"Bearer " + bearer
+
+    if auth is None:
+        raise ValueError("exactly one of the auth and bearer kwargs must be set")
+
+    initial = pq3.SASLInitialResponse.build(
+        dict(
+            name=b"OAUTHBEARER",
+            data=b"n,,\x01auth=" + auth + b"\x01\x01",
+        )
+    )
+    pq3.send(conn, pq3.types.PasswordMessage, initial)
+
+
+def expect_handshake_success(conn):
+    """
+    Validates that the server responds with an AuthnOK message, and then drains
+    the connection until a ReadyForQuery message is received.
+    """
+    resp = pq3.recv1(conn)
+
+    assert resp.type == pq3.types.AuthnRequest
+    assert resp.payload.type == pq3.authn.OK
+    assert not resp.payload.body
+
+    receive_until(conn, pq3.types.ReadyForQuery)
+
+
+def expect_handshake_failure(conn, oauth_ctx):
+    """
+    Performs the OAUTHBEARER SASL failure "handshake" and validates the server's
+    side of the conversation, including the final ErrorResponse.
+    """
+
+    # We expect a discovery "challenge" back from the server before the authn
+    # failure message.
+    resp = pq3.recv1(conn)
+    assert resp.type == pq3.types.AuthnRequest
+
+    req = resp.payload
+    assert req.type == pq3.authn.SASLContinue
+
+    body = json.loads(req.body)
+    assert body["status"] == "invalid_token"
+    assert body["scope"] == oauth_ctx.scope
+
+    expected_config = oauth_ctx.issuer + "/.well-known/openid-configuration"
+    assert body["openid-configuration"] == expected_config
+
+    # Send the dummy response to complete the failed handshake.
+    pq3.send(conn, pq3.types.PasswordMessage, b"\x01")
+    resp = pq3.recv1(conn)
+
+    err = ExpectedError(INVALID_AUTHORIZATION_ERRCODE, "bearer authentication failed")
+    err.match(resp)
+
+
+def receive_until(conn, type):
+    """
+    receive_until pulls packets off the pq3 connection until a packet with the
+    desired type is found, or an error response is received.
+    """
+    while True:
+        pkt = pq3.recv1(conn)
+
+        if pkt.type == type:
+            return pkt
+        elif pkt.type == pq3.types.ErrorResponse:
+            raise RuntimeError(
+                f"received error response from peer: {pkt.payload.fields!r}"
+            )
+
+
[email protected]()
+def setup_validator(postgres_instance):
+    """
+    A per-test fixture that sets up the test validator with expected behavior.
+    The setting will be reverted during teardown.
+    """
+    host, port = postgres_instance
+    conn = psycopg2.connect(host=host, port=port)
+    conn.autocommit = True
+
+    with contextlib.closing(conn):
+        c = conn.cursor()
+        prev = dict()
+
+        def setter(**gucs):
+            for guc, val in gucs.items():
+                # Save the previous value.
+                c.execute(sql.SQL("SHOW oauthtest.{};").format(sql.Identifier(guc)))
+                prev[guc] = c.fetchone()[0]
+
+                c.execute(
+                    sql.SQL("ALTER SYSTEM SET oauthtest.{} TO %s;").format(
+                        sql.Identifier(guc)
+                    ),
+                    (val,),
+                )
+                c.execute("SELECT pg_reload_conf();")
+
+        yield setter
+
+        # Restore the previous values.
+        for guc, val in prev.items():
+            c.execute(
+                sql.SQL("ALTER SYSTEM SET oauthtest.{} TO %s;").format(
+                    sql.Identifier(guc)
+                ),
+                (val,),
+            )
+            c.execute("SELECT pg_reload_conf();")
+
+
[email protected]("token_len", [16, 1024, 4096])
[email protected](
+    "auth_prefix",
+    [
+        b"Bearer ",
+        b"bearer ",
+        b"Bearer    ",
+    ],
+)
+def test_oauth(setup_validator, connect, oauth_ctx, auth_prefix, token_len):
+    # Generate our bearer token with the desired length.
+    token = bearer_token(size=token_len)
+    setup_validator(expected_bearer=token)
+
+    conn = connect()
+    begin_oauth_handshake(conn, oauth_ctx)
+
+    auth = auth_prefix + token.encode("ascii")
+    send_initial_response(conn, auth=auth)
+    expect_handshake_success(conn)
+
+    # Make sure that the server has not set an authenticated ID.
+    pq3.send(conn, pq3.types.Query, query=b"SELECT system_user;")
+    resp = receive_until(conn, pq3.types.DataRow)
+
+    row = resp.payload
+    assert row.columns == [None]
+
+
[email protected](
+    "token_value",
+    [
+        "abcdzA==",
+        "123456M=",
+        "x-._~+/x",
+    ],
+)
+def test_oauth_bearer_corner_cases(setup_validator, connect, oauth_ctx, token_value):
+    setup_validator(expected_bearer=token_value)
+
+    conn = connect()
+    begin_oauth_handshake(conn, oauth_ctx)
+
+    send_initial_response(conn, bearer=token_value.encode("ascii"))
+
+    expect_handshake_success(conn)
+
+
[email protected](
+    "user,authn_id,should_succeed",
+    [
+        pytest.param(
+            lambda ctx: ctx.user,
+            lambda ctx: ctx.user,
+            True,
+            id="validator authn: succeeds when authn_id == username",
+        ),
+        pytest.param(
+            lambda ctx: ctx.user,
+            lambda ctx: None,
+            False,
+            id="validator authn: fails when authn_id is not set",
+        ),
+        pytest.param(
+            lambda ctx: ctx.user,
+            lambda ctx: "",
+            False,
+            id="validator authn: fails when authn_id is empty",
+        ),
+        pytest.param(
+            lambda ctx: ctx.user,
+            lambda ctx: ctx.authz_user,
+            False,
+            id="validator authn: fails when authn_id != username",
+        ),
+        pytest.param(
+            lambda ctx: ctx.map_user,
+            lambda ctx: ctx.map_user + "@example.com",
+            True,
+            id="validator with map: succeeds when authn_id matches map",
+        ),
+        pytest.param(
+            lambda ctx: ctx.map_user,
+            lambda ctx: None,
+            False,
+            id="validator with map: fails when authn_id is not set",
+        ),
+        pytest.param(
+            lambda ctx: ctx.map_user,
+            lambda ctx: ctx.map_user + "@example.net",
+            False,
+            id="validator with map: fails when authn_id doesn't match map",
+        ),
+        pytest.param(
+            lambda ctx: ctx.authz_user,
+            lambda ctx: None,
+            True,
+            id="validator authz: succeeds with no authn_id",
+        ),
+        pytest.param(
+            lambda ctx: ctx.authz_user,
+            lambda ctx: "",
+            True,
+            id="validator authz: succeeds with empty authn_id",
+        ),
+        pytest.param(
+            lambda ctx: ctx.authz_user,
+            lambda ctx: "postgres",
+            True,
+            id="validator authz: succeeds with basic username",
+        ),
+        pytest.param(
+            lambda ctx: ctx.authz_user,
+            lambda ctx: "[email protected]",
+            True,
+            id="validator authz: succeeds with email address",
+        ),
+    ],
+)
+def test_oauth_authn_id(
+    setup_validator, connect, oauth_ctx, user, authn_id, should_succeed
+):
+    token = bearer_token()
+    authn_id = authn_id(oauth_ctx)
+
+    # Set up the validator appropriately.
+    gucs = dict(expected_bearer=token)
+    if authn_id is not None:
+        gucs["set_authn_id"] = True
+        gucs["authn_id"] = authn_id
+    setup_validator(**gucs)
+
+    conn = connect()
+    username = user(oauth_ctx)
+    begin_oauth_handshake(conn, oauth_ctx, user=username)
+    send_initial_response(conn, bearer=token.encode("ascii"))
+
+    if not should_succeed:
+        expect_handshake_failure(conn, oauth_ctx)
+        return
+
+    expect_handshake_success(conn)
+
+    # Check the reported authn_id.
+    pq3.send(conn, pq3.types.Query, query=b"SELECT system_user;")
+    resp = receive_until(conn, pq3.types.DataRow)
+
+    expected = authn_id
+    if expected is not None:
+        expected = b"oauth:" + expected.encode("ascii")
+
+    row = resp.payload
+    assert row.columns == [expected]
+
+
+class ExpectedError(object):
+    def __init__(self, code, msg=None, detail=None):
+        self.code = code
+        self.msg = msg
+        self.detail = detail
+
+        # Protect against the footgun of an accidental empty string, which will
+        # "match" anything. If you don't want to match message or detail, just
+        # don't pass them.
+        if self.msg == "":
+            raise ValueError("msg must be non-empty or None")
+        if self.detail == "":
+            raise ValueError("detail must be non-empty or None")
+
+    def _getfield(self, resp, type):
+        """
+        Searches an ErrorResponse for a single field of the given type (e.g.
+        "M", "C", "D") and returns its value. Asserts if it doesn't find exactly
+        one field.
+        """
+        prefix = type.encode("ascii")
+        fields = [f for f in resp.payload.fields if f.startswith(prefix)]
+
+        assert len(fields) == 1
+        return fields[0][1:]  # strip off the type byte
+
+    def match(self, resp):
+        """
+        Checks that the given response matches the expected code, message, and
+        detail (if given). The error code must match exactly. The expected
+        message and detail must be contained within the actual strings.
+        """
+        assert resp.type == pq3.types.ErrorResponse
+
+        code = self._getfield(resp, "C")
+        assert code == self.code
+
+        if self.msg:
+            msg = self._getfield(resp, "M")
+            expected = self.msg.encode("utf-8")
+            assert expected in msg
+
+        if self.detail:
+            detail = self._getfield(resp, "D")
+            expected = self.detail.encode("utf-8")
+            assert expected in detail
+
+
+def test_oauth_rejected_bearer(conn, oauth_ctx):
+    begin_oauth_handshake(conn, oauth_ctx)
+
+    # Send a bearer token that doesn't match what the validator expects. It
+    # should fail the connection.
+    send_initial_response(conn, bearer=b"xxxxxx")
+
+    expect_handshake_failure(conn, oauth_ctx)
+
+
[email protected](
+    "bad_bearer",
+    [
+        b"Bearer    ",
+        b"Bearer a===b",
+        b"Bearer hello!",
+        b"Bearer trailingspace ",
+        b"Bearer trailingtab\t",
+        b"Bearer [email protected]",
+        b"Beare abcd",
+        b" Bearer leadingspace",
+        b'OAuth realm="Example"',
+        b"",
+    ],
+)
+def test_oauth_invalid_bearer(setup_validator, connect, oauth_ctx, bad_bearer):
+    # Tell the validator to accept any token. This ensures that the invalid
+    # bearer tokens are rejected before the validation step.
+    setup_validator(reflect_role=True)
+
+    conn = connect()
+    begin_oauth_handshake(conn, oauth_ctx)
+    send_initial_response(conn, auth=bad_bearer)
+
+    expect_handshake_failure(conn, oauth_ctx)
+
+
[email protected]
[email protected](
+    "resp_type,resp,err",
+    [
+        pytest.param(
+            None,
+            None,
+            None,
+            marks=pytest.mark.slow,
+            id="no response (expect timeout)",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            b"hello",
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "did not send a kvsep response",
+            ),
+            id="bad dummy response",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            b"\x01\x01",
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "did not send a kvsep response",
+            ),
+            id="multiple kvseps",
+        ),
+        pytest.param(
+            pq3.types.Query,
+            dict(query=b""),
+            ExpectedError(PROTOCOL_VIOLATION_ERRCODE, "expected SASL response"),
+            id="bad response message type",
+        ),
+    ],
+)
+def test_oauth_bad_response_to_error_challenge(conn, oauth_ctx, resp_type, resp, err):
+    begin_oauth_handshake(conn, oauth_ctx)
+
+    # Send an empty auth initial response, which will force an authn failure.
+    send_initial_response(conn, auth=b"")
+
+    # We expect a discovery "challenge" back from the server before the authn
+    # failure message.
+    pkt = pq3.recv1(conn)
+    assert pkt.type == pq3.types.AuthnRequest
+
+    req = pkt.payload
+    assert req.type == pq3.authn.SASLContinue
+
+    body = json.loads(req.body)
+    assert body["status"] == "invalid_token"
+
+    if resp_type is None:
+        # Do not send the dummy response. We should time out and not get a
+        # response from the server.
+        with pytest.raises(socket.timeout):
+            conn.read(1)
+
+        # Done with the test.
+        return
+
+    # Send the bad response.
+    pq3.send(conn, resp_type, resp)
+
+    # Make sure the server fails the connection correctly.
+    pkt = pq3.recv1(conn)
+    err.match(pkt)
+
+
[email protected](
+    "type,payload,err",
+    [
+        pytest.param(
+            pq3.types.ErrorResponse,
+            dict(fields=[b""]),
+            ExpectedError(PROTOCOL_VIOLATION_ERRCODE, "expected SASL response"),
+            id="error response in initial message",
+        ),
+        pytest.param(
+            None,
+            # Sending an actual 65k packet results in ECONNRESET on Windows, and
+            # it floods the tests' connection log uselessly, so just fake the
+            # length and send a smaller number of bytes.
+            dict(
+                type=pq3.types.PasswordMessage,
+                len=MAX_SASL_MESSAGE_LENGTH + 1,
+                payload=b"x" * 512,
+            ),
+            ExpectedError(
+                INVALID_AUTHORIZATION_ERRCODE, "bearer authentication failed"
+            ),
+            id="overlong initial response data",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"SCRAM-SHA-256")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE, "invalid SASL authentication mechanism"
+            ),
+            id="bad SASL mechanism selection",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", len=2, data=b"x")),
+            ExpectedError(PROTOCOL_VIOLATION_ERRCODE, "insufficient data"),
+            id="SASL data underflow",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", len=0, data=b"x")),
+            ExpectedError(PROTOCOL_VIOLATION_ERRCODE, "invalid message format"),
+            id="SASL data overflow",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "message is empty",
+            ),
+            id="empty",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(name=b"OAUTHBEARER", data=b"n,,\x01auth=\x01\x01\0")
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "length does not match input length",
+            ),
+            id="contains null byte",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"\x01")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "Unexpected channel-binding flag",  # XXX this is a bit strange
+            ),
+            id="initial error response",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(name=b"OAUTHBEARER", data=b"p=tls-server-end-point,,\x01")
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "server does not support channel binding",
+            ),
+            id="uses channel binding",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"x,,\x01")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "Unexpected channel-binding flag",
+            ),
+            id="invalid channel binding specifier",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"y")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "Comma expected",
+            ),
+            id="bad GS2 header: missing channel binding terminator",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"y,a")),
+            ExpectedError(
+                FEATURE_NOT_SUPPORTED_ERRCODE,
+                "client uses authorization identity",
+            ),
+            id="bad GS2 header: authzid in use",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"y,b,")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "Unexpected attribute",
+            ),
+            id="bad GS2 header: extra attribute",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"y,")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                'Unexpected attribute "0x00"',  # XXX this is a bit strange
+            ),
+            id="bad GS2 header: missing authzid terminator",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"y,,")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "Key-value separator expected",
+            ),
+            id="missing initial kvsep",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"y,,")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "Key-value separator expected",
+            ),
+            id="missing initial kvsep",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(name=b"OAUTHBEARER", data=b"y,,\x01\x01")
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "does not contain an auth value",
+            ),
+            id="missing auth value: empty key-value list",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(name=b"OAUTHBEARER", data=b"y,,\x01host=example.com\x01\x01")
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "does not contain an auth value",
+            ),
+            id="missing auth value: other keys present",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(name=b"OAUTHBEARER", data=b"y,,\x01host=example.com")
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "unterminated key/value pair",
+            ),
+            id="missing value terminator",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER", data=b"y,,\x01")),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "did not contain a final terminator",
+            ),
+            id="missing list terminator: empty list",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(name=b"OAUTHBEARER", data=b"y,,\x01auth=Bearer 0\x01")
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "did not contain a final terminator",
+            ),
+            id="missing list terminator: with auth value",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(name=b"OAUTHBEARER", data=b"y,,\x01auth=Bearer 0\x01\x01blah")
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "additional data after the final terminator",
+            ),
+            id="additional key after terminator",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(name=b"OAUTHBEARER", data=b"y,,\x01key\x01\x01")
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "key without a value",
+            ),
+            id="key without value",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(
+                    name=b"OAUTHBEARER",
+                    data=b"y,,\x01auth=Bearer 0\x01auth=Bearer 1\x01\x01",
+                )
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "contains multiple auth values",
+            ),
+            id="multiple auth values",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(
+                    name=b"OAUTHBEARER",
+                    data=b"y,,\x01=\x01\x01",
+                )
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "empty key name",
+            ),
+            id="empty key",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(
+                    name=b"OAUTHBEARER",
+                    data=b"y,,\x01my key= \x01\x01",
+                )
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "invalid key name",
+            ),
+            id="whitespace in key name",
+        ),
+        pytest.param(
+            pq3.types.PasswordMessage,
+            pq3.SASLInitialResponse.build(
+                dict(
+                    name=b"OAUTHBEARER",
+                    data=b"y,,\x01key=a\x05b\x01\x01",
+                )
+            ),
+            ExpectedError(
+                PROTOCOL_VIOLATION_ERRCODE,
+                "malformed OAUTHBEARER message",
+                "invalid value",
+            ),
+            id="junk in value",
+        ),
+    ],
+)
+def test_oauth_bad_initial_response(conn, oauth_ctx, type, payload, err):
+    begin_oauth_handshake(conn, oauth_ctx)
+
+    # The server expects a SASL response; give it something else instead.
+    if type is not None:
+        # Build a new packet of the desired type.
+        if not isinstance(payload, dict):
+            payload = dict(payload_data=payload)
+        pq3.send(conn, type, **payload)
+    else:
+        # The test has a custom packet to send. (The only reason to do this is
+        # if the packet is corrupt or otherwise unbuildable/unparsable, so we
+        # don't use the standard pq3.send().)
+        conn.write(pq3.Pq3.build(payload))
+        conn.end_packet(Container(payload))
+
+    resp = pq3.recv1(conn)
+    err.match(resp)
+
+
+def test_oauth_empty_initial_response(setup_validator, connect, oauth_ctx):
+    token = bearer_token()
+    setup_validator(expected_bearer=token)
+
+    conn = connect()
+    begin_oauth_handshake(conn, oauth_ctx)
+
+    # Send an initial response without data.
+    initial = pq3.SASLInitialResponse.build(dict(name=b"OAUTHBEARER"))
+    pq3.send(conn, pq3.types.PasswordMessage, initial)
+
+    # The server should respond with an empty challenge so we can send the data
+    # it wants.
+    pkt = pq3.recv1(conn)
+
+    assert pkt.type == pq3.types.AuthnRequest
+    assert pkt.payload.type == pq3.authn.SASLContinue
+    assert not pkt.payload.body
+
+    # Now send the initial data.
+    data = b"n,,\x01auth=Bearer " + token.encode("ascii") + b"\x01\x01"
+    pq3.send(conn, pq3.types.PasswordMessage, data)
+
+    # Server should now complete the handshake.
+    expect_handshake_success(conn)
+
+
+# TODO: see if there's a way to test this easily after the API switch
+def xtest_oauth_no_validator(setup_validator, oauth_ctx, connect):
+    # Clear out our validator command, then establish a new connection.
+    set_validator("")
+    conn = connect()
+
+    begin_oauth_handshake(conn, oauth_ctx)
+    send_initial_response(conn, bearer=bearer_token())
+
+    # The server should fail the connection.
+    expect_handshake_failure(conn, oauth_ctx)
+
+
[email protected](
+    "user",
+    [
+        pytest.param(
+            lambda ctx: ctx.user,
+            id="basic username",
+        ),
+        pytest.param(
+            lambda ctx: ctx.punct_user,
+            id="'unsafe' characters are passed through correctly",
+        ),
+    ],
+)
+def test_oauth_validator_role(setup_validator, oauth_ctx, connect, user):
+    username = user(oauth_ctx)
+
+    # Tell the validator to reflect the PGUSER as the authenticated identity.
+    setup_validator(reflect_role=True)
+    conn = connect()
+
+    # Log in. Note that reflection ignores the bearer token.
+    begin_oauth_handshake(conn, oauth_ctx, user=username)
+    send_initial_response(conn, bearer=b"dontcare")
+    expect_handshake_success(conn)
+
+    # Check the user identity.
+    pq3.send(conn, pq3.types.Query, query=b"SELECT system_user;")
+    resp = receive_until(conn, pq3.types.DataRow)
+
+    row = resp.payload
+    expected = b"oauth:" + username.encode("utf-8")
+    assert row.columns == [expected]
+
+
[email protected]
+def odd_oauth_ctx(postgres_instance, oauth_ctx):
+    """
+    Adds an HBA entry with messed up issuer/scope settings, to pin the server
+    behavior.
+
+    TODO: these should really be rejected in the HBA rather than passed through
+    by the server.
+    """
+    id = secrets.token_hex(4)
+
+    class Context:
+        user = oauth_ctx.user
+        dbname = oauth_ctx.dbname
+
+        # Both of these embedded double-quotes are invalid; they're prohibited
+        # in both URLs and OAuth scope identifiers.
+        issuer = oauth_ctx.issuer + '/"/'
+        scope = oauth_ctx.scope + ' quo"ted'
+
+    ctx = Context()
+    hba_issuer = ctx.issuer.replace('"', '""')
+    hba_scope = ctx.scope.replace('"', '""')
+    hba_lines = [
+        f'host {ctx.dbname} {ctx.user} samehost oauth issuer="{hba_issuer}" scope="{hba_scope}"\n',
+    ]
+
+    if platform.system() == "Windows":
+        # XXX why is 'samehost' not behaving as expected on Windows?
+        for l in list(hba_lines):
+            hba_lines.append(l.replace("samehost", "::1/128"))
+
+    host, port = postgres_instance
+    conn = psycopg2.connect(host=host, port=port)
+    conn.autocommit = True
+
+    with contextlib.closing(conn):
+        c = conn.cursor()
+
+        # Replace pg_hba. Note that it's already been replaced once by
+        # oauth_ctx, so use a different backup prefix in prepend_file().
+        c.execute("SHOW hba_file;")
+        hba = c.fetchone()[0]
+
+        with prepend_file(hba, hba_lines, suffix=".bak2"):
+            c.execute("SELECT pg_reload_conf();")
+
+            yield ctx
+
+        # Put things back the way they were.
+        c.execute("SELECT pg_reload_conf();")
+
+
+def test_odd_server_response(odd_oauth_ctx, connect):
+    """
+    Verifies that the server is correctly escaping the JSON in its failure
+    response.
+    """
+    conn = connect()
+    begin_oauth_handshake(conn, odd_oauth_ctx, user=odd_oauth_ctx.user)
+
+    # Send an empty auth initial response, which will force an authn failure.
+    send_initial_response(conn, auth=b"")
+
+    expect_handshake_failure(conn, odd_oauth_ctx)
diff --git a/src/test/python/server/test_server.py b/src/test/python/server/test_server.py
new file mode 100644
index 00000000000..02126dba792
--- /dev/null
+++ b/src/test/python/server/test_server.py
@@ -0,0 +1,21 @@
+#
+# Copyright 2021 VMware, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import pq3
+
+
+def test_handshake(connect):
+    """Basic sanity check."""
+    conn = connect()
+
+    pq3.handshake(conn, user=pq3.pguser(), database=pq3.pgdatabase())
+
+    pq3.send(conn, pq3.types.Query, query=b"")
+
+    resp = pq3.recv1(conn)
+    assert resp.type == pq3.types.EmptyQueryResponse
+
+    resp = pq3.recv1(conn)
+    assert resp.type == pq3.types.ReadyForQuery
diff --git a/src/test/python/test_internals.py b/src/test/python/test_internals.py
new file mode 100644
index 00000000000..dee4855fc0b
--- /dev/null
+++ b/src/test/python/test_internals.py
@@ -0,0 +1,138 @@
+#
+# Copyright 2021 VMware, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import io
+
+from pq3 import _DebugStream
+
+
+def test_DebugStream_read():
+    under = io.BytesIO(b"abcdefghijklmnopqrstuvwxyz")
+    out = io.StringIO()
+
+    stream = _DebugStream(under, out)
+
+    res = stream.read(5)
+    assert res == b"abcde"
+
+    res = stream.read(16)
+    assert res == b"fghijklmnopqrstu"
+
+    stream.flush_debug()
+
+    res = stream.read()
+    assert res == b"vwxyz"
+
+    stream.flush_debug()
+
+    expected = (
+        "< 0000:\t61 62 63 64 65 66 67 68 69 6a 6b 6c 6d 6e 6f 70\tabcdefghijklmnop\n"
+        "< 0010:\t71 72 73 74 75                                 \tqrstu\n"
+        "\n"
+        "< 0000:\t76 77 78 79 7a                                 \tvwxyz\n"
+        "\n"
+    )
+    assert out.getvalue() == expected
+
+
+def test_DebugStream_write():
+    under = io.BytesIO()
+    out = io.StringIO()
+
+    stream = _DebugStream(under, out)
+
+    stream.write(b"\x00\x01\x02")
+    stream.flush()
+
+    assert under.getvalue() == b"\x00\x01\x02"
+
+    stream.write(b"\xc0\xc1\xc2")
+    stream.flush()
+
+    assert under.getvalue() == b"\x00\x01\x02\xc0\xc1\xc2"
+
+    stream.flush_debug()
+
+    expected = "> 0000:\t00 01 02 c0 c1 c2                              \t......\n\n"
+    assert out.getvalue() == expected
+
+
+def test_DebugStream_read_write():
+    under = io.BytesIO(b"abcdefghijklmnopqrstuvwxyz")
+    out = io.StringIO()
+    stream = _DebugStream(under, out)
+
+    res = stream.read(5)
+    assert res == b"abcde"
+
+    stream.write(b"xxxxx")
+    stream.flush()
+
+    assert under.getvalue() == b"abcdexxxxxklmnopqrstuvwxyz"
+
+    res = stream.read(5)
+    assert res == b"klmno"
+
+    stream.write(b"xxxxx")
+    stream.flush()
+
+    assert under.getvalue() == b"abcdexxxxxklmnoxxxxxuvwxyz"
+
+    stream.flush_debug()
+
+    expected = (
+        "< 0000:\t61 62 63 64 65 6b 6c 6d 6e 6f                  \tabcdeklmno\n"
+        "\n"
+        "> 0000:\t78 78 78 78 78 78 78 78 78 78                  \txxxxxxxxxx\n"
+        "\n"
+    )
+    assert out.getvalue() == expected
+
+
+def test_DebugStream_end_packet():
+    under = io.BytesIO(b"abcdefghijklmnopqrstuvwxyz")
+    out = io.StringIO()
+    stream = _DebugStream(under, out)
+
+    stream.read(5)
+    stream.end_packet("read description", read=True, indent=" ")
+
+    stream.write(b"xxxxx")
+    stream.flush()
+    stream.end_packet("write description", indent=" ")
+
+    stream.read(5)
+    stream.write(b"xxxxx")
+    stream.flush()
+    stream.end_packet("read/write combo for read", read=True, indent=" ")
+
+    stream.read(5)
+    stream.write(b"xxxxx")
+    stream.flush()
+    stream.end_packet("read/write combo for write", indent=" ")
+
+    expected = (
+        " < 0000:\t61 62 63 64 65                                 \tabcde\n"
+        "\n"
+        "< read description\n"
+        "\n"
+        "> write description\n"
+        "\n"
+        " > 0000:\t78 78 78 78 78                                 \txxxxx\n"
+        "\n"
+        " < 0000:\t6b 6c 6d 6e 6f                                 \tklmno\n"
+        "\n"
+        " > 0000:\t78 78 78 78 78                                 \txxxxx\n"
+        "\n"
+        "< read/write combo for read\n"
+        "\n"
+        "> read/write combo for write\n"
+        "\n"
+        " < 0000:\t75 76 77 78 79                                 \tuvwxy\n"
+        "\n"
+        " > 0000:\t78 78 78 78 78                                 \txxxxx\n"
+        "\n"
+    )
+    assert out.getvalue() == expected
diff --git a/src/test/python/test_pq3.py b/src/test/python/test_pq3.py
new file mode 100644
index 00000000000..7c6817de31c
--- /dev/null
+++ b/src/test/python/test_pq3.py
@@ -0,0 +1,574 @@
+#
+# Copyright 2021 VMware, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+import contextlib
+import getpass
+import io
+import os
+import platform
+import struct
+import sys
+
+import pytest
+from construct import Container, PaddingError, StreamError, TerminatedError
+
+import pq3
+
+
[email protected](
+    "raw,expected,extra",
+    [
+        pytest.param(
+            b"\x00\x00\x00\x10\x00\x04\x00\x00abcdefgh",
+            Container(len=16, proto=0x40000, payload=b"abcdefgh"),
+            b"",
+            id="8-byte payload",
+        ),
+        pytest.param(
+            b"\x00\x00\x00\x08\x00\x04\x00\x00",
+            Container(len=8, proto=0x40000, payload=b""),
+            b"",
+            id="no payload",
+        ),
+        pytest.param(
+            b"\x00\x00\x00\x09\x00\x04\x00\x00abcde",
+            Container(len=9, proto=0x40000, payload=b"a"),
+            b"bcde",
+            id="1-byte payload and extra padding",
+        ),
+        pytest.param(
+            b"\x00\x00\x00\x0B\x00\x03\x00\x00hi\x00",
+            Container(len=11, proto=pq3.protocol(3, 0), payload=[b"hi"]),
+            b"",
+            id="implied parameter list when using proto version 3.0",
+        ),
+    ],
+)
+def test_Startup_parse(raw, expected, extra):
+    with io.BytesIO(raw) as stream:
+        actual = pq3.Startup.parse_stream(stream)
+
+        assert actual == expected
+        assert stream.read() == extra
+
+
[email protected](
+    "packet,expected_bytes",
+    [
+        pytest.param(
+            dict(),
+            b"\x00\x00\x00\x08\x00\x00\x00\x00",
+            id="nothing set",
+        ),
+        pytest.param(
+            dict(len=10, proto=0x12345678),
+            b"\x00\x00\x00\x0A\x12\x34\x56\x78\x00\x00",
+            id="len and proto set explicitly",
+        ),
+        pytest.param(
+            dict(proto=0x12345678),
+            b"\x00\x00\x00\x08\x12\x34\x56\x78",
+            id="implied len with no payload",
+        ),
+        pytest.param(
+            dict(proto=0x12345678, payload=b"abcd"),
+            b"\x00\x00\x00\x0C\x12\x34\x56\x78abcd",
+            id="implied len with payload",
+        ),
+        pytest.param(
+            dict(payload=[b""]),
+            b"\x00\x00\x00\x09\x00\x03\x00\x00\x00",
+            id="implied proto version 3 when sending parameters",
+        ),
+        pytest.param(
+            dict(payload=[b"hi", b""]),
+            b"\x00\x00\x00\x0C\x00\x03\x00\x00hi\x00\x00",
+            id="implied proto version 3 and len when sending more than one parameter",
+        ),
+        pytest.param(
+            dict(payload=dict(user="jsmith", database="postgres")),
+            b"\x00\x00\x00\x27\x00\x03\x00\x00user\x00jsmith\x00database\x00postgres\x00\x00",
+            id="auto-serialization of dict parameters",
+        ),
+    ],
+)
+def test_Startup_build(packet, expected_bytes):
+    actual = pq3.Startup.build(packet)
+    assert actual == expected_bytes
+
+
[email protected](
+    "raw,expected,extra",
+    [
+        pytest.param(
+            b"*\x00\x00\x00\x08abcd",
+            dict(type=b"*", len=8, payload=b"abcd"),
+            b"",
+            id="4-byte payload",
+        ),
+        pytest.param(
+            b"*\x00\x00\x00\x04",
+            dict(type=b"*", len=4, payload=b""),
+            b"",
+            id="no payload",
+        ),
+        pytest.param(
+            b"*\x00\x00\x00\x05xabcd",
+            dict(type=b"*", len=5, payload=b"x"),
+            b"abcd",
+            id="1-byte payload with extra padding",
+        ),
+        pytest.param(
+            b"R\x00\x00\x00\x08\x00\x00\x00\x00",
+            dict(
+                type=pq3.types.AuthnRequest,
+                len=8,
+                payload=dict(type=pq3.authn.OK, body=None),
+            ),
+            b"",
+            id="AuthenticationOk",
+        ),
+        pytest.param(
+            b"R\x00\x00\x00\x12\x00\x00\x00\x0AEXTERNAL\x00\x00",
+            dict(
+                type=pq3.types.AuthnRequest,
+                len=18,
+                payload=dict(type=pq3.authn.SASL, body=[b"EXTERNAL", b""]),
+            ),
+            b"",
+            id="AuthenticationSASL",
+        ),
+        pytest.param(
+            b"R\x00\x00\x00\x0D\x00\x00\x00\x0B12345",
+            dict(
+                type=pq3.types.AuthnRequest,
+                len=13,
+                payload=dict(type=pq3.authn.SASLContinue, body=b"12345"),
+            ),
+            b"",
+            id="AuthenticationSASLContinue",
+        ),
+        pytest.param(
+            b"R\x00\x00\x00\x0D\x00\x00\x00\x0C12345",
+            dict(
+                type=pq3.types.AuthnRequest,
+                len=13,
+                payload=dict(type=pq3.authn.SASLFinal, body=b"12345"),
+            ),
+            b"",
+            id="AuthenticationSASLFinal",
+        ),
+        pytest.param(
+            b"p\x00\x00\x00\x0Bhunter2",
+            dict(
+                type=pq3.types.PasswordMessage,
+                len=11,
+                payload=b"hunter2",
+            ),
+            b"",
+            id="PasswordMessage",
+        ),
+        pytest.param(
+            b"K\x00\x00\x00\x0C\x00\x00\x00\x00\x12\x34\x56\x78",
+            dict(
+                type=pq3.types.BackendKeyData,
+                len=12,
+                payload=dict(pid=0, key=0x12345678),
+            ),
+            b"",
+            id="BackendKeyData",
+        ),
+        pytest.param(
+            b"C\x00\x00\x00\x08SET\x00",
+            dict(
+                type=pq3.types.CommandComplete,
+                len=8,
+                payload=dict(tag=b"SET"),
+            ),
+            b"",
+            id="CommandComplete",
+        ),
+        pytest.param(
+            b"E\x00\x00\x00\x11Mbad!\x00Mdog!\x00\x00",
+            dict(type=b"E", len=17, payload=dict(fields=[b"Mbad!", b"Mdog!", b""])),
+            b"",
+            id="ErrorResponse",
+        ),
+        pytest.param(
+            b"S\x00\x00\x00\x08a\x00b\x00",
+            dict(
+                type=pq3.types.ParameterStatus,
+                len=8,
+                payload=dict(name=b"a", value=b"b"),
+            ),
+            b"",
+            id="ParameterStatus",
+        ),
+        pytest.param(
+            b"Z\x00\x00\x00\x05x",
+            dict(type=b"Z", len=5, payload=dict(status=b"x")),
+            b"",
+            id="ReadyForQuery",
+        ),
+        pytest.param(
+            b"Q\x00\x00\x00\x06!\x00",
+            dict(type=pq3.types.Query, len=6, payload=dict(query=b"!")),
+            b"",
+            id="Query",
+        ),
+        pytest.param(
+            b"D\x00\x00\x00\x0B\x00\x01\x00\x00\x00\x01!",
+            dict(type=pq3.types.DataRow, len=11, payload=dict(columns=[b"!"])),
+            b"",
+            id="DataRow",
+        ),
+        pytest.param(
+            b"D\x00\x00\x00\x06\x00\x00extra",
+            dict(type=pq3.types.DataRow, len=6, payload=dict(columns=[])),
+            b"extra",
+            id="DataRow with extra data",
+        ),
+        pytest.param(
+            b"I\x00\x00\x00\x04",
+            dict(type=pq3.types.EmptyQueryResponse, len=4, payload=None),
+            b"",
+            id="EmptyQueryResponse",
+        ),
+        pytest.param(
+            b"I\x00\x00\x00\x04\xFF",
+            dict(type=b"I", len=4, payload=None),
+            b"\xFF",
+            id="EmptyQueryResponse with extra bytes",
+        ),
+        pytest.param(
+            b"X\x00\x00\x00\x04",
+            dict(type=pq3.types.Terminate, len=4, payload=None),
+            b"",
+            id="Terminate",
+        ),
+    ],
+)
+def test_Pq3_parse(raw, expected, extra):
+    with io.BytesIO(raw) as stream:
+        actual = pq3.Pq3.parse_stream(stream)
+
+        assert actual == expected
+        assert stream.read() == extra
+
+
[email protected](
+    "fields,expected",
+    [
+        pytest.param(
+            dict(type=b"*", len=5),
+            b"*\x00\x00\x00\x05",
+            id="type and len set explicitly",
+        ),
+        pytest.param(
+            dict(type=b"*"),
+            b"*\x00\x00\x00\x04",
+            id="implied len with no payload",
+        ),
+        pytest.param(
+            dict(type=b"*", payload=b"1234"),
+            b"*\x00\x00\x00\x081234",
+            id="implied len with payload",
+        ),
+        pytest.param(
+            dict(type=b"*", len=12, payload=b"1234"),
+            b"*\x00\x00\x00\x0C1234",
+            id="overridden len (payload underflow)",
+        ),
+        pytest.param(
+            dict(type=b"*", len=5, payload=b"1234"),
+            b"*\x00\x00\x00\x051234",
+            id="overridden len (payload overflow)",
+        ),
+        pytest.param(
+            dict(type=pq3.types.AuthnRequest, payload=dict(type=pq3.authn.OK)),
+            b"R\x00\x00\x00\x08\x00\x00\x00\x00",
+            id="implied len/type for AuthenticationOK",
+        ),
+        pytest.param(
+            dict(
+                type=pq3.types.AuthnRequest,
+                payload=dict(
+                    type=pq3.authn.SASL,
+                    body=[b"SCRAM-SHA-256-PLUS", b"SCRAM-SHA-256", b""],
+                ),
+            ),
+            b"R\x00\x00\x00\x2A\x00\x00\x00\x0ASCRAM-SHA-256-PLUS\x00SCRAM-SHA-256\x00\x00",
+            id="implied len/type for AuthenticationSASL",
+        ),
+        pytest.param(
+            dict(
+                type=pq3.types.AuthnRequest,
+                payload=dict(type=pq3.authn.SASLContinue, body=b"12345"),
+            ),
+            b"R\x00\x00\x00\x0D\x00\x00\x00\x0B12345",
+            id="implied len/type for AuthenticationSASLContinue",
+        ),
+        pytest.param(
+            dict(
+                type=pq3.types.AuthnRequest,
+                payload=dict(type=pq3.authn.SASLFinal, body=b"12345"),
+            ),
+            b"R\x00\x00\x00\x0D\x00\x00\x00\x0C12345",
+            id="implied len/type for AuthenticationSASLFinal",
+        ),
+        pytest.param(
+            dict(
+                type=pq3.types.PasswordMessage,
+                payload=b"hunter2",
+            ),
+            b"p\x00\x00\x00\x0Bhunter2",
+            id="implied len/type for PasswordMessage",
+        ),
+        pytest.param(
+            dict(type=pq3.types.BackendKeyData, payload=dict(pid=1, key=7)),
+            b"K\x00\x00\x00\x0C\x00\x00\x00\x01\x00\x00\x00\x07",
+            id="implied len/type for BackendKeyData",
+        ),
+        pytest.param(
+            dict(type=pq3.types.CommandComplete, payload=dict(tag=b"SET")),
+            b"C\x00\x00\x00\x08SET\x00",
+            id="implied len/type for CommandComplete",
+        ),
+        pytest.param(
+            dict(type=pq3.types.ErrorResponse, payload=dict(fields=[b"error", b""])),
+            b"E\x00\x00\x00\x0Berror\x00\x00",
+            id="implied len/type for ErrorResponse",
+        ),
+        pytest.param(
+            dict(type=pq3.types.ParameterStatus, payload=dict(name=b"a", value=b"b")),
+            b"S\x00\x00\x00\x08a\x00b\x00",
+            id="implied len/type for ParameterStatus",
+        ),
+        pytest.param(
+            dict(type=pq3.types.ReadyForQuery, payload=dict(status=b"I")),
+            b"Z\x00\x00\x00\x05I",
+            id="implied len/type for ReadyForQuery",
+        ),
+        pytest.param(
+            dict(type=pq3.types.Query, payload=dict(query=b"SELECT 1;")),
+            b"Q\x00\x00\x00\x0eSELECT 1;\x00",
+            id="implied len/type for Query",
+        ),
+        pytest.param(
+            dict(type=pq3.types.DataRow, payload=dict(columns=[b"abcd"])),
+            b"D\x00\x00\x00\x0E\x00\x01\x00\x00\x00\x04abcd",
+            id="implied len/type for DataRow",
+        ),
+        pytest.param(
+            dict(type=pq3.types.EmptyQueryResponse),
+            b"I\x00\x00\x00\x04",
+            id="implied len for EmptyQueryResponse",
+        ),
+        pytest.param(
+            dict(type=pq3.types.Terminate),
+            b"X\x00\x00\x00\x04",
+            id="implied len for Terminate",
+        ),
+    ],
+)
+def test_Pq3_build(fields, expected):
+    actual = pq3.Pq3.build(fields)
+    assert actual == expected
+
+
[email protected](
+    "raw,expected,extra",
+    [
+        pytest.param(
+            b"\x00\x00",
+            dict(columns=[]),
+            b"",
+            id="no columns",
+        ),
+        pytest.param(
+            b"\x00\x01\x00\x00\x00\x04abcd",
+            dict(columns=[b"abcd"]),
+            b"",
+            id="one column",
+        ),
+        pytest.param(
+            b"\x00\x02\x00\x00\x00\x04abcd\x00\x00\x00\x01x",
+            dict(columns=[b"abcd", b"x"]),
+            b"",
+            id="multiple columns",
+        ),
+        pytest.param(
+            b"\x00\x02\x00\x00\x00\x00\x00\x00\x00\x01x",
+            dict(columns=[b"", b"x"]),
+            b"",
+            id="empty column value",
+        ),
+        pytest.param(
+            b"\x00\x02\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF",
+            dict(columns=[None, None]),
+            b"",
+            id="null columns",
+        ),
+    ],
+)
+def test_DataRow_parse(raw, expected, extra):
+    pkt = b"D" + struct.pack("!i", len(raw) + 4) + raw
+    with io.BytesIO(pkt) as stream:
+        actual = pq3.Pq3.parse_stream(stream)
+
+        assert actual.type == pq3.types.DataRow
+        assert actual.payload == expected
+        assert stream.read() == extra
+
+
[email protected](
+    "fields,expected",
+    [
+        pytest.param(
+            dict(),
+            b"\x00\x00",
+            id="no columns",
+        ),
+        pytest.param(
+            dict(columns=[None, None]),
+            b"\x00\x02\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF",
+            id="null columns",
+        ),
+    ],
+)
+def test_DataRow_build(fields, expected):
+    actual = pq3.Pq3.build(dict(type=pq3.types.DataRow, payload=fields))
+
+    expected = b"D" + struct.pack("!i", len(expected) + 4) + expected
+    assert actual == expected
+
+
[email protected](
+    "raw,expected,exception",
+    [
+        pytest.param(
+            b"EXTERNAL\x00\xFF\xFF\xFF\xFF",
+            dict(name=b"EXTERNAL", len=-1, data=None),
+            None,
+            id="no initial response",
+        ),
+        pytest.param(
+            b"EXTERNAL\x00\x00\x00\x00\x02me",
+            dict(name=b"EXTERNAL", len=2, data=b"me"),
+            None,
+            id="initial response",
+        ),
+        pytest.param(
+            b"EXTERNAL\x00\x00\x00\x00\x02meextra",
+            None,
+            TerminatedError,
+            id="extra data",
+        ),
+        pytest.param(
+            b"EXTERNAL\x00\x00\x00\x00\xFFme",
+            None,
+            StreamError,
+            id="underflow",
+        ),
+    ],
+)
+def test_SASLInitialResponse_parse(raw, expected, exception):
+    ctx = contextlib.nullcontext()
+    if exception:
+        ctx = pytest.raises(exception)
+
+    with ctx:
+        actual = pq3.SASLInitialResponse.parse(raw)
+        assert actual == expected
+
+
[email protected](
+    "fields,expected",
+    [
+        pytest.param(
+            dict(name=b"EXTERNAL"),
+            b"EXTERNAL\x00\xFF\xFF\xFF\xFF",
+            id="no initial response",
+        ),
+        pytest.param(
+            dict(name=b"EXTERNAL", data=None),
+            b"EXTERNAL\x00\xFF\xFF\xFF\xFF",
+            id="no initial response (explicit None)",
+        ),
+        pytest.param(
+            dict(name=b"EXTERNAL", data=b""),
+            b"EXTERNAL\x00\x00\x00\x00\x00",
+            id="empty response",
+        ),
+        pytest.param(
+            dict(name=b"EXTERNAL", data=b"[email protected]"),
+            b"EXTERNAL\x00\x00\x00\x00\[email protected]",
+            id="initial response",
+        ),
+        pytest.param(
+            dict(name=b"EXTERNAL", len=2, data=b"[email protected]"),
+            b"EXTERNAL\x00\x00\x00\x00\[email protected]",
+            id="data overflow",
+        ),
+        pytest.param(
+            dict(name=b"EXTERNAL", len=14, data=b"me"),
+            b"EXTERNAL\x00\x00\x00\x00\x0Eme",
+            id="data underflow",
+        ),
+    ],
+)
+def test_SASLInitialResponse_build(fields, expected):
+    actual = pq3.SASLInitialResponse.build(fields)
+    assert actual == expected
+
+
[email protected](
+    "version,expected_bytes",
+    [
+        pytest.param((3, 0), b"\x00\x03\x00\x00", id="version 3"),
+        pytest.param((1234, 5679), b"\x04\xd2\x16\x2f", id="SSLRequest"),
+    ],
+)
+def test_protocol(version, expected_bytes):
+    # Make sure the integer returned by protocol is correctly serialized on the
+    # wire.
+    assert struct.pack("!i", pq3.protocol(*version)) == expected_bytes
+
+
[email protected](
+    "envvar,func,expected",
+    [
+        ("PGHOST", pq3.pghost, "localhost"),
+        ("PGPORT", pq3.pgport, 5432),
+        (
+            "PGUSER",
+            pq3.pguser,
+            os.getlogin() if platform.system() == "Windows" else getpass.getuser(),
+        ),
+        ("PGDATABASE", pq3.pgdatabase, "postgres"),
+    ],
+)
+def test_env_defaults(monkeypatch, envvar, func, expected):
+    monkeypatch.delenv(envvar, raising=False)
+
+    actual = func()
+    assert actual == expected
+
+
[email protected](
+    "envvars,func,expected",
+    [
+        (dict(PGHOST="otherhost"), pq3.pghost, "otherhost"),
+        (dict(PGPORT="6789"), pq3.pgport, 6789),
+        (dict(PGUSER="postgres"), pq3.pguser, "postgres"),
+        (dict(PGDATABASE="template1"), pq3.pgdatabase, "template1"),
+    ],
+)
+def test_env(monkeypatch, envvars, func, expected):
+    for k, v in envvars.items():
+        monkeypatch.setenv(k, v)
+
+    actual = func()
+    assert actual == expected
diff --git a/src/test/python/tls.py b/src/test/python/tls.py
new file mode 100644
index 00000000000..075c02c1ca6
--- /dev/null
+++ b/src/test/python/tls.py
@@ -0,0 +1,195 @@
+#
+# Copyright 2021 VMware, Inc.
+# SPDX-License-Identifier: PostgreSQL
+#
+
+from construct import *
+
+#
+# TLS 1.3
+#
+# Most of the types below are transcribed from RFC 8446:
+#
+#     https://tools.ietf.org/html/rfc8446
+#
+
+
+def _Vector(size_field, element):
+    return Prefixed(size_field, GreedyRange(element))
+
+
+# Alerts
+
+AlertLevel = Enum(
+    Byte,
+    warning=1,
+    fatal=2,
+)
+
+AlertDescription = Enum(
+    Byte,
+    close_notify=0,
+    unexpected_message=10,
+    bad_record_mac=20,
+    decryption_failed_RESERVED=21,
+    record_overflow=22,
+    decompression_failure=30,
+    handshake_failure=40,
+    no_certificate_RESERVED=41,
+    bad_certificate=42,
+    unsupported_certificate=43,
+    certificate_revoked=44,
+    certificate_expired=45,
+    certificate_unknown=46,
+    illegal_parameter=47,
+    unknown_ca=48,
+    access_denied=49,
+    decode_error=50,
+    decrypt_error=51,
+    export_restriction_RESERVED=60,
+    protocol_version=70,
+    insufficient_security=71,
+    internal_error=80,
+    user_canceled=90,
+    no_renegotiation=100,
+    unsupported_extension=110,
+)
+
+Alert = Struct(
+    "level" / AlertLevel,
+    "description" / AlertDescription,
+)
+
+
+# Extensions
+
+ExtensionType = Enum(
+    Int16ub,
+    server_name=0,
+    max_fragment_length=1,
+    status_request=5,
+    supported_groups=10,
+    signature_algorithms=13,
+    use_srtp=14,
+    heartbeat=15,
+    application_layer_protocol_negotiation=16,
+    signed_certificate_timestamp=18,
+    client_certificate_type=19,
+    server_certificate_type=20,
+    padding=21,
+    pre_shared_key=41,
+    early_data=42,
+    supported_versions=43,
+    cookie=44,
+    psk_key_exchange_modes=45,
+    certificate_authorities=47,
+    oid_filters=48,
+    post_handshake_auth=49,
+    signature_algorithms_cert=50,
+    key_share=51,
+)
+
+Extension = Struct(
+    "extension_type" / ExtensionType,
+    "extension_data" / Prefixed(Int16ub, GreedyBytes),
+)
+
+
+# ClientHello
+
+
+class _CipherSuiteAdapter(Adapter):
+    class _hextuple(tuple):
+        def __repr__(self):
+            return f"(0x{self[0]:02X}, 0x{self[1]:02X})"
+
+    def _encode(self, obj, context, path):
+        return bytes(obj)
+
+    def _decode(self, obj, context, path):
+        assert len(obj) == 2
+        return self._hextuple(obj)
+
+
+ProtocolVersion = Hex(Int16ub)
+
+Random = Hex(Bytes(32))
+
+CipherSuite = _CipherSuiteAdapter(Byte[2])
+
+ClientHello = Struct(
+    "legacy_version" / ProtocolVersion,
+    "random" / Random,
+    "legacy_session_id" / Prefixed(Byte, Hex(GreedyBytes)),
+    "cipher_suites" / _Vector(Int16ub, CipherSuite),
+    "legacy_compression_methods" / Prefixed(Byte, GreedyBytes),
+    "extensions" / _Vector(Int16ub, Extension),
+)
+
+# ServerHello
+
+ServerHello = Struct(
+    "legacy_version" / ProtocolVersion,
+    "random" / Random,
+    "legacy_session_id_echo" / Prefixed(Byte, Hex(GreedyBytes)),
+    "cipher_suite" / CipherSuite,
+    "legacy_compression_method" / Hex(Byte),
+    "extensions" / _Vector(Int16ub, Extension),
+)
+
+# Handshake
+
+HandshakeType = Enum(
+    Byte,
+    client_hello=1,
+    server_hello=2,
+    new_session_ticket=4,
+    end_of_early_data=5,
+    encrypted_extensions=8,
+    certificate=11,
+    certificate_request=13,
+    certificate_verify=15,
+    finished=20,
+    key_update=24,
+    message_hash=254,
+)
+
+Handshake = Struct(
+    "msg_type" / HandshakeType,
+    "length" / Int24ub,
+    "payload"
+    / Switch(
+        this.msg_type,
+        {
+            HandshakeType.client_hello: ClientHello,
+            HandshakeType.server_hello: ServerHello,
+            # HandshakeType.end_of_early_data: EndOfEarlyData,
+            # HandshakeType.encrypted_extensions: EncryptedExtensions,
+            # HandshakeType.certificate_request: CertificateRequest,
+            # HandshakeType.certificate: Certificate,
+            # HandshakeType.certificate_verify: CertificateVerify,
+            # HandshakeType.finished: Finished,
+            # HandshakeType.new_session_ticket: NewSessionTicket,
+            # HandshakeType.key_update: KeyUpdate,
+        },
+        default=FixedSized(this.length, GreedyBytes),
+    ),
+)
+
+# Records
+
+ContentType = Enum(
+    Byte,
+    invalid=0,
+    change_cipher_spec=20,
+    alert=21,
+    handshake=22,
+    application_data=23,
+)
+
+Plaintext = Struct(
+    "type" / ContentType,
+    "legacy_record_version" / ProtocolVersion,
+    "length" / Int16ub,
+    "fragment" / FixedSized(this.length, GreedyBytes),
+)
diff --git a/src/tools/make_venv b/src/tools/make_venv
new file mode 100755
index 00000000000..804307ee120
--- /dev/null
+++ b/src/tools/make_venv
@@ -0,0 +1,56 @@
+#!/usr/bin/env python3
+
+import argparse
+import subprocess
+import os
+import platform
+import sys
+
+parser = argparse.ArgumentParser()
+
+parser.add_argument('--requirements', help='path to pip requirements file', type=str)
+parser.add_argument('--privatedir', help='private directory for target', type=str)
+parser.add_argument('venv_path', help='desired venv location')
+
+args = parser.parse_args()
+
+# Decide whether or not to capture stdout into a log file. We only do this if
+# we've been given our own private directory.
+#
+# FIXME Unfortunately this interferes with debugging on Cirrus, because the
+# private directory isn't uploaded in the sanity check's artifacts. When we
+# don't capture the log file, it gets spammed to stdout during build... Is there
+# a way to push this into the meson-log somehow? For now, the capture
+# implementation is commented out.
+logfile = None
+
+if args.privatedir:
+    if not os.path.isdir(args.privatedir):
+        os.mkdir(args.privatedir)
+
+    # FIXME see above comment
+    # logpath = os.path.join(args.privatedir, 'stdout.txt')
+    # logfile = open(logpath, 'w')
+
+def run(*args):
+    kwargs = dict(check=True)
+    if logfile:
+        kwargs.update(stdout=logfile)
+
+    subprocess.run(args, **kwargs)
+
+# Create the virtualenv first.
+run(sys.executable, '-m', 'venv', args.venv_path)
+
+# Update pip next. This helps avoid old pip bugs; the version inside system
+# Pythons tends to be pretty out of date.
+bindir = 'Scripts' if platform.system() == 'Windows' else 'bin'
+python = os.path.join(args.venv_path, bindir, 'python3')
+run(python, '-m', 'pip', 'install', '-U', 'pip')
+
+# Finally, install the test's requirements. We need pytest and pytest-tap, no
+# matter what the test needs.
+pip = os.path.join(args.venv_path, bindir, 'pip')
+run(pip, 'install', 'pytest', 'pytest-tap')
+if args.requirements:
+    run(pip, 'install', '-r', args.requirements)
diff --git a/src/tools/testwrap b/src/tools/testwrap
index 8ae8fb79ba7..ffdf760d79a 100755
--- a/src/tools/testwrap
+++ b/src/tools/testwrap
@@ -14,6 +14,7 @@ parser.add_argument('--testgroup', help='test group', type=str)
 parser.add_argument('--testname', help='test name', type=str)
 parser.add_argument('--skip', help='skip test (with reason)', type=str)
 parser.add_argument('--pg-test-extra', help='extra tests', type=str)
+parser.add_argument('--skip-without-extra', help='skip if PG_TEST_EXTRA is missing this arg', type=str)
 parser.add_argument('test_command', nargs='*')
 
 args = parser.parse_args()
@@ -29,6 +30,12 @@ if args.skip is not None:
     print('1..0 # Skipped: ' + args.skip)
     sys.exit(0)
 
+if args.skip_without_extra is not None:
+    extras = os.environ.get("PG_TEST_EXTRA", args.pg_test_extra)
+    if extras is None or args.skip_without_extra not in extras.split():
+        print(f'1..0 # Skipped: PG_TEST_EXTRA does not contain "{args.skip_without_extra}"')
+        sys.exit(0)
+
 if os.path.exists(testdir) and os.path.isdir(testdir):
     shutil.rmtree(testdir)
 os.makedirs(testdir)
-- 
2.34.1



^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-02-12 14:55  Peter Eisentraut <[email protected]>
  parent: Jacob Champion <[email protected]>
  2 siblings, 0 replies; 101+ messages in thread

From: Peter Eisentraut @ 2025-02-12 14:55 UTC (permalink / raw)
  To: Jacob Champion <[email protected]>; Daniel Gustafsson <[email protected]>; +Cc: Antonin Houska <[email protected]>; pgsql-hackers

On 08.02.25 02:56, Jacob Champion wrote:
> On Fri, Feb 7, 2025 at 12:12 PM Daniel Gustafsson<[email protected]> wrote:
>> Is it really enough to do this at build time?  A very small percentage of users
>> running this will also be building their own libpq so the warning is lost on
>> them.  That being said, I'm not entirely sure what else we could do (bleeping a
>> warning every time is clearly not userfriendly) so maybe this is a TODO in the
>> code?
> I've added a TODO back. At the moment, I don't have any good ideas; if
> the user isn't building libpq, they're not going to be able to take
> action on the warning anyway, and for many use cases they're probably
> not going to care.

This just depends on how people have built their libcurl, right?

Do we have any information whether the async-dns-free build is a common 
configuration?







^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-02-12 14:59  Peter Eisentraut <[email protected]>
  parent: Jacob Champion <[email protected]>
  2 siblings, 1 reply; 101+ messages in thread

From: Peter Eisentraut @ 2025-02-12 14:59 UTC (permalink / raw)
  To: Jacob Champion <[email protected]>; Daniel Gustafsson <[email protected]>; +Cc: Antonin Houska <[email protected]>; pgsql-hackers

On 08.02.25 02:56, Jacob Champion wrote:
>> +  oauth_json_set_error(ctx,       /* don't bother translating */
>> With the project style format for translator comments this should be:
>>
>>          +  /* translator: xxx */
>>          +  oauth_json_set_error(ctx,
> This comment was just meant to draw attention to the lack of
> libpq_gettext(). Does it still need a translator note if we don't run
> it through translation?

No, that wouldn't have any effect.

I think you can just remove that comment.  It's pretty established that 
internal errors don't need translation, so it would be understood from 
looking at the code.







^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-02-13 21:23  Jacob Champion <[email protected]>
  parent: Peter Eisentraut <[email protected]>
  0 siblings, 1 reply; 101+ messages in thread

From: Jacob Champion @ 2025-02-13 21:23 UTC (permalink / raw)
  To: Peter Eisentraut <[email protected]>; +Cc: Daniel Gustafsson <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

On Wed, Feb 12, 2025 at 6:55 AM Peter Eisentraut <[email protected]> wrote:
> This just depends on how people have built their libcurl, right?
>
> Do we have any information whether the async-dns-free build is a common
> configuration?

I don't think the annual Curl survey covers that, unfortunately.

On Wed, Feb 12, 2025 at 6:59 AM Peter Eisentraut <[email protected]> wrote:
> I think you can just remove that comment.  It's pretty established that
> internal errors don't need translation, so it would be understood from
> looking at the code.

Okay, will do.

Thanks,
--Jacob






^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-02-13 22:56  Daniel Gustafsson <[email protected]>
  parent: Jacob Champion <[email protected]>
  2 siblings, 1 reply; 101+ messages in thread

From: Daniel Gustafsson @ 2025-02-13 22:56 UTC (permalink / raw)
  To: Jacob Champion <[email protected]>; +Cc: Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

> On 8 Feb 2025, at 02:56, Jacob Champion <[email protected]> wrote:

Thanks for the new version!

> - 0004 gets a missed pgperltidy and explicitly skips unsupported tests
> on Windows.

+if ($Config{osname} eq 'MSWin32')
We can get away with nog importing Config at all since Test::Utils export a
symbol for this already in $windows_os.  Fixed in my attached.

> Daniel and I talked at FOSDEM about wanting to have additional
> guardrails on the server-side validator API. Ideally, we'd wait for
> major version boundaries to change APIs, as per usual. But if any bugs
> come to light that affect the security of the system, we may want to
> have more control over the boundary between the server and the
> validator. So I've added two features to the API.

I think what you added there is quite sufficient for handling the worst case
that ideally should never happen.  Even though I can't see us breaking this
given the code being trivial, I also don't feel like realizing after the fact
when we need it that it was subtly broken, so I added a test validator which
use the wrong ABI version.

I have now read the entire patch cover-to-cover twice to try and catch any
rough or sharp edges.  Unsurprisingly given the number of revisions this patch
has gone through, and the number of hours that have been put into it, there
isn't much to be found.  Most of my findings below are well and truly in the
nit- pickery category (and my favourite, paranoia-induced defensive
programming).  There are no architectural flaws that I can detect, and cross-
referencing with the RFC's I don't see anything mixed up in spec compliance.

To make it easier for you to see what I mean I have implemented most of the
comments and attached as a fixup patch, from which you can cherry-pick hunks
you agree with.  Those I didn't implement should be marked as such below.

As we discussed off-list I took the liberty of squashing the previous fixup
patches into a single one, and squashed your fixes for my comments against v47
into 0001.  All of my proposals are in 0004.

Some comments:

+        The system which hosts the protected resources which are
The repetition of "which ..  which" reads a bit off to me, I propose to
simplify as "The system hosting then.." instead.

+        The organization, product vendor, or other entity which develops and/or
+        administers the OAuth servers and clients for a given application.
Since we define terminology here, shouldn't this be "OAuth resource servers"?

+        <productname>PostgreSQL</productname> does not provide an authorization
+        server; it's obtained from the OAuth provider.
The "obtained from" part makes it sound like you need to get some server
software to run this with PostgreSQL.  How about "; it is the responsibility
of.."?

+         <xref linkend="libpq-connect-oauth-issuer"/> setting. No variations in
+         case or format are permitted.
While not wrong in any way, I think it would be clearer to write "formatting"
here since that's really what we are talking about no?

+         Build with libcurl support for OAuth 2.0 client flows.
+         This requires the <productname>curl</productname> package to be
+         installed.  Building with this will check for the required header files
I don't think we need to document that curl need to be installed since it's
likely already on the system of anyone reading this.  I do however think we
should state the minimum required version.

+        setting in <link linkend="auth-oauth">the server's HBA configuration.</link>
Nitpickery: I prefer to have the period outside the <link> markup.

+        the connection will fail. This is required to prevent a class of "mix-up
+        attacks" on OAuth clients.
Since mix-up attacks aren't very well documented I think we should aid the
readers by linking to the OAuth WG announcement of this class of attacks.  From
there readers can find the original paper but I think linking directly to that
is less helpful than the mailinglist post.

+   running a client via SSH. Client applications may implement their own flows
For consistency with the rest of the docs we should wrap SSH with an
<application> tag.

+   You will then log into your OAuth provider, which will ask whether you want
A think third person here reads more like the rest of the docs.

+        which <application>libpq</application> will call when when action is
"when when", I think this should really be "when an"?

+       sprays HTTP traffic (containing several critical secrets) to standard
+       error during the OAuth flow
This might not be readily understandable by non-native speakers.

+   Similarly, if you are using Curl inside your application,
Should use <productname> markup.

+   more recent versions of Curl that are built to support threadsafe
s/threadsafe/thread-safe/g for documentation consistency (ditto in other places
where used as a adjective and not code identifier).

+  itself; validator modules provide the glue between the server and the OAuth
s/glue/integration layer/ to avoid confusing readers not used to english idioms.

+   Since a misbehaving validator might let unauthorized users into the database,
+   correct implementation is critical. See
"Don't make any bugs" isn't very helpful advice =) Expanded on it slightly.

+   An OAuth validator module is loaded by dynamically loading one of the shared
The double use of load in "loaded .. loading", rewording to try and simplify.

+    The server has ensured that the token is well-formed syntactically, but no
"server" is an overloaded nomenclature here, perhaps using libpq instead to
clearly indicate that it's postgres and not an OAuth server.

+    The connection will only proceed if the module sets
+    <structfield>authorized</structfield> to <literal>true</literal>.  To
In other places we use the structure name as well and not just the member,
adding that here to be consistent.

+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
Off-by-one

+ * - RFC 7628: https://tools.ietf.org/html/rfc7628
Replacing all tools.ietf.org mentions with datatracker.ietf.org to save a few
redirects.

+                       p++;
+                       if (*p != ',')
If the SASL exchange, are we certain that a rogue client cannot inject a
message which trips us past the end of string?  Should we be doublecheck when
advancing p across the message?

+sanitize_char(char c)
+{
+       static char buf[5];
With the multithreading work on the horizon we should probably avoid static
variables like these to not create work for our future selves?  The code isn't
as neat when passing in a buffer/length but it avoids the need for a static or
threadlocal variable. Or am I overthinking this?

+       initStringInfo(&issuer);
+       appendStringInfoString(&issuer, ctx->issuer);
The double StringInfoData variables in generate_error_response to be able to
JSON escape the issuer is a bit of an eye-sore, a version of
escape_json_with_len which also took an offset into the buffer on where to
start maybe?  Nothing that is urgent to address now (and I have not changed
anything here), but I'll keep it at the back of my head.

+       ereport(LOG, errmsg("internal error in OAuth validator module"));
I wonder if this should be using WARNING instead?  It's really something that
should trigger alarm bells going off.  I've also added an errcode for easier
fleet analysis.

In load_validator_library we don't explicitly verify that the required callback
is defined in the returned structure, which seems like a cheap enough belts and
suspenders level check.

+       if (parsed < 1)
+               return actx->debugging ? 0 : 1;
Is 1 second a sane lower bound on interval for all situations?  I'm starting to
wonder if we should be more conservative here, or even make it configurable in
some way? The default if not set of 5 seconds is quite a lot higher than 1.

+       if (INT_MAX <= parsed)
I think it's closer to project to style to keep the variable on the left side
in such comparisons, so changed these.

+       parsed = parse_json_number(expires_in_str);
+       parsed = round(parsed);
Shouldn't we floor() the value here to ensure we never report an expiration
time longer than the actual expiration?

+       * Some services (Google, Azure) spell verification_uri differently.
I did another round of documentation reading and couldn't find any provider
which also use "verification_url_complete".  However, since it looks so similar
to verification_url it seems worthwhile to add a comment to save readers from
the same rabbithole.

register_socket() doesn't have an error catch for the case when neither epoll
nor kqeue is supported.  Shouldn't it set actx_error() here as well?  (Not done
in my review patch.)

+       if (actx->curl_err[0])
+       {
+               size_t          len;
+
+               appendPQExpBuffer(&conn->errorMessage, " (%s)", actx->curl_err);
Should this also qualify that the error comes from outside of postgres?
Something like "(libcurl:%s)" to match?  I haven't changed this in the attached
since I'm still on the fence, but I'm leanings that we probably should.
Thoughts?

-   * We only support one mechanism at the moment, so rather than deal with a
+   * We only support two mechanisms at the moment, so rather than deal with a
While there's nothing incorrect about this comment, I have a feeling we won't
support more mechanisms than we can justify having a simple array for anytime
soon =)

Sorry for the wall of text.

In general, I feel that this is getting very close to its final form wrt being
a committable patch, and assuming we don't find anything structurally unsound
in the coming days I don't see a blocker for getting this into v18 before the
final commitfest.  If anyone disagrees with this I'd love for that be brought
up.

--
Daniel Gustafsson



Attachments:

  [application/octet-stream] v49-0001-Add-OAUTHBEARER-SASL-mechanism.patch (313.1K, ../../[email protected]/2-v49-0001-Add-OAUTHBEARER-SASL-mechanism.patch)
  download | inline diff:
From 187890fbc3dadf0fed125dce7d164e245c3bb7c7 Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Wed, 23 Oct 2024 09:37:33 -0700
Subject: [PATCH v49 1/4] Add OAUTHBEARER SASL mechanism

DO NOT USE THIS PROOF OF CONCEPT IN PRODUCTION.

Implement OAUTHBEARER (RFC 7628) and OAuth 2.0 Device Authorization
Grants (RFC 8628). This adds a new auth method, oauth, to pg_hba. When
speaking to a OAuth-enabled server, it looks a bit like this:

    $ psql 'host=example.org oauth_issuer=... oauth_client_id=...'
    Visit https://oauth.example.org/login and enter the code: FPQ2-M4BG

The OAuth issuer must support device authorization. No other OAuth flows
are currently implemented (but clients may provide their own flows).

The client implementation requires libcurl and its development headers.
Pass --with-libcurl/-Dlibcurl=enabled during configuration. The server
implementation does not require additional build-time dependencies, but
an external validator module must be supplied.

Thomas Munro wrote the kqueue() implementation for oauth-curl; thanks!

Several TODOs:
- perform several sanity checks on the OAuth issuer's responses
- improve error debuggability during the OAuth handshake
- fix libcurl initialization thread-safety
- harden the libcurl flow implementation
- fill in documentation stubs
- support protocol "variants" implemented by major providers
- implement more helpful handling of HBA misconfigurations
- use logdetail during auth failures
- ...and more.

Co-authored-by: Daniel Gustafsson <[email protected]>
---
 .cirrus.tasks.yml                             |   15 +-
 config/programs.m4                            |   65 +
 configure                                     |  332 ++
 configure.ac                                  |   41 +
 doc/src/sgml/client-auth.sgml                 |  252 ++
 doc/src/sgml/config.sgml                      |   26 +
 doc/src/sgml/filelist.sgml                    |    1 +
 doc/src/sgml/installation.sgml                |   27 +
 doc/src/sgml/libpq.sgml                       |  406 +++
 doc/src/sgml/oauth-validators.sgml            |  402 +++
 doc/src/sgml/postgres.sgml                    |    1 +
 doc/src/sgml/protocol.sgml                    |  133 +-
 doc/src/sgml/regress.sgml                     |   10 +
 meson.build                                   |  100 +
 meson_options.txt                             |    3 +
 src/Makefile.global.in                        |    1 +
 src/backend/libpq/Makefile                    |    1 +
 src/backend/libpq/auth-oauth.c                |  864 +++++
 src/backend/libpq/auth.c                      |   10 +-
 src/backend/libpq/hba.c                       |   64 +-
 src/backend/libpq/meson.build                 |    1 +
 src/backend/libpq/pg_hba.conf.sample          |    4 +-
 src/backend/utils/adt/hbafuncs.c              |   19 +
 src/backend/utils/misc/guc_tables.c           |   12 +
 src/backend/utils/misc/postgresql.conf.sample |    3 +
 src/include/common/oauth-common.h             |   19 +
 src/include/libpq/auth.h                      |    1 +
 src/include/libpq/hba.h                       |    7 +-
 src/include/libpq/oauth.h                     |   54 +
 src/include/pg_config.h.in                    |    9 +
 src/interfaces/libpq/Makefile                 |   11 +-
 src/interfaces/libpq/exports.txt              |    3 +
 src/interfaces/libpq/fe-auth-oauth-curl.c     | 2858 +++++++++++++++++
 src/interfaces/libpq/fe-auth-oauth.c          | 1153 +++++++
 src/interfaces/libpq/fe-auth-oauth.h          |   45 +
 src/interfaces/libpq/fe-auth.c                |   36 +-
 src/interfaces/libpq/fe-auth.h                |    3 +
 src/interfaces/libpq/fe-connect.c             |   48 +-
 src/interfaces/libpq/libpq-fe.h               |   85 +
 src/interfaces/libpq/libpq-int.h              |   13 +-
 src/interfaces/libpq/meson.build              |    5 +
 src/makefiles/meson.build                     |    1 +
 src/test/authentication/t/001_password.pl     |    8 +-
 src/test/modules/Makefile                     |    1 +
 src/test/modules/meson.build                  |    1 +
 src/test/modules/oauth_validator/.gitignore   |    4 +
 src/test/modules/oauth_validator/Makefile     |   40 +
 src/test/modules/oauth_validator/README       |   13 +
 .../modules/oauth_validator/fail_validator.c  |   42 +
 src/test/modules/oauth_validator/meson.build  |   69 +
 .../oauth_validator/oauth_hook_client.c       |  293 ++
 .../modules/oauth_validator/t/001_server.pl   |  566 ++++
 .../modules/oauth_validator/t/002_client.pl   |  154 +
 .../modules/oauth_validator/t/OAuth/Server.pm |  140 +
 .../modules/oauth_validator/t/oauth_server.py |  391 +++
 src/test/modules/oauth_validator/validator.c  |  135 +
 src/test/perl/PostgreSQL/Test/Cluster.pm      |   20 +-
 src/tools/pgindent/pgindent                   |   14 +
 src/tools/pgindent/typedefs.list              |   11 +
 59 files changed, 9007 insertions(+), 39 deletions(-)
 create mode 100644 doc/src/sgml/oauth-validators.sgml
 create mode 100644 src/backend/libpq/auth-oauth.c
 create mode 100644 src/include/common/oauth-common.h
 create mode 100644 src/include/libpq/oauth.h
 create mode 100644 src/interfaces/libpq/fe-auth-oauth-curl.c
 create mode 100644 src/interfaces/libpq/fe-auth-oauth.c
 create mode 100644 src/interfaces/libpq/fe-auth-oauth.h
 create mode 100644 src/test/modules/oauth_validator/.gitignore
 create mode 100644 src/test/modules/oauth_validator/Makefile
 create mode 100644 src/test/modules/oauth_validator/README
 create mode 100644 src/test/modules/oauth_validator/fail_validator.c
 create mode 100644 src/test/modules/oauth_validator/meson.build
 create mode 100644 src/test/modules/oauth_validator/oauth_hook_client.c
 create mode 100644 src/test/modules/oauth_validator/t/001_server.pl
 create mode 100644 src/test/modules/oauth_validator/t/002_client.pl
 create mode 100644 src/test/modules/oauth_validator/t/OAuth/Server.pm
 create mode 100755 src/test/modules/oauth_validator/t/oauth_server.py
 create mode 100644 src/test/modules/oauth_validator/validator.c

diff --git a/.cirrus.tasks.yml b/.cirrus.tasks.yml
index fffa438cec1..2f5f5ef21a8 100644
--- a/.cirrus.tasks.yml
+++ b/.cirrus.tasks.yml
@@ -23,7 +23,7 @@ env:
   MTEST_ARGS: --print-errorlogs --no-rebuild -C build
   PGCTLTIMEOUT: 120 # avoids spurious failures during parallel tests
   TEMP_CONFIG: ${CIRRUS_WORKING_DIR}/src/tools/ci/pg_ci_base.conf
-  PG_TEST_EXTRA: kerberos ldap ssl libpq_encryption load_balance
+  PG_TEST_EXTRA: kerberos ldap ssl libpq_encryption load_balance oauth
 
 
 # What files to preserve in case tests fail
@@ -167,7 +167,7 @@ task:
     chown root:postgres /tmp/cores
     sysctl kern.corefile='/tmp/cores/%N.%P.core'
   setup_additional_packages_script: |
-    #pkg install -y ...
+    pkg install -y curl
 
   # NB: Intentionally build without -Dllvm. The freebsd image size is already
   # large enough to make VM startup slow, and even without llvm freebsd
@@ -329,6 +329,7 @@ LINUX_CONFIGURE_FEATURES: &LINUX_CONFIGURE_FEATURES >-
   --with-gssapi
   --with-icu
   --with-ldap
+  --with-libcurl
   --with-libxml
   --with-libxslt
   --with-llvm
@@ -422,8 +423,10 @@ task:
     EOF
 
   setup_additional_packages_script: |
-    #apt-get update
-    #DEBIAN_FRONTEND=noninteractive apt-get -y install ...
+    apt-get update
+    DEBIAN_FRONTEND=noninteractive apt-get -y install \
+      libcurl4-openssl-dev \
+      libcurl4-openssl-dev:i386 \
 
   matrix:
     - name: Linux - Debian Bookworm - Autoconf
@@ -799,8 +802,8 @@ task:
     folder: $CCACHE_DIR
 
   setup_additional_packages_script: |
-    #apt-get update
-    #DEBIAN_FRONTEND=noninteractive apt-get -y install ...
+    apt-get update
+    DEBIAN_FRONTEND=noninteractive apt-get -y install libcurl4-openssl-dev
 
   ###
   # Test that code can be built with gcc/clang without warnings
diff --git a/config/programs.m4 b/config/programs.m4
index 7b55c2664a6..ead427046f5 100644
--- a/config/programs.m4
+++ b/config/programs.m4
@@ -274,3 +274,68 @@ AC_DEFUN([PGAC_CHECK_STRIP],
   AC_SUBST(STRIP_STATIC_LIB)
   AC_SUBST(STRIP_SHARED_LIB)
 ])# PGAC_CHECK_STRIP
+
+
+
+# PGAC_CHECK_LIBCURL
+# ------------------
+# Check for required libraries and headers, and test to see whether the current
+# installation of libcurl is threadsafe.
+
+AC_DEFUN([PGAC_CHECK_LIBCURL],
+[
+  AC_CHECK_HEADER(curl/curl.h, [],
+				  [AC_MSG_ERROR([header file <curl/curl.h> is required for --with-libcurl])])
+  AC_CHECK_LIB(curl, curl_multi_init, [],
+			   [AC_MSG_ERROR([library 'curl' does not provide curl_multi_init])])
+
+  # Check to see whether the current platform supports threadsafe Curl
+  # initialization.
+  AC_CACHE_CHECK([for curl_global_init thread safety], [pgac_cv__libcurl_threadsafe_init],
+  [AC_RUN_IFELSE([AC_LANG_PROGRAM([
+#include <curl/curl.h>
+],[
+    curl_version_info_data *info;
+
+    if (curl_global_init(CURL_GLOBAL_ALL))
+        return -1;
+
+    info = curl_version_info(CURLVERSION_NOW);
+#ifdef CURL_VERSION_THREADSAFE
+    if (info->features & CURL_VERSION_THREADSAFE)
+        return 0;
+#endif
+
+    return 1;
+])],
+  [pgac_cv__libcurl_threadsafe_init=yes],
+  [pgac_cv__libcurl_threadsafe_init=no],
+  [pgac_cv__libcurl_threadsafe_init=unknown])])
+  if test x"$pgac_cv__libcurl_threadsafe_init" = xyes ; then
+    AC_DEFINE(HAVE_THREADSAFE_CURL_GLOBAL_INIT, 1,
+              [Define to 1 if curl_global_init() is guaranteed to be threadsafe.])
+  fi
+
+  # Warn if a thread-friendly DNS resolver isn't built.
+  AC_CACHE_CHECK([for curl support for asynchronous DNS], [pgac_cv__libcurl_async_dns],
+  [AC_RUN_IFELSE([AC_LANG_PROGRAM([
+#include <curl/curl.h>
+],[
+    curl_version_info_data *info;
+
+    if (curl_global_init(CURL_GLOBAL_ALL))
+        return -1;
+
+    info = curl_version_info(CURLVERSION_NOW);
+    return (info->features & CURL_VERSION_ASYNCHDNS) ? 0 : 1;
+])],
+  [pgac_cv__libcurl_async_dns=yes],
+  [pgac_cv__libcurl_async_dns=no],
+  [pgac_cv__libcurl_async_dns=unknown])])
+  if test x"$pgac_cv__libcurl_async_dns" != xyes ; then
+    AC_MSG_WARN([
+*** The installed version of libcurl does not support asynchronous DNS
+*** lookups. Connection timeouts will not be honored during DNS resolution,
+*** which may lead to hangs in client programs.])
+  fi
+])# PGAC_CHECK_LIBCURL
diff --git a/configure b/configure
index 0ffcaeb4367..93fddd69981 100755
--- a/configure
+++ b/configure
@@ -708,6 +708,9 @@ XML2_LIBS
 XML2_CFLAGS
 XML2_CONFIG
 with_libxml
+LIBCURL_LIBS
+LIBCURL_CFLAGS
+with_libcurl
 with_uuid
 with_readline
 with_systemd
@@ -864,6 +867,7 @@ with_readline
 with_libedit_preferred
 with_uuid
 with_ossp_uuid
+with_libcurl
 with_libxml
 with_libxslt
 with_system_tzdata
@@ -894,6 +898,8 @@ PKG_CONFIG_PATH
 PKG_CONFIG_LIBDIR
 ICU_CFLAGS
 ICU_LIBS
+LIBCURL_CFLAGS
+LIBCURL_LIBS
 XML2_CONFIG
 XML2_CFLAGS
 XML2_LIBS
@@ -1574,6 +1580,7 @@ Optional Packages:
                           prefer BSD Libedit over GNU Readline
   --with-uuid=LIB         build contrib/uuid-ossp using LIB (bsd,e2fs,ossp)
   --with-ossp-uuid        obsolete spelling of --with-uuid=ossp
+  --with-libcurl          build with libcurl support
   --with-libxml           build with XML support
   --with-libxslt          use XSLT support when building contrib/xml2
   --with-system-tzdata=DIR
@@ -1607,6 +1614,10 @@ Some influential environment variables:
               path overriding pkg-config's built-in search path
   ICU_CFLAGS  C compiler flags for ICU, overriding pkg-config
   ICU_LIBS    linker flags for ICU, overriding pkg-config
+  LIBCURL_CFLAGS
+              C compiler flags for LIBCURL, overriding pkg-config
+  LIBCURL_LIBS
+              linker flags for LIBCURL, overriding pkg-config
   XML2_CONFIG path to xml2-config utility
   XML2_CFLAGS C compiler flags for XML2, overriding pkg-config
   XML2_LIBS   linker flags for XML2, overriding pkg-config
@@ -8762,6 +8773,157 @@ fi
 
 
 
+#
+# libcurl
+#
+{ $as_echo "$as_me:${as_lineno-$LINENO}: checking whether to build with libcurl support" >&5
+$as_echo_n "checking whether to build with libcurl support... " >&6; }
+
+
+
+# Check whether --with-libcurl was given.
+if test "${with_libcurl+set}" = set; then :
+  withval=$with_libcurl;
+  case $withval in
+    yes)
+
+$as_echo "#define USE_LIBCURL 1" >>confdefs.h
+
+      ;;
+    no)
+      :
+      ;;
+    *)
+      as_fn_error $? "no argument expected for --with-libcurl option" "$LINENO" 5
+      ;;
+  esac
+
+else
+  with_libcurl=no
+
+fi
+
+
+{ $as_echo "$as_me:${as_lineno-$LINENO}: result: $with_libcurl" >&5
+$as_echo "$with_libcurl" >&6; }
+
+
+if test "$with_libcurl" = yes ; then
+  # Check for libcurl 7.61.0 or higher (corresponding to RHEL8 and the ability
+  # to explicitly set TLS 1.3 ciphersuites).
+
+pkg_failed=no
+{ $as_echo "$as_me:${as_lineno-$LINENO}: checking for libcurl >= 7.61.0" >&5
+$as_echo_n "checking for libcurl >= 7.61.0... " >&6; }
+
+if test -n "$LIBCURL_CFLAGS"; then
+    pkg_cv_LIBCURL_CFLAGS="$LIBCURL_CFLAGS"
+ elif test -n "$PKG_CONFIG"; then
+    if test -n "$PKG_CONFIG" && \
+    { { $as_echo "$as_me:${as_lineno-$LINENO}: \$PKG_CONFIG --exists --print-errors \"libcurl >= 7.61.0\""; } >&5
+  ($PKG_CONFIG --exists --print-errors "libcurl >= 7.61.0") 2>&5
+  ac_status=$?
+  $as_echo "$as_me:${as_lineno-$LINENO}: \$? = $ac_status" >&5
+  test $ac_status = 0; }; then
+  pkg_cv_LIBCURL_CFLAGS=`$PKG_CONFIG --cflags "libcurl >= 7.61.0" 2>/dev/null`
+		      test "x$?" != "x0" && pkg_failed=yes
+else
+  pkg_failed=yes
+fi
+ else
+    pkg_failed=untried
+fi
+if test -n "$LIBCURL_LIBS"; then
+    pkg_cv_LIBCURL_LIBS="$LIBCURL_LIBS"
+ elif test -n "$PKG_CONFIG"; then
+    if test -n "$PKG_CONFIG" && \
+    { { $as_echo "$as_me:${as_lineno-$LINENO}: \$PKG_CONFIG --exists --print-errors \"libcurl >= 7.61.0\""; } >&5
+  ($PKG_CONFIG --exists --print-errors "libcurl >= 7.61.0") 2>&5
+  ac_status=$?
+  $as_echo "$as_me:${as_lineno-$LINENO}: \$? = $ac_status" >&5
+  test $ac_status = 0; }; then
+  pkg_cv_LIBCURL_LIBS=`$PKG_CONFIG --libs "libcurl >= 7.61.0" 2>/dev/null`
+		      test "x$?" != "x0" && pkg_failed=yes
+else
+  pkg_failed=yes
+fi
+ else
+    pkg_failed=untried
+fi
+
+
+
+if test $pkg_failed = yes; then
+        { $as_echo "$as_me:${as_lineno-$LINENO}: result: no" >&5
+$as_echo "no" >&6; }
+
+if $PKG_CONFIG --atleast-pkgconfig-version 0.20; then
+        _pkg_short_errors_supported=yes
+else
+        _pkg_short_errors_supported=no
+fi
+        if test $_pkg_short_errors_supported = yes; then
+	        LIBCURL_PKG_ERRORS=`$PKG_CONFIG --short-errors --print-errors --cflags --libs "libcurl >= 7.61.0" 2>&1`
+        else
+	        LIBCURL_PKG_ERRORS=`$PKG_CONFIG --print-errors --cflags --libs "libcurl >= 7.61.0" 2>&1`
+        fi
+	# Put the nasty error message in config.log where it belongs
+	echo "$LIBCURL_PKG_ERRORS" >&5
+
+	as_fn_error $? "Package requirements (libcurl >= 7.61.0) were not met:
+
+$LIBCURL_PKG_ERRORS
+
+Consider adjusting the PKG_CONFIG_PATH environment variable if you
+installed software in a non-standard prefix.
+
+Alternatively, you may set the environment variables LIBCURL_CFLAGS
+and LIBCURL_LIBS to avoid the need to call pkg-config.
+See the pkg-config man page for more details." "$LINENO" 5
+elif test $pkg_failed = untried; then
+        { $as_echo "$as_me:${as_lineno-$LINENO}: result: no" >&5
+$as_echo "no" >&6; }
+	{ { $as_echo "$as_me:${as_lineno-$LINENO}: error: in \`$ac_pwd':" >&5
+$as_echo "$as_me: error: in \`$ac_pwd':" >&2;}
+as_fn_error $? "The pkg-config script could not be found or is too old.  Make sure it
+is in your PATH or set the PKG_CONFIG environment variable to the full
+path to pkg-config.
+
+Alternatively, you may set the environment variables LIBCURL_CFLAGS
+and LIBCURL_LIBS to avoid the need to call pkg-config.
+See the pkg-config man page for more details.
+
+To get pkg-config, see <http://pkg-config.freedesktop.org/>.
+See \`config.log' for more details" "$LINENO" 5; }
+else
+	LIBCURL_CFLAGS=$pkg_cv_LIBCURL_CFLAGS
+	LIBCURL_LIBS=$pkg_cv_LIBCURL_LIBS
+        { $as_echo "$as_me:${as_lineno-$LINENO}: result: yes" >&5
+$as_echo "yes" >&6; }
+
+fi
+
+  # We only care about -I, -D, and -L switches;
+  # note that -lcurl will be added by PGAC_CHECK_LIBCURL below.
+  for pgac_option in $LIBCURL_CFLAGS; do
+    case $pgac_option in
+      -I*|-D*) CPPFLAGS="$CPPFLAGS $pgac_option";;
+    esac
+  done
+  for pgac_option in $LIBCURL_LIBS; do
+    case $pgac_option in
+      -L*) LDFLAGS="$LDFLAGS $pgac_option";;
+    esac
+  done
+
+  # OAuth requires python for testing
+  if test "$with_python" != yes; then
+    { $as_echo "$as_me:${as_lineno-$LINENO}: WARNING: *** OAuth support tests require --with-python to run" >&5
+$as_echo "$as_me: WARNING: *** OAuth support tests require --with-python to run" >&2;}
+  fi
+fi
+
+
 #
 # XML
 #
@@ -12216,6 +12378,176 @@ fi
 
 fi
 
+# XXX libcurl must link after libgssapi_krb5 on FreeBSD to avoid segfaults
+# during gss_acquire_cred(). This is possibly related to Curl's Heimdal
+# dependency on that platform?
+if test "$with_libcurl" = yes ; then
+
+  ac_fn_c_check_header_mongrel "$LINENO" "curl/curl.h" "ac_cv_header_curl_curl_h" "$ac_includes_default"
+if test "x$ac_cv_header_curl_curl_h" = xyes; then :
+
+else
+  as_fn_error $? "header file <curl/curl.h> is required for --with-libcurl" "$LINENO" 5
+fi
+
+
+  { $as_echo "$as_me:${as_lineno-$LINENO}: checking for curl_multi_init in -lcurl" >&5
+$as_echo_n "checking for curl_multi_init in -lcurl... " >&6; }
+if ${ac_cv_lib_curl_curl_multi_init+:} false; then :
+  $as_echo_n "(cached) " >&6
+else
+  ac_check_lib_save_LIBS=$LIBS
+LIBS="-lcurl  $LIBS"
+cat confdefs.h - <<_ACEOF >conftest.$ac_ext
+/* end confdefs.h.  */
+
+/* Override any GCC internal prototype to avoid an error.
+   Use char because int might match the return type of a GCC
+   builtin and then its argument prototype would still apply.  */
+#ifdef __cplusplus
+extern "C"
+#endif
+char curl_multi_init ();
+int
+main ()
+{
+return curl_multi_init ();
+  ;
+  return 0;
+}
+_ACEOF
+if ac_fn_c_try_link "$LINENO"; then :
+  ac_cv_lib_curl_curl_multi_init=yes
+else
+  ac_cv_lib_curl_curl_multi_init=no
+fi
+rm -f core conftest.err conftest.$ac_objext \
+    conftest$ac_exeext conftest.$ac_ext
+LIBS=$ac_check_lib_save_LIBS
+fi
+{ $as_echo "$as_me:${as_lineno-$LINENO}: result: $ac_cv_lib_curl_curl_multi_init" >&5
+$as_echo "$ac_cv_lib_curl_curl_multi_init" >&6; }
+if test "x$ac_cv_lib_curl_curl_multi_init" = xyes; then :
+  cat >>confdefs.h <<_ACEOF
+#define HAVE_LIBCURL 1
+_ACEOF
+
+  LIBS="-lcurl $LIBS"
+
+else
+  as_fn_error $? "library 'curl' does not provide curl_multi_init" "$LINENO" 5
+fi
+
+
+  # Check to see whether the current platform supports threadsafe Curl
+  # initialization.
+  { $as_echo "$as_me:${as_lineno-$LINENO}: checking for curl_global_init thread safety" >&5
+$as_echo_n "checking for curl_global_init thread safety... " >&6; }
+if ${pgac_cv__libcurl_threadsafe_init+:} false; then :
+  $as_echo_n "(cached) " >&6
+else
+  if test "$cross_compiling" = yes; then :
+  pgac_cv__libcurl_threadsafe_init=unknown
+else
+  cat confdefs.h - <<_ACEOF >conftest.$ac_ext
+/* end confdefs.h.  */
+
+#include <curl/curl.h>
+
+int
+main ()
+{
+
+    curl_version_info_data *info;
+
+    if (curl_global_init(CURL_GLOBAL_ALL))
+        return -1;
+
+    info = curl_version_info(CURLVERSION_NOW);
+#ifdef CURL_VERSION_THREADSAFE
+    if (info->features & CURL_VERSION_THREADSAFE)
+        return 0;
+#endif
+
+    return 1;
+
+  ;
+  return 0;
+}
+_ACEOF
+if ac_fn_c_try_run "$LINENO"; then :
+  pgac_cv__libcurl_threadsafe_init=yes
+else
+  pgac_cv__libcurl_threadsafe_init=no
+fi
+rm -f core *.core core.conftest.* gmon.out bb.out conftest$ac_exeext \
+  conftest.$ac_objext conftest.beam conftest.$ac_ext
+fi
+
+fi
+{ $as_echo "$as_me:${as_lineno-$LINENO}: result: $pgac_cv__libcurl_threadsafe_init" >&5
+$as_echo "$pgac_cv__libcurl_threadsafe_init" >&6; }
+  if test x"$pgac_cv__libcurl_threadsafe_init" = xyes ; then
+
+$as_echo "#define HAVE_THREADSAFE_CURL_GLOBAL_INIT 1" >>confdefs.h
+
+  fi
+
+  # Warn if a thread-friendly DNS resolver isn't built.
+  { $as_echo "$as_me:${as_lineno-$LINENO}: checking for curl support for asynchronous DNS" >&5
+$as_echo_n "checking for curl support for asynchronous DNS... " >&6; }
+if ${pgac_cv__libcurl_async_dns+:} false; then :
+  $as_echo_n "(cached) " >&6
+else
+  if test "$cross_compiling" = yes; then :
+  pgac_cv__libcurl_async_dns=unknown
+else
+  cat confdefs.h - <<_ACEOF >conftest.$ac_ext
+/* end confdefs.h.  */
+
+#include <curl/curl.h>
+
+int
+main ()
+{
+
+    curl_version_info_data *info;
+
+    if (curl_global_init(CURL_GLOBAL_ALL))
+        return -1;
+
+    info = curl_version_info(CURLVERSION_NOW);
+    return (info->features & CURL_VERSION_ASYNCHDNS) ? 0 : 1;
+
+  ;
+  return 0;
+}
+_ACEOF
+if ac_fn_c_try_run "$LINENO"; then :
+  pgac_cv__libcurl_async_dns=yes
+else
+  pgac_cv__libcurl_async_dns=no
+fi
+rm -f core *.core core.conftest.* gmon.out bb.out conftest$ac_exeext \
+  conftest.$ac_objext conftest.beam conftest.$ac_ext
+fi
+
+fi
+{ $as_echo "$as_me:${as_lineno-$LINENO}: result: $pgac_cv__libcurl_async_dns" >&5
+$as_echo "$pgac_cv__libcurl_async_dns" >&6; }
+  if test x"$pgac_cv__libcurl_async_dns" != xyes ; then
+    { $as_echo "$as_me:${as_lineno-$LINENO}: WARNING:
+*** The installed version of libcurl does not support asynchronous DNS
+*** lookups. Connection timeouts will not be honored during DNS resolution,
+*** which may lead to hangs in client programs." >&5
+$as_echo "$as_me: WARNING:
+*** The installed version of libcurl does not support asynchronous DNS
+*** lookups. Connection timeouts will not be honored during DNS resolution,
+*** which may lead to hangs in client programs." >&2;}
+  fi
+
+fi
+
 if test "$with_gssapi" = yes ; then
   if test "$PORTNAME" != "win32"; then
     { $as_echo "$as_me:${as_lineno-$LINENO}: checking for library containing gss_store_cred_into" >&5
diff --git a/configure.ac b/configure.ac
index f56681e0d91..b6d02f5ecc7 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1007,6 +1007,40 @@ fi
 AC_SUBST(with_uuid)
 
 
+#
+# libcurl
+#
+AC_MSG_CHECKING([whether to build with libcurl support])
+PGAC_ARG_BOOL(with, libcurl, no, [build with libcurl support],
+              [AC_DEFINE([USE_LIBCURL], 1, [Define to 1 to build with libcurl support. (--with-libcurl)])])
+AC_MSG_RESULT([$with_libcurl])
+AC_SUBST(with_libcurl)
+
+if test "$with_libcurl" = yes ; then
+  # Check for libcurl 7.61.0 or higher (corresponding to RHEL8 and the ability
+  # to explicitly set TLS 1.3 ciphersuites).
+  PKG_CHECK_MODULES(LIBCURL, [libcurl >= 7.61.0])
+
+  # We only care about -I, -D, and -L switches;
+  # note that -lcurl will be added by PGAC_CHECK_LIBCURL below.
+  for pgac_option in $LIBCURL_CFLAGS; do
+    case $pgac_option in
+      -I*|-D*) CPPFLAGS="$CPPFLAGS $pgac_option";;
+    esac
+  done
+  for pgac_option in $LIBCURL_LIBS; do
+    case $pgac_option in
+      -L*) LDFLAGS="$LDFLAGS $pgac_option";;
+    esac
+  done
+
+  # OAuth requires python for testing
+  if test "$with_python" != yes; then
+    AC_MSG_WARN([*** OAuth support tests require --with-python to run])
+  fi
+fi
+
+
 #
 # XML
 #
@@ -1294,6 +1328,13 @@ failure.  It is possible the compiler isn't looking in the proper directory.
 Use --without-zlib to disable zlib support.])])
 fi
 
+# XXX libcurl must link after libgssapi_krb5 on FreeBSD to avoid segfaults
+# during gss_acquire_cred(). This is possibly related to Curl's Heimdal
+# dependency on that platform?
+if test "$with_libcurl" = yes ; then
+  PGAC_CHECK_LIBCURL
+fi
+
 if test "$with_gssapi" = yes ; then
   if test "$PORTNAME" != "win32"; then
     AC_SEARCH_LIBS(gss_store_cred_into, [gssapi_krb5 gss 'gssapi -lkrb5 -lcrypto'], [],
diff --git a/doc/src/sgml/client-auth.sgml b/doc/src/sgml/client-auth.sgml
index 782b49c85ac..f84085dbac4 100644
--- a/doc/src/sgml/client-auth.sgml
+++ b/doc/src/sgml/client-auth.sgml
@@ -656,6 +656,16 @@ include_dir         <replaceable>directory</replaceable>
          </para>
         </listitem>
        </varlistentry>
+
+       <varlistentry>
+        <term><literal>oauth</literal></term>
+        <listitem>
+         <para>
+          Authorize and optionally authenticate using a third-party OAuth 2.0
+          identity provider. See <xref linkend="auth-oauth"/> for details.
+         </para>
+        </listitem>
+       </varlistentry>
       </variablelist>
 
       </para>
@@ -1143,6 +1153,12 @@ omicron         bryanh                  guest1
       only on OpenBSD).
      </para>
     </listitem>
+    <listitem>
+     <para>
+      <link linkend="auth-oauth">OAuth authorization/authentication</link>,
+      which relies on an external OAuth 2.0 identity provider.
+     </para>
+    </listitem>
    </itemizedlist>
   </para>
 
@@ -2329,6 +2345,242 @@ host ... radius radiusservers="server1,server2" radiussecrets="""secret one"",""
    </note>
   </sect1>
 
+  <sect1 id="auth-oauth">
+   <title>OAuth Authorization/Authentication</title>
+
+   <indexterm zone="auth-oauth">
+    <primary>OAuth Authorization/Authentication</primary>
+   </indexterm>
+
+   <para>
+    OAuth 2.0 is an industry-standard framework, defined in
+    <ulink url="https://datatracker.ietf.org/doc/html/rfc6749">RFC 6749</ulink>,
+    to enable third-party applications to obtain limited access to a protected
+    resource.
+
+    OAuth client support has to be enabled when <productname>PostgreSQL</productname>
+    is built, see <xref linkend="installation"/> for more information.
+   </para>
+
+   <para>
+    This documentation uses the following terminology when discussing the OAuth
+    ecosystem:
+
+    <variablelist>
+
+     <varlistentry>
+      <term>Resource Owner (or End User)</term>
+      <listitem>
+       <para>
+        The user or system who owns protected resources and can grant access to
+        them. This documentation also uses the term <emphasis>end user</emphasis>
+        when the resource owner is a person. When you use
+        <application>psql</application> to connect to the database using OAuth,
+        you are the resource owner/end user.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term>Client</term>
+      <listitem>
+       <para>
+        The system which accesses the protected resources using access
+        tokens. Applications using libpq, such as <application>psql</application>,
+        are the OAuth clients when connecting to a
+        <productname>PostgreSQL</productname> cluster.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term>Resource Server</term>
+      <listitem>
+       <para>
+        The system which hosts the protected resources which are
+        accessed by the client. The <productname>PostgreSQL</productname>
+        cluster being connected to is the resource server.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term>Provider</term>
+      <listitem>
+       <para>
+        The organization, product vendor, or other entity which develops and/or
+        administers the OAuth servers and clients for a given application.
+        Different providers typically choose different implementation details
+        for their OAuth systems; a client of one provider is not generally
+        guaranteed to have access to the servers of another.
+       </para>
+       <para>
+        This use of the term "provider" is not standard, but it seems to be in
+        wide use colloquially. (It should not be confused with OpenID's similar
+        term "Identity Provider". While the implementation of OAuth in
+        <productname>PostgreSQL</productname> is intended to be interoperable
+        and compatible with OpenID Connect/OIDC, it is not itself an OIDC client
+        and does not require its use.)
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term>Authorization Server</term>
+      <listitem>
+       <para>
+        The system which receives requests from, and issues access tokens to,
+        the client after the authenticated resource owner has given approval.
+        <productname>PostgreSQL</productname> does not provide an authorization
+        server; it's obtained from the OAuth provider.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term id="auth-oauth-issuer">Issuer</term>
+      <listitem>
+       <para>
+        An identifier for an authorization server, printed as an
+        <literal>https://</literal> URL, which provides a trusted "namespace"
+        for OAuth clients and applications. The issuer identifier allows a
+        single authorization server to talk to the clients of mutually
+        untrusting entities, as long as they maintain separate issuers.
+       </para>
+      </listitem>
+     </varlistentry>
+
+    </variablelist>
+
+    <note>
+     <para>
+      For small deployments, there may not be a meaningful distinction between
+      the "provider", "authorization server", and "issuer". However, for more
+      complicated setups, there may be a one-to-many (or many-to-many)
+      relationship: a provider may rent out multiple issuer identifiers to
+      separate tenants, then provide multiple authorization servers, possibly
+      with different supported feature sets, to interact with their clients.
+     </para>
+    </note>
+   </para>
+
+   <para>
+    <productname>PostgreSQL</productname> supports bearer tokens, defined in
+    <ulink url="https://datatracker.ietf.org/doc/html/rfc6750">RFC 6750</ulink>,
+    which are a type of access token used with OAuth 2.0 where the token is an
+    opaque string.  The format of the access token is implementation specific
+    and is chosen by each authorization server.
+   </para>
+
+   <para>
+    The following configuration options are supported for OAuth:
+    <variablelist>
+     <varlistentry>
+      <term><literal>issuer</literal></term>
+      <listitem>
+       <para>
+        An HTTPS URL which is either the exact
+        <link linkend="auth-oauth-issuer">issuer identifier</link> of the
+        authorization server, as defined by its discovery document, or a
+        well-known URI that points directly to that discovery document. This
+        parameter is required.
+       </para>
+       <para>
+        When an OAuth client connects to the server, a URL for the discovery
+        document will be constructed using the issuer identifier. By default,
+        this URL uses the conventions of OpenID Connect Discovery: the path
+        <literal>/.well-known/openid-configuration</literal> will be appended
+        to the end of the issuer identifier. Alternatively, if the
+        <literal>issuer</literal> contains a <literal>/.well-known/</literal>
+        path segment, that URL will be provided to the client as-is.
+       </para>
+       <warning>
+        <para>
+         The OAuth client in libpq requires the server's issuer setting to
+         exactly match the issuer identifier which is provided in the discovery
+         document, which must in turn match the client's
+         <xref linkend="libpq-connect-oauth-issuer"/> setting. No variations in
+         case or format are permitted.
+        </para>
+       </warning>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term><literal>scope</literal></term>
+      <listitem>
+       <para>
+        A space-separated list of the OAuth scopes needed for the server to
+        both authorize the client and authenticate the user.  Appropriate values
+        are determined by the authorization server and the OAuth validation
+        module used (see <xref linkend="oauth-validators" /> for more
+        information on validators).  This parameter is required.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term><literal>validator</literal></term>
+      <listitem>
+       <para>
+        The library to use for validating bearer tokens. If given, the name must
+        exactly match one of the libraries listed in
+        <xref linkend="guc-oauth-validator-libraries" />.  This parameter is
+        optional unless <literal>oauth_validator_libraries</literal> contains
+        more than one library, in which case it is required.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term><literal>map</literal></term>
+      <listitem>
+       <para>
+        Allows for mapping between OAuth identity provider and database user
+        names.  See <xref linkend="auth-username-maps"/> for details.  If a
+        map is not specified, the user name associated with the token (as
+        determined by the OAuth validator) must exactly match the role name
+        being requested.  This parameter is optional.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term id="auth-oauth-delegate-ident-mapping" xreflabel="delegate_ident_mapping">
+       <literal>delegate_ident_mapping</literal>
+      </term>
+      <listitem>
+       <para>
+        An advanced option which is not intended for common use.
+       </para>
+       <para>
+        When set to <literal>1</literal>, standard user mapping with
+        <filename>pg_ident.conf</filename> is skipped, and the OAuth validator
+        takes full responsibility for mapping end user identities to database
+        roles.  If the validator authorizes the token, the server trusts that
+        the user is allowed to connect under the requested role, and the
+        connection is allowed to proceed regardless of the authentication
+        status of the user.
+       </para>
+       <para>
+        This parameter is incompatible with <literal>map</literal>.
+       </para>
+       <warning>
+        <para>
+         <literal>delegate_ident_mapping</literal> provides additional
+         flexibility in the design of the authentication system, but it also
+         requires careful implementation of the OAuth validator, which must
+         determine whether the provided token carries sufficient end-user
+         privileges in addition to the <link linkend="oauth-validators">standard
+         checks</link> required of all validators.  Use with caution.
+        </para>
+       </warning>
+      </listitem>
+     </varlistentry>
+    </variablelist>
+   </para>
+  </sect1>
+
   <sect1 id="client-authentication-problems">
    <title>Authentication Problems</title>
 
diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml
index 5e4f201e099..6591a54124c 100644
--- a/doc/src/sgml/config.sgml
+++ b/doc/src/sgml/config.sgml
@@ -1209,6 +1209,32 @@ include_dir 'conf.d'
        </para>
       </listitem>
      </varlistentry>
+
+     <varlistentry id="guc-oauth-validator-libraries" xreflabel="oauth_validator_libraries">
+      <term><varname>oauth_validator_libraries</varname> (<type>string</type>)
+      <indexterm>
+       <primary><varname>oauth_validator_libraries</varname> configuration parameter</primary>
+      </indexterm>
+      </term>
+      <listitem>
+       <para>
+        The library/libraries to use for validating OAuth connection tokens. If
+        only one validator library is provided, it will be used by default for
+        any OAuth connections; otherwise, all
+        <link linkend="auth-oauth"><literal>oauth</literal> HBA entries</link>
+        must explicitly set a <literal>validator</literal> chosen from this
+        list. If set to an empty string (the default), OAuth connections will be
+        refused. This parameter can only be set in the
+        <filename>postgresql.conf</filename> file.
+       </para>
+       <para>
+        Validator modules must be implemented/obtained separately;
+        <productname>PostgreSQL</productname> does not ship with any default
+        implementations. For more information on implementing OAuth validators,
+        see <xref linkend="oauth-validators" />.
+       </para>
+      </listitem>
+     </varlistentry>
      </variablelist>
      </sect2>
 
diff --git a/doc/src/sgml/filelist.sgml b/doc/src/sgml/filelist.sgml
index 66e6dccd4c9..25fb99cee69 100644
--- a/doc/src/sgml/filelist.sgml
+++ b/doc/src/sgml/filelist.sgml
@@ -111,6 +111,7 @@
 <!ENTITY generic-wal SYSTEM "generic-wal.sgml">
 <!ENTITY custom-rmgr SYSTEM "custom-rmgr.sgml">
 <!ENTITY backup-manifest SYSTEM "backup-manifest.sgml">
+<!ENTITY oauth-validators SYSTEM "oauth-validators.sgml">
 
 <!-- contrib information -->
 <!ENTITY contrib         SYSTEM "contrib.sgml">
diff --git a/doc/src/sgml/installation.sgml b/doc/src/sgml/installation.sgml
index 3f0a7e9c069..96e433179b9 100644
--- a/doc/src/sgml/installation.sgml
+++ b/doc/src/sgml/installation.sgml
@@ -1143,6 +1143,19 @@ build-postgresql:
        </listitem>
       </varlistentry>
 
+      <varlistentry id="configure-option-with-libcurl">
+       <term><option>--with-libcurl</option></term>
+       <listitem>
+        <para>
+         Build with libcurl support for OAuth 2.0 client flows.
+         This requires the <productname>curl</productname> package to be
+         installed.  Building with this will check for the required header files
+         and libraries to make sure that your <productname>curl</productname>
+         installation is sufficient before proceeding.
+        </para>
+       </listitem>
+      </varlistentry>
+
       <varlistentry id="configure-option-with-libxml">
        <term><option>--with-libxml</option></term>
        <listitem>
@@ -2584,6 +2597,20 @@ ninja install
       </listitem>
      </varlistentry>
 
+     <varlistentry id="configure-with-libcurl-meson">
+      <term><option>-Dlibcurl={ auto | enabled | disabled }</option></term>
+      <listitem>
+       <para>
+        Build with libcurl support for OAuth 2.0 client flows.
+        This requires the <productname>curl</productname> package to be
+        installed.  Building with this will check for the required header files
+        and libraries to make sure that your <productname>curl</productname>
+        installation is sufficient before proceeding. The default for this
+        option is auto.
+       </para>
+      </listitem>
+     </varlistentry>
+
      <varlistentry id="configure-with-libxml-meson">
       <term><option>-Dlibxml={ auto | enabled | disabled }</option></term>
       <listitem>
diff --git a/doc/src/sgml/libpq.sgml b/doc/src/sgml/libpq.sgml
index c49e975b082..a51355e238f 100644
--- a/doc/src/sgml/libpq.sgml
+++ b/doc/src/sgml/libpq.sgml
@@ -1385,6 +1385,15 @@ postgresql://%2Fvar%2Flib%2Fpostgresql/dbname
           </listitem>
          </varlistentry>
 
+         <varlistentry>
+          <term><literal>oauth</literal></term>
+          <listitem>
+           <para>
+            The server must request an OAuth bearer token from the client.
+           </para>
+          </listitem>
+         </varlistentry>
+
          <varlistentry>
           <term><literal>none</literal></term>
           <listitem>
@@ -2373,6 +2382,106 @@ postgresql://%2Fvar%2Flib%2Fpostgresql/dbname
        </para>
       </listitem>
      </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-issuer" xreflabel="oauth_issuer">
+      <term><literal>oauth_issuer</literal></term>
+      <listitem>
+       <para>
+        The HTTPS URL of a trusted issuer to contact if the server requests an
+        OAuth token for the connection. This parameter is required for all OAuth
+        connections; it should exactly match the <literal>issuer</literal>
+        setting in <link linkend="auth-oauth">the server's HBA configuration.</link>
+       </para>
+       <para>
+        As part of the standard authentication handshake, <application>libpq</application>
+        will ask the server for a <emphasis>discovery document:</emphasis> a URL
+        providing a set of OAuth configuration parameters. The server must
+        provide a URL that is directly constructed from the components of the
+        <literal>oauth_issuer</literal>, and this value must exactly match the
+        issuer identifier that is declared in the discovery document itself, or
+        the connection will fail. This is required to prevent a class of "mix-up
+        attacks" on OAuth clients.
+       </para>
+       <para>
+        You may also explicitly set <literal>oauth_issuer</literal> to the
+        <literal>/.well-known/</literal> URI used for OAuth discovery. In this
+        case, if the server asks for a different URL, the connection will fail,
+        but a <link linkend="libpq-oauth-authdata-hooks">custom OAuth flow</link>
+        may be able to speed up the standard handshake by using previously
+        cached tokens. (In this case, it is recommended that
+        <xref linkend="libpq-connect-oauth-scope"/> be set as well, since the
+        client will not have a chance to ask the server for a correct scope
+        setting, and the default scopes for a token may not be sufficient to
+        connect.) <application>libpq</application> currently supports the
+        following well-known endpoints:
+        <itemizedlist spacing="compact">
+         <listitem><para><literal>/.well-known/openid-configuration</literal></para></listitem>
+         <listitem><para><literal>/.well-known/oauth-authorization-server</literal></para></listitem>
+        </itemizedlist>
+       </para>
+       <warning>
+        <para>
+         Issuers are highly privileged during the OAuth connection handshake. As
+         a rule of thumb, if you would not trust the operator of a URL to handle
+         access to your servers, or to impersonate you directly, that URL should
+         not be trusted as an <literal>oauth_issuer</literal>.
+        </para>
+       </warning>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-client-id" xreflabel="oauth_client_id">
+      <term><literal>oauth_client_id</literal></term>
+      <listitem>
+       <para>
+        An OAuth 2.0 client identifier, as issued by the authorization server.
+        If the <productname>PostgreSQL</productname> server
+        <link linkend="auth-oauth">requests an OAuth token</link> for the
+        connection (and if no <link linkend="libpq-oauth-authdata-hooks">custom
+        OAuth hook</link> is installed to provide one), then this parameter must
+        be set; otherwise, the connection will fail.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-client-secret" xreflabel="oauth_client_secret">
+      <term><literal>oauth_client_secret</literal></term>
+      <listitem>
+       <para>
+        The client password, if any, to use when contacting the OAuth
+        authorization server. Whether this parameter is required or not is
+        determined by the OAuth provider; "public" clients generally do not use
+        a secret, whereas "confidential" clients generally do.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-scope" xreflabel="oauth_scope">
+      <term><literal>oauth_scope</literal></term>
+      <listitem>
+       <para>
+        The scope of the access request sent to the authorization server,
+        specified as a (possibly empty) space-separated list of OAuth scope
+        identifiers. This parameter is optional and intended for advanced usage.
+       </para>
+       <para>
+        Usually the client will obtain appropriate scope settings from the
+        <productname>PostgreSQL</productname> server. If this parameter is used,
+        the server's requested scope list will be ignored. This can prevent a
+        less-trusted server from requesting inappropriate access scopes from the
+        end user. However, if the client's scope setting does not contain the
+        server's required scopes, the server is likely to reject the issued
+        token, and the connection will fail.
+       </para>
+       <para>
+        The meaning of an empty scope list is provider-dependent. An OAuth
+        authorization server may choose to issue a token with "default scope",
+        whatever that happens to be, or it may reject the token request
+        entirely.
+       </para>
+      </listitem>
+     </varlistentry>
+
     </variablelist>
    </para>
   </sect2>
@@ -10020,6 +10129,291 @@ void PQinitSSL(int do_ssl);
 
  </sect1>
 
+ <sect1 id="libpq-oauth">
+  <title>OAuth Support</title>
+
+  <para>
+   TODO
+  </para>
+
+  <sect2 id="libpq-oauth-authdata-hooks">
+   <title>Authdata Hooks</title>
+
+   <para>
+    The behavior of the OAuth flow may be modified or replaced by a client using
+    the following hook API:
+
+    <variablelist>
+     <varlistentry id="libpq-PQsetAuthDataHook">
+      <term><function>PQsetAuthDataHook</function><indexterm><primary>PQsetAuthDataHook</primary></indexterm></term>
+
+      <listitem>
+       <para>
+        Sets the <symbol>PGauthDataHook</symbol>, overriding
+        <application>libpq</application>'s handling of one or more aspects of
+        its OAuth client flow.
+<synopsis>
+void PQsetAuthDataHook(PQauthDataHook_type hook);
+</synopsis>
+        If <replaceable>hook</replaceable> is <literal>NULL</literal>, the
+        default handler will be reinstalled. Otherwise, the application passes
+        a pointer to a callback function with the signature:
+<programlisting>
+int hook_fn(PGauthData type, PGconn *conn, void *data);
+</programlisting>
+        which <application>libpq</application> will call when when action is
+        required of the application. <replaceable>type</replaceable> describes
+        the request being made, <replaceable>conn</replaceable> is the
+        connection handle being authenticated, and <replaceable>data</replaceable>
+        points to request-specific metadata. The contents of this pointer are
+        determined by <replaceable>type</replaceable>; see
+        <xref linkend="libpq-oauth-authdata-hooks-types"/> for the supported
+        list.
+       </para>
+       <para>
+        Hooks can be chained together to allow cooperative and/or fallback
+        behavior. In general, a hook implementation should examine the incoming
+        <replaceable>type</replaceable> (and, potentially, the request metadata
+        and/or the settings for the particular <replaceable>conn</replaceable>
+        in use) to decide whether or not to handle a specific piece of authdata.
+        If not, it should delegate to the previous hook in the chain
+        (retrievable via <function>PQgetAuthDataHook</function>).
+       </para>
+       <para>
+        Success is indicated by returning an integer greater than zero.
+        Returning a negative integer signals an error condition and abandons the
+        connection attempt. (A zero value is reserved for the default
+        implementation.)
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="libpq-PQgetAuthDataHook">
+      <term><function>PQgetAuthDataHook</function><indexterm><primary>PQgetAuthDataHook</primary></indexterm></term>
+
+      <listitem>
+       <para>
+        Retrieves the current value of <symbol>PGauthDataHook</symbol>.
+<synopsis>
+PQauthDataHook_type PQgetAuthDataHook(void);
+</synopsis>
+        At initialization time (before the first call to
+        <function>PQsetAuthDataHook</function>), this function will return
+        <symbol>PQdefaultAuthDataHook</symbol>.
+       </para>
+      </listitem>
+     </varlistentry>
+    </variablelist>
+   </para>
+
+   <sect3 id="libpq-oauth-authdata-hooks-types">
+    <title>Hook Types</title>
+    <para>
+     The following <symbol>PGauthData</symbol> types and their corresponding
+     <replaceable>data</replaceable> structures are defined:
+
+     <variablelist>
+      <varlistentry id="libpq-oauth-authdata-prompt-oauth-device">
+       <term>
+        <symbol>PQAUTHDATA_PROMPT_OAUTH_DEVICE</symbol>
+        <indexterm><primary>PQAUTHDATA_PROMPT_OAUTH_DEVICE</primary></indexterm>
+       </term>
+       <listitem>
+        <para>
+         Replaces the default user prompt during the builtin device
+         authorization client flow. <replaceable>data</replaceable> points to
+         an instance of <symbol>PGpromptOAuthDevice</symbol>:
+<synopsis>
+typedef struct _PGpromptOAuthDevice
+{
+    const char *verification_uri;   /* verification URI to visit */
+    const char *user_code;          /* user code to enter */
+    const char *verification_uri_complete;  /* optional combination of URI and
+                                             * code, or NULL */
+    int         expires_in;         /* seconds until user code expires */
+} PGpromptOAuthDevice;
+</synopsis>
+        </para>
+        <para>
+         The OAuth Device Authorization flow included in <application>libpq</application>
+         requires the end user to visit a URL with a browser, then enter a code
+         which permits <application>libpq</application> to connect to the server
+         on their behalf. The default prompt simply prints the
+         <literal>verification_uri</literal> and <literal>user_code</literal>
+         on standard error. Replacement implementations may display this
+         information using any preferred method, for example with a GUI.
+        </para>
+        <para>
+         This callback is only invoked during the builtin device
+         authorization flow. If the application installs a
+         <link linkend="libpq-oauth-authdata-oauth-bearer-token">custom OAuth
+         flow</link>, this authdata type will not be used.
+        </para>
+        <para>
+         If a non-NULL <structfield>verification_uri_complete</structfield> is
+         provided, it may optionally be used for non-textual verification (for
+         example, by displaying a QR code). The URL and user code should still
+         be displayed to the end user in this case, because the code will be
+         manually confirmed by the provider, and the URL lets users continue
+         even if they can't use the non-textual method. For more information,
+         see section 3.3.1 in
+         <ulink url="https://datatracker.ietf.org/doc/html/rfc8628#section-3.3.1">RFC 8628</ulink>.
+        </para>
+       </listitem>
+      </varlistentry>
+
+      <varlistentry id="libpq-oauth-authdata-oauth-bearer-token">
+       <term>
+        <symbol>PQAUTHDATA_OAUTH_BEARER_TOKEN</symbol>
+        <indexterm><primary>PQAUTHDATA_OAUTH_BEARER_TOKEN</primary></indexterm>
+       </term>
+       <listitem>
+        <para>
+         Replaces the entire OAuth flow with a custom implementation. The hook
+         should either directly return a Bearer token for the current
+         user/issuer/scope combination, if one is available without blocking, or
+         else set up an asynchronous callback to retrieve one.
+        </para>
+        <para>
+         <replaceable>data</replaceable> points to an instance
+         of <symbol>PGoauthBearerRequest</symbol>, which should be filled in
+         by the implementation:
+<synopsis>
+typedef struct _PGoauthBearerRequest
+{
+    /* Hook inputs (constant across all calls) */
+    const char *const openid_configuration; /* OIDC discovery URL */
+    const char *const scope;                /* required scope(s), or NULL */
+
+    /* Hook outputs */
+
+    /* Callback implementing a custom asynchronous OAuth flow. */
+    PostgresPollingStatusType (*async) (PGconn *conn,
+                                        struct _PGoauthBearerRequest *request,
+                                        SOCKTYPE *altsock);
+
+    /* Callback to clean up custom allocations. */
+    void        (*cleanup) (PGconn *conn, struct _PGoauthBearerRequest *request);
+
+    char       *token;   /* acquired Bearer token */
+    void       *user;    /* hook-defined allocated data */
+} PGoauthBearerRequest;
+</synopsis>
+        </para>
+        <para>
+         Two pieces of information are provided to the hook by
+         <application>libpq</application>:
+         <replaceable>openid_configuration</replaceable> contains the URL of an
+         OAuth discovery document describing the authorization server's
+         supported flows, and <replaceable>scope</replaceable> contains a
+         (possibly empty) space-separated list of OAuth scopes which are
+         required to access the server. Either or both may be
+         <literal>NULL</literal> to indicate that the information was not
+         discoverable. (In this case, implementations may be able to establish
+         the requirements using some other preconfigured knowledge, or they may
+         choose to fail.)
+        </para>
+        <para>
+         The final output of the hook is <replaceable>token</replaceable>, which
+         must point to a valid Bearer token for use on the connection. (This
+         token should be issued by the
+         <xref linkend="libpq-connect-oauth-issuer"/> and hold the requested
+         scopes, or the connection will be rejected by the server's validator
+         module.) The allocated token string must remain valid until
+         <application>libpq</application> is finished connecting; the hook
+         should set a <replaceable>cleanup</replaceable> callback which will be
+         called when <application>libpq</application> no longer requires it.
+        </para>
+        <para>
+         If an implementation cannot immediately produce a
+         <replaceable>token</replaceable> during the initial call to the hook,
+         it should set the <replaceable>async</replaceable> callback to handle
+         nonblocking communication with the authorization server.
+         <footnote>
+          <para>
+           Performing blocking operations during the
+           <symbol>PQAUTHDATA_OAUTH_BEARER_TOKEN</symbol> hook callback will
+           interfere with nonblocking connection APIs such as
+           <function>PQconnectPoll</function> and prevent concurrent connections
+           from making progress. Applications which only ever use the
+           synchronous connection primitives, such as
+           <function>PQconnectdb</function>, may synchronously retrieve a token
+           during the hook instead of implementing the
+           <replaceable>async</replaceable> callback, but they will necessarily
+           be limited to one connection at a time.
+          </para>
+         </footnote>
+         This will be called to begin the flow immediately upon return from the
+         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
+         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>
+         after setting <replaceable>token</replaceable> when the flow is
+         complete, or <symbol>PGRES_POLLING_FAILED</symbol> to indicate failure.
+        </para>
+        <para>
+         Implementations may wish to store additional data for bookkeeping
+         across calls to the <replaceable>async</replaceable> and
+         <replaceable>cleanup</replaceable> callbacks. The
+         <replaceable>user</replaceable> pointer is provided for this purpose;
+         <application>libpq</application> will not touch its contents and the
+         application may use it at its convenience. (Remember to free any
+         allocations during token cleanup.)
+        </para>
+       </listitem>
+      </varlistentry>
+     </variablelist>
+    </para>
+   </sect3>
+  </sect2>
+
+  <sect2 id="libpq-oauth-debugging">
+   <title>Debugging and Developer Settings</title>
+
+   <para>
+    A "dangerous debugging mode" may be enabled by setting the environment
+    variable <envar>PGOAUTHDEBUG=UNSAFE</envar>. This functionality is provided
+    for ease of local development and testing only. It does several things that
+    you will not want a production system to do:
+
+    <itemizedlist spacing="compact">
+     <listitem>
+      <para>
+       permits the use of unencrypted HTTP during the OAuth provider exchange
+      </para>
+     </listitem>
+     <listitem>
+      <para>
+       allows the system's trusted CA list to be completely replaced using the
+       <envar>PGOAUTHCAFILE</envar> environment variable
+      </para>
+     </listitem>
+     <listitem>
+      <para>
+       sprays HTTP traffic (containing several critical secrets) to standard
+       error during the OAuth flow
+      </para>
+     </listitem>
+     <listitem>
+      <para>
+       permits the use of zero-second retry intervals, which can cause the
+       client to busy-loop and pointlessly consume CPU
+      </para>
+     </listitem>
+    </itemizedlist>
+   </para>
+   <warning>
+    <para>
+     Do not share the output of the OAuth flow traffic with third parties. It
+     contains secrets that can be used to attack your clients and servers.
+    </para>
+   </warning>
+  </sect2>
+ </sect1>
+
 
  <sect1 id="libpq-threading">
   <title>Behavior in Threaded Programs</title>
@@ -10092,6 +10486,18 @@ int PQisthreadsafe();
    <application>libpq</application> source code for a way to do cooperative
    locking between <application>libpq</application> and your application.
   </para>
+
+  <para>
+   Similarly, if you are using Curl inside your application,
+   <emphasis>and</emphasis> you do not already
+   <ulink url="https://curl.se/libcurl/c/curl_global_init.html">initialize
+   libcurl globally</ulink> before starting new threads, you will need to
+   cooperatively lock (again via <function>PQregisterThreadLock</function>)
+   around any code that may initialize libcurl. This restriction is lifted for
+   more recent versions of Curl that are built to support threadsafe
+   initialization; those builds can be identified by the advertisement of a
+   <literal>threadsafe</literal> feature in their version metadata.
+  </para>
  </sect1>
 
 
diff --git a/doc/src/sgml/oauth-validators.sgml b/doc/src/sgml/oauth-validators.sgml
new file mode 100644
index 00000000000..d0bca9196d9
--- /dev/null
+++ b/doc/src/sgml/oauth-validators.sgml
@@ -0,0 +1,402 @@
+<!-- doc/src/sgml/oauth-validators.sgml -->
+
+<chapter id="oauth-validators">
+ <title>OAuth Validator Modules</title>
+ <indexterm zone="oauth-validators">
+  <primary>OAuth Validators</primary>
+ </indexterm>
+ <para>
+  <productname>PostgreSQL</productname> provides infrastructure for creating
+  custom modules to perform server-side validation of OAuth bearer tokens.
+  Because OAuth implementations vary so wildly, and bearer token validation is
+  heavily dependent on the issuing party, the server cannot check the token
+  itself; validator modules provide the glue between the server and the OAuth
+  provider in use.
+ </para>
+ <para>
+  OAuth validator modules must at least consist of an initialization function
+  (see <xref linkend="oauth-validator-init"/>) and the required callback for
+  performing validation (see <xref linkend="oauth-validator-callback-validate"/>).
+ </para>
+ <warning>
+  <para>
+   Since a misbehaving validator might let unauthorized users into the database,
+   correct implementation is critical. See
+   <xref linkend="oauth-validator-design"/> for design considerations.
+  </para>
+ </warning>
+
+ <sect1 id="oauth-validator-design">
+  <title>Safely Designing a Validator Module</title>
+  <warning>
+   <para>
+    Read and understand the entirety of this section before implementing a
+    validator module. A malfunctioning validator is potentially worse than no
+    authentication at all, both because of the false sense of security it
+    provides, and because it may contribute to attacks against other pieces of
+    an OAuth ecosystem.
+   </para>
+  </warning>
+
+  <sect2 id="oauth-validator-design-responsibilities">
+   <title>Validator Responsibilities</title>
+   <para>
+    TODO
+   </para>
+   <variablelist>
+    <varlistentry>
+     <term>Validate the Token</term>
+     <listitem>
+      <para>
+       The validator must first ensure that the presented token is in fact a
+       valid Bearer token for use in client authentication. The correct way to
+       do this depends on the provider, but it generally involves either
+       cryptographic operations to prove that the token was created by a trusted
+       party (offline validation), or the presentation of the token to that
+       trusted party so that it can perform validation for you (online
+       validation).
+      </para>
+      <para>
+       Online validation, usually implemented via
+       <ulink url="https://datatracker.ietf.org/doc/html/rfc7662">OAuth Token
+       Introspection</ulink>, requires fewer steps of a validator module and
+       allows central revocation of a token in the event that it is stolen
+       or misissued. However, it does require the module to make at least one
+       network call per authentication attempt (all of which must complete
+       within the configured <xref linkend="guc-authentication-timeout"/>).
+       Additionally, your provider may not provide introspection endpoints for
+       use by external resource servers.
+      </para>
+      <para>
+       Offline validation is much more involved, typically requiring a validator
+       to maintain a list of trusted signing keys for a provider and then
+       check the token's cryptographic signature along with its contents.
+       Implementations must follow the provider's instructions to the letter,
+       including any verification of issuer ("where is this token from?"),
+       audience ("who is this token for?"), and validity period ("when can this
+       token be used?"). Since there is no communication between the module and
+       the provider, tokens cannot be centrally revoked using this method;
+       offline validator implementations may wish to place restrictions on the
+       maximum length of a token's validity period.
+      </para>
+      <para>
+       If the token cannot be validated, the module should immediately fail.
+       Further authentication/authorization is pointless if the bearer token
+       wasn't issued by a trusted party.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Authorize the Client</term>
+     <listitem>
+      <para>
+       Next the validator must ensure that the end user has given the client
+       permission to access the server on their behalf. This generally involves
+       checking the scopes that have been assigned to the token, to make sure
+       that they cover database access for the current HBA parameters.
+      </para>
+      <para>
+       The purpose of this step is to prevent an OAuth client from obtaining a
+       token under false pretenses. If the validator requires all tokens to
+       carry scopes that cover database access, the provider should then loudly
+       prompt the user to grant that access during the flow. This gives them the
+       opportunity to reject the request if the client isn't supposed to be
+       using their credentials to connect to databases.
+      </para>
+      <para>
+       While it is possible to establish client authorization without explicit
+       scopes by using out-of-band knowledge of the deployed architecture, doing
+       so removes the user from the loop, which prevents them from catching
+       deployment mistakes and allows any such mistakes to be exploited
+       silently. Access to the database must be tightly restricted to only
+       trusted clients
+       <footnote>
+        <para>
+         That is, "trusted" in the sense that the OAuth client and the
+         <productname>PostgreSQL</productname> server are controlled by the same
+         entity. Notably, the Device Authorization client flow supported by
+         libpq does not usually meet this bar, since it's designed for use by
+         public/untrusted clients.
+        </para>
+       </footnote>
+       if users are not prompted for additional scopes.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Authenticate the End User</term>
+     <listitem>
+      <para>
+       Finally, the validator should determine a user identifier for the token,
+       either by asking the provider for this information or by extracting it
+       from the token itself, and return that identifier to the server (which
+       will then make a final authorization decision using the HBA
+       configuration). This identifier will be available within the session via
+       <link linkend="functions-info-session-table"><function>system_user</function></link>
+       and recorded in the server logs if <xref linkend="guc-log-connections"/>
+       is enabled.
+      </para>
+      <para>
+       Different providers may record a variety of different authentication
+       information for an end user, typically referred to as
+       <emphasis>claims</emphasis>. Providers usually document which of these
+       claims are trustworthy enough to use for authorization decisions and
+       which are not. (For instance, it would probably not be wise to use an
+       end user's full name as the identifier for authentication, since many
+       providers allow users to change their display names arbitrarily.)
+       Ultimately, the choice of which claim (or combination of claims) to use
+       comes down to the provider implementation and application requirements.
+      </para>
+      <para>
+       Note that anonymous/pseudonymous login is possible as well, by enabling
+       usermap delegation; see
+       <xref linkend="oauth-validator-design-usermap-delegation"/>.
+      </para>
+     </listitem>
+    </varlistentry>
+   </variablelist>
+  </sect2>
+
+  <sect2 id="oauth-validator-design-guidelines">
+   <title>General Coding Guidelines</title>
+   <para>
+    Developers should keep the following in mind when implementing token
+    validation:
+   </para>
+   <variablelist>
+    <varlistentry>
+     <term>Token Confidentiality</term>
+     <listitem>
+      <para>
+       Modules should not write tokens, or pieces of tokens, into the server
+       log. This is true even if the module considers the token invalid; an
+       attacker who confuses a client into communicating with the wrong provider
+       should not be able to retrieve that (otherwise valid) token from the
+       disk.
+      </para>
+      <para>
+       Implementations that send tokens over the network (for example, to
+       perform online token validation with a provider) must authenticate the
+       peer and ensure that strong transport security is in use.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Logging</term>
+     <listitem>
+      <para>
+       Modules may use the same <link linkend="error-message-reporting">logging
+       facilities</link> as standard extensions; however, the rules for emitting
+       log entries to the client are subtly different during the authentication
+       phase of the connection. Generally speaking, modules should log
+       verification problems at the <symbol>COMMERROR</symbol> level and return
+       normally, instead of using <symbol>ERROR</symbol>/<symbol>FATAL</symbol>
+       to unwind the stack, to avoid leaking information to unauthenticated
+       clients.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Interruptibility</term>
+     <listitem>
+      <para>
+       Modules must remain interruptible by signals so that the server can
+       correctly handle authentication timeouts and shutdown signals from
+       <application>pg_ctl</application>. For example, a module receiving
+       <symbol>EINTR</symbol>/<symbol>EAGAIN</symbol> from a blocking call
+       should call <function>CHECK_FOR_INTERRUPTS()</function> before retrying.
+       The same should be done during any long-running loops. Failure to follow
+       this guidance may result in unresponsive backend sessions.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Testing</term>
+     <listitem>
+      <para>
+       The breadth of testing an OAuth system is well beyond the scope of this
+       documentation, but at minimum, negative testing should be considered
+       mandatory. It's trivial to design a module that lets authorized users in;
+       the whole point of the system is to keep unauthorized users out.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Documentation</term>
+     <listitem>
+      <para>
+       Validator implementations should document the contents and format of the
+       authenticated ID that is reported to the server for each end user, since
+       DBAs may need to use this information to construct pg_ident maps. (For
+       instance, is it an email address? an organizational ID number? a UUID?)
+       They should also document whether or not it is safe to use the module in
+       <symbol>delegate_ident_mapping=1</symbol> mode, and what additional
+       configuration is required in order to do so.
+      </para>
+     </listitem>
+    </varlistentry>
+   </variablelist>
+  </sect2>
+
+  <sect2 id="oauth-validator-design-usermap-delegation">
+   <title>Authorizing Users (Usermap Delegation)</title>
+   <para>
+    The standard deliverable of a validation module is the user identifier,
+    which the server will then compare to any configured
+    <link linkend="auth-username-maps"><filename>pg_ident.conf</filename>
+    mappings</link> and determine whether the end user is authorized to connect.
+    However, OAuth is itself an authorization framework, and tokens may carry
+    information about user privileges. For example, a token may be associated
+    with the organizational groups that a user belongs to, or list the roles
+    that a user may assume, and duplicating that knowledge into local usermaps
+    for every server may not be desirable.
+   </para>
+   <para>
+    To bypass username mapping entirely, and have the validator module assume
+    the additional responsibility of authorizing user connections, the HBA may
+    be configured with <xref linkend="auth-oauth-delegate-ident-mapping"/>.
+    The module may then use token scopes or an equivalent method to decide
+    whether the user is allowed to connect under their desired role. The user
+    identifier will still be recorded by the server, but it plays no part in
+    determining whether to continue the connection.
+   </para>
+   <para>
+    Using this scheme, authentication itself is optional. As long as the module
+    reports that the connection is authorized, login will continue even if there
+    is no recorded user identifier at all. This makes it possible to implement
+    anonymous or pseudonymous access to the database, where the third-party
+    provider performs all necessary authentication but does not provide any
+    user-identifying information to the server. (Some providers may create an
+    anonymized ID number that can be recorded instead, for later auditing.)
+   </para>
+   <para>
+    Usermap delegation provides the most architectural flexibility, but it turns
+    the validator module into a single point of failure for connection
+    authorization. Use with caution.
+   </para>
+  </sect2>
+ </sect1>
+
+ <sect1 id="oauth-validator-init">
+  <title>Initialization Functions</title>
+  <indexterm zone="oauth-validator-init">
+   <primary>_PG_oauth_validator_module_init</primary>
+  </indexterm>
+  <para>
+   An OAuth validator module is loaded by dynamically loading one of the shared
+   libraries listed in <xref linkend="guc-oauth-validator-libraries"/>.
+   The normal library search path is used to locate the library. To
+   provide the validator callbacks and to indicate that the library is an OAuth
+   validator module a function named
+   <function>_PG_oauth_validator_module_init</function> must be provided. The
+   return value of the function must be a pointer to a struct of type
+   <structname>OAuthValidatorCallbacks</structname>, which contains pointers to
+   the module's token validation functions. The returned
+   pointer must be of server lifetime, which is typically achieved by defining
+   it as a <literal>static const</literal> variable in global scope.
+<programlisting>
+typedef struct OAuthValidatorCallbacks
+{
+    ValidatorStartupCB startup_cb;
+    ValidatorShutdownCB shutdown_cb;
+    ValidatorValidateCB validate_cb;
+} OAuthValidatorCallbacks;
+
+typedef const OAuthValidatorCallbacks *(*OAuthValidatorModuleInit) (void);
+</programlisting>
+
+   Only the <function>validate_cb</function> callback is required, the others
+   are optional.
+  </para>
+ </sect1>
+
+ <sect1 id="oauth-validator-callbacks">
+  <title>OAuth Validator Callbacks</title>
+  <para>
+   OAuth validator modules implement their functionality by defining a set of
+   callbacks. The server will call them as required to process the
+   authentication request from the user.
+  </para>
+
+  <sect2 id="oauth-validator-callback-startup">
+   <title>Startup Callback</title>
+   <para>
+    The <function>startup_cb</function> callback is executed directly after
+    loading the module. This callback can be used to set up local state and
+    perform additional initialization if required. If the validator module
+    has state it can use <structfield>state->private_data</structfield> to
+    store it.
+
+<programlisting>
+typedef void (*ValidatorStartupCB) (ValidatorModuleState *state);
+</programlisting>
+   </para>
+  </sect2>
+
+  <sect2 id="oauth-validator-callback-validate">
+   <title>Validate Callback</title>
+   <para>
+    The <function>validate_cb</function> callback is executed during the OAuth
+    exchange when a user attempts to authenticate using OAuth.  Any state set in
+    previous calls will be available in <structfield>state->private_data</structfield>.
+
+<programlisting>
+typedef ValidatorModuleResult *(*ValidatorValidateCB) (ValidatorModuleState *state, const char *token, const char *role);
+</programlisting>
+
+    <replaceable>token</replaceable> will contain the bearer token to validate.
+    The server has ensured that the token is well-formed syntactically, but no
+    other validation has been performed.  <replaceable>role</replaceable> will
+    contain the role the user has requested to log in as.  The callback must
+    return a palloc'd <literal>ValidatorModuleResult</literal> struct, which is
+    defined as below:
+
+<programlisting>
+typedef struct ValidatorModuleResult
+{
+    bool        authorized;
+    char       *authn_id;
+} ValidatorModuleResult;
+</programlisting>
+
+    The connection will only proceed if the module sets
+    <structfield>authorized</structfield> to <literal>true</literal>.  To
+    authenticate the user, the authenticated user name (as determined using the
+    token) shall be palloc'd and returned in the <structfield>authn_id</structfield>
+    field.  Alternatively, <structfield>authn_id</structfield> may be set to
+    NULL if the token is valid but the associated user identity cannot be
+    determined.
+   </para>
+   <para>
+    The caller assumes ownership of the returned memory allocation, the
+    validator module should not in any way access the memory after it has been
+    returned.  A validator may instead return NULL to signal an internal
+    error.
+   </para>
+   <para>
+    The behavior after <function>validate_cb</function> returns depends on the
+    specific HBA setup.  Normally, the <structfield>authn_id</structfield> user
+    name must exactly match the role that the user is logging in as.  (This
+    behavior may be modified with a usermap.)  But when authenticating against
+    an HBA rule with <literal>trust_validator_authz</literal> turned on, the
+    server will not perform any checks on the value of
+    <structfield>authn_id</structfield> at all; in this case it is up to the
+    validator to ensure that the token carries enough privileges for the user to
+    log in under the indicated <replaceable>role</replaceable>.
+   </para>
+  </sect2>
+
+  <sect2 id="oauth-validator-callback-shutdown">
+   <title>Shutdown Callback</title>
+   <para>
+    The <function>shutdown_cb</function> callback is executed when the backend
+    process associated with the connection exits. If the validator module has
+    any state, this callback should free it to avoid resource leaks.
+<programlisting>
+typedef void (*ValidatorShutdownCB) (ValidatorModuleState *state);
+</programlisting>
+   </para>
+  </sect2>
+
+ </sect1>
+</chapter>
diff --git a/doc/src/sgml/postgres.sgml b/doc/src/sgml/postgres.sgml
index 7be25c58507..af476c82fcc 100644
--- a/doc/src/sgml/postgres.sgml
+++ b/doc/src/sgml/postgres.sgml
@@ -229,6 +229,7 @@ break is not needed in a wider output rendering.
   &logicaldecoding;
   &replication-origins;
   &archive-modules;
+  &oauth-validators;
 
  </part>
 
diff --git a/doc/src/sgml/protocol.sgml b/doc/src/sgml/protocol.sgml
index fb5dec1172e..3bd9e68e6ce 100644
--- a/doc/src/sgml/protocol.sgml
+++ b/doc/src/sgml/protocol.sgml
@@ -1688,11 +1688,11 @@ SELCT 1/0;<!-- this typo is intentional -->
 
   <para>
    <firstterm>SASL</firstterm> is a framework for authentication in connection-oriented
-   protocols. At the moment, <productname>PostgreSQL</productname> implements two SASL
-   authentication mechanisms, SCRAM-SHA-256 and SCRAM-SHA-256-PLUS. More
-   might be added in the future. The below steps illustrate how SASL
-   authentication is performed in general, while the next subsection gives
-   more details on SCRAM-SHA-256 and SCRAM-SHA-256-PLUS.
+   protocols. At the moment, <productname>PostgreSQL</productname> implements three
+   SASL authentication mechanisms: SCRAM-SHA-256, SCRAM-SHA-256-PLUS, and
+   OAUTHBEARER. More might be added in the future. The below steps illustrate how SASL
+   authentication is performed in general, while the next subsections give
+   more details on particular mechanisms.
   </para>
 
   <procedure>
@@ -1727,7 +1727,7 @@ SELCT 1/0;<!-- this typo is intentional -->
    <step id="sasl-auth-end">
     <para>
      Finally, when the authentication exchange is completed successfully, the
-     server sends an AuthenticationSASLFinal message, followed
+     server sends an optional AuthenticationSASLFinal message, followed
      immediately by an AuthenticationOk message. The AuthenticationSASLFinal
      contains additional server-to-client data, whose content is particular to the
      selected authentication mechanism. If the authentication mechanism doesn't
@@ -1746,9 +1746,9 @@ SELCT 1/0;<!-- this typo is intentional -->
    <title>SCRAM-SHA-256 Authentication</title>
 
    <para>
-    The implemented SASL mechanisms at the moment
-    are <literal>SCRAM-SHA-256</literal> and its variant with channel
-    binding <literal>SCRAM-SHA-256-PLUS</literal>. They are described in
+    <literal>SCRAM-SHA-256</literal>, and its variant with channel
+    binding <literal>SCRAM-SHA-256-PLUS</literal>, are password-based
+    authentication mechanisms. They are described in
     detail in <ulink url="https://datatracker.ietf.org/doc/html/rfc7677">RFC 7677</ulink>
     and <ulink url="https://datatracker.ietf.org/doc/html/rfc5802">RFC 5802</ulink>.
    </para>
@@ -1850,6 +1850,121 @@ SELCT 1/0;<!-- this typo is intentional -->
     </step>
    </procedure>
   </sect2>
+
+  <sect2 id="sasl-oauthbearer">
+   <title>OAUTHBEARER Authentication</title>
+
+   <para>
+    <literal>OAUTHBEARER</literal> is a token-based mechanism for federated
+    authentication. It is described in detail in
+    <ulink url="https://datatracker.ietf.org/doc/html/rfc7628">RFC 7628</ulink>.
+   </para>
+
+   <para>
+    A typical exchange differs depending on whether or not the client already
+    has a bearer token cached for the current user. If it does not, the exchange
+    will take place over two connections: the first "discovery" connection to
+    obtain OAuth metadata from the server, and the second connection to send
+    the token after the client has obtained it. (libpq does not currently
+    implement a caching method as part of its builtin flow, so it uses the
+    two-connection exchange.)
+   </para>
+
+   <para>
+    This mechanism is client-initiated, like SCRAM. The client initial response
+    consists of the standard "GS2" header used by SCRAM, followed by a list of
+    <literal>key=value</literal> pairs. The only key currently supported by
+    the server is <literal>auth</literal>, which contains the bearer token.
+    <literal>OAUTHBEARER</literal> additionally specifies three optional
+    components of the client initial response (the <literal>authzid</literal> of
+    the GS2 header, and the <structfield>host</structfield> and
+    <structfield>port</structfield> keys) which are currently ignored by the
+    server.
+   </para>
+
+   <para>
+    <literal>OAUTHBEARER</literal> does not support channel binding, and there
+    is no "OAUTHBEARER-PLUS" mechanism. This mechanism does not make use of
+    server data during a successful authentication, so the
+    AuthenticationSASLFinal message is not used in the exchange.
+   </para>
+
+   <procedure>
+    <title>Example</title>
+    <step>
+     <para>
+      During the first exchange, the server sends an AuthenticationSASL message
+      with the <literal>OAUTHBEARER</literal> mechanism advertised.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      The client responds by sending a SASLInitialResponse message which
+      indicates the <literal>OAUTHBEARER</literal> mechanism. Assuming the
+      client does not already have a valid bearer token for the current user,
+      the <structfield>auth</structfield> field is empty, indicating a discovery
+      connection.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      Server sends an AuthenticationSASLContinue message containing an error
+      <literal>status</literal> alongside a well-known URI and scopes that the
+      client should use to conduct an OAuth flow.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      Client sends a SASLResponse message containing the empty set (a single
+      <literal>0x01</literal> byte) to finish its half of the discovery
+      exchange.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      Server sends an ErrorMessage to fail the first exchange.
+     </para>
+     <para>
+      At this point, the client conducts one of many possible OAuth flows to
+      obtain a bearer token, using any metadata that it has been configured with
+      in addition to that provided by the server. (This description is left
+      deliberately vague; <literal>OAUTHBEARER</literal> does not specify or
+      mandate any particular method for obtaining a token.)
+     </para>
+     <para>
+      Once it has a token, the client reconnects to the server for the final
+      exchange:
+     </para>
+    </step>
+
+    <step>
+     <para>
+      The server once again sends an AuthenticationSASL message with the
+      <literal>OAUTHBEARER</literal> mechanism advertised.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      The client responds by sending a SASLInitialResponse message, but this
+      time the <structfield>auth</structfield> field in the message contains the
+      bearer token that was obtained during the client flow.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      The server validates the token according to the instructions of the
+      token provider. If the client is authorized to connect, it sends an
+      AuthenticationOk message to end the SASL exchange.
+     </para>
+    </step>
+   </procedure>
+  </sect2>
  </sect1>
 
  <sect1 id="protocol-replication">
diff --git a/doc/src/sgml/regress.sgml b/doc/src/sgml/regress.sgml
index 7c474559bdf..0e5e8e8f309 100644
--- a/doc/src/sgml/regress.sgml
+++ b/doc/src/sgml/regress.sgml
@@ -347,6 +347,16 @@ make check-world PG_TEST_EXTRA='kerberos ldap ssl load_balance libpq_encryption'
       </para>
      </listitem>
     </varlistentry>
+
+    <varlistentry>
+     <term><literal>oauth</literal></term>
+     <listitem>
+      <para>
+       Runs the test suite under <filename>src/test/modules/oauth_validator</filename>.
+       This opens TCP/IP listen sockets for a test-server running HTTPS.
+      </para>
+     </listitem>
+    </varlistentry>
    </variablelist>
 
    Tests for features that are not supported by the current build
diff --git a/meson.build b/meson.build
index 7dd7110318d..5c35159b4f1 100644
--- a/meson.build
+++ b/meson.build
@@ -855,6 +855,101 @@ endif
 
 
 
+###############################################################
+# Library: libcurl
+###############################################################
+
+libcurlopt = get_option('libcurl')
+if not libcurlopt.disabled()
+  # Check for libcurl 7.61.0 or higher (corresponding to RHEL8 and the ability
+  # to explicitly set TLS 1.3 ciphersuites).
+  libcurl = dependency('libcurl', version: '>= 7.61.0', required: libcurlopt)
+  if libcurl.found()
+    cdata.set('USE_LIBCURL', 1)
+
+    # Check to see whether the current platform supports threadsafe Curl
+    # initialization.
+    libcurl_threadsafe_init = false
+
+    if not meson.is_cross_build()
+      r = cc.run('''
+        #include <curl/curl.h>
+
+        int main(void)
+        {
+            curl_version_info_data *info;
+
+            if (curl_global_init(CURL_GLOBAL_ALL))
+                return -1;
+
+            info = curl_version_info(CURLVERSION_NOW);
+        #ifdef CURL_VERSION_THREADSAFE
+            if (info->features & CURL_VERSION_THREADSAFE)
+                return 0;
+        #endif
+
+            return 1;
+        }''',
+        name: 'test for curl_global_init thread safety',
+        dependencies: libcurl,
+      )
+
+      assert(r.compiled())
+      if r.returncode() == 0
+        libcurl_threadsafe_init = true
+        message('curl_global_init is threadsafe')
+      elif r.returncode() == 1
+        message('curl_global_init is not threadsafe')
+      else
+        message('curl_global_init failed; assuming not threadsafe')
+      endif
+    endif
+
+    if libcurl_threadsafe_init
+      cdata.set('HAVE_THREADSAFE_CURL_GLOBAL_INIT', 1)
+    endif
+
+    # Warn if a thread-friendly DNS resolver isn't built.
+    libcurl_async_dns = false
+
+    if not meson.is_cross_build()
+      r = cc.run('''
+        #include <curl/curl.h>
+
+        int main(void)
+        {
+            curl_version_info_data *info;
+
+            if (curl_global_init(CURL_GLOBAL_ALL))
+                return -1;
+
+            info = curl_version_info(CURLVERSION_NOW);
+            return (info->features & CURL_VERSION_ASYNCHDNS) ? 0 : 1;
+        }''',
+        name: 'test for curl support for asynchronous DNS',
+        dependencies: libcurl,
+      )
+
+      assert(r.compiled())
+      if r.returncode() == 0
+        libcurl_async_dns = true
+      endif
+    endif
+
+    if not libcurl_async_dns
+      warning('''
+*** The installed version of libcurl does not support asynchronous DNS
+*** lookups. Connection timeouts will not be honored during DNS resolution,
+*** which may lead to hangs in client programs.''')
+    endif
+  endif
+
+else
+  libcurl = not_found_dep
+endif
+
+
+
 ###############################################################
 # Library: libxml
 ###############################################################
@@ -3045,6 +3140,10 @@ libpq_deps += [
 
   gssapi,
   ldap_r,
+  # XXX libcurl must link after libgssapi_krb5 on FreeBSD to avoid segfaults
+  # during gss_acquire_cred(). This is possibly related to Curl's Heimdal
+  # dependency on that platform?
+  libcurl,
   libintl,
   ssl,
 ]
@@ -3721,6 +3820,7 @@ if meson.version().version_compare('>=0.57')
       'gss': gssapi,
       'icu': icu,
       'ldap': ldap,
+      'libcurl': libcurl,
       'libxml': libxml,
       'libxslt': libxslt,
       'llvm': llvm,
diff --git a/meson_options.txt b/meson_options.txt
index d9c7ddccbc4..702c4517145 100644
--- a/meson_options.txt
+++ b/meson_options.txt
@@ -100,6 +100,9 @@ option('icu', type: 'feature', value: 'auto',
 option('ldap', type: 'feature', value: 'auto',
   description: 'LDAP support')
 
+option('libcurl', type : 'feature', value: 'auto',
+  description: 'libcurl support')
+
 option('libedit_preferred', type: 'boolean', value: false,
   description: 'Prefer BSD Libedit over GNU Readline')
 
diff --git a/src/Makefile.global.in b/src/Makefile.global.in
index bbe11e75bf0..3b620bac5ac 100644
--- a/src/Makefile.global.in
+++ b/src/Makefile.global.in
@@ -190,6 +190,7 @@ with_systemd	= @with_systemd@
 with_gssapi	= @with_gssapi@
 with_krb_srvnam	= @with_krb_srvnam@
 with_ldap	= @with_ldap@
+with_libcurl	= @with_libcurl@
 with_libxml	= @with_libxml@
 with_libxslt	= @with_libxslt@
 with_llvm	= @with_llvm@
diff --git a/src/backend/libpq/Makefile b/src/backend/libpq/Makefile
index 6d385fd6a45..98eb2a8242d 100644
--- a/src/backend/libpq/Makefile
+++ b/src/backend/libpq/Makefile
@@ -15,6 +15,7 @@ include $(top_builddir)/src/Makefile.global
 # be-fsstubs is here for historical reasons, probably belongs elsewhere
 
 OBJS = \
+	auth-oauth.o \
 	auth-sasl.o \
 	auth-scram.o \
 	auth.o \
diff --git a/src/backend/libpq/auth-oauth.c b/src/backend/libpq/auth-oauth.c
new file mode 100644
index 00000000000..aa16977c643
--- /dev/null
+++ b/src/backend/libpq/auth-oauth.c
@@ -0,0 +1,864 @@
+/*-------------------------------------------------------------------------
+ *
+ * auth-oauth.c
+ *	  Server-side implementation of the SASL OAUTHBEARER mechanism.
+ *
+ * See the following RFC for more details:
+ * - RFC 7628: https://tools.ietf.org/html/rfc7628
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/backend/libpq/auth-oauth.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include <unistd.h>
+#include <fcntl.h>
+
+#include "common/oauth-common.h"
+#include "fmgr.h"
+#include "lib/stringinfo.h"
+#include "libpq/auth.h"
+#include "libpq/hba.h"
+#include "libpq/oauth.h"
+#include "libpq/sasl.h"
+#include "storage/fd.h"
+#include "storage/ipc.h"
+#include "utils/json.h"
+#include "utils/varlena.h"
+
+/* GUC */
+char	   *oauth_validator_libraries_string = NULL;
+
+static void oauth_get_mechanisms(Port *port, StringInfo buf);
+static void *oauth_init(Port *port, const char *selected_mech, const char *shadow_pass);
+static int	oauth_exchange(void *opaq, const char *input, int inputlen,
+						   char **output, int *outputlen, const char **logdetail);
+
+static void load_validator_library(const char *libname);
+static void shutdown_validator_library(void *arg);
+
+static ValidatorModuleState *validator_module_state;
+static const OAuthValidatorCallbacks *ValidatorCallbacks;
+
+/* Mechanism declaration */
+const pg_be_sasl_mech pg_be_oauth_mech = {
+	.get_mechanisms = oauth_get_mechanisms,
+	.init = oauth_init,
+	.exchange = oauth_exchange,
+
+	.max_message_length = PG_MAX_AUTH_TOKEN_LENGTH,
+};
+
+/* Valid states for the oauth_exchange() machine. */
+enum oauth_state
+{
+	OAUTH_STATE_INIT = 0,
+	OAUTH_STATE_ERROR,
+	OAUTH_STATE_FINISHED,
+};
+
+/* Mechanism callback state. */
+struct oauth_ctx
+{
+	enum oauth_state state;
+	Port	   *port;
+	const char *issuer;
+	const char *scope;
+};
+
+static char *sanitize_char(char c);
+static char *parse_kvpairs_for_auth(char **input);
+static void generate_error_response(struct oauth_ctx *ctx, char **output, int *outputlen);
+static bool validate(Port *port, const char *auth);
+
+/* Constants seen in an OAUTHBEARER client initial response. */
+#define KVSEP 0x01				/* separator byte for key/value pairs */
+#define AUTH_KEY "auth"			/* key containing the Authorization header */
+#define BEARER_SCHEME "Bearer " /* required header scheme (case-insensitive!) */
+
+/*
+ * Retrieves the OAUTHBEARER mechanism list (currently a single item).
+ *
+ * For a full description of the API, see libpq/sasl.h.
+ */
+static void
+oauth_get_mechanisms(Port *port, StringInfo buf)
+{
+	/* Only OAUTHBEARER is supported. */
+	appendStringInfoString(buf, OAUTHBEARER_NAME);
+	appendStringInfoChar(buf, '\0');
+}
+
+/*
+ * Initializes mechanism state and loads the configured validator module.
+ *
+ * For a full description of the API, see libpq/sasl.h.
+ */
+static void *
+oauth_init(Port *port, const char *selected_mech, const char *shadow_pass)
+{
+	struct oauth_ctx *ctx;
+
+	if (strcmp(selected_mech, OAUTHBEARER_NAME) != 0)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("client selected an invalid SASL authentication mechanism"));
+
+	ctx = palloc0(sizeof(*ctx));
+
+	ctx->state = OAUTH_STATE_INIT;
+	ctx->port = port;
+
+	Assert(port->hba);
+	ctx->issuer = port->hba->oauth_issuer;
+	ctx->scope = port->hba->oauth_scope;
+
+	load_validator_library(port->hba->oauth_validator);
+
+	return ctx;
+}
+
+/*
+ * Implements the OAUTHBEARER SASL exchange (RFC 7628, Sec. 3.2). This pulls
+ * apart the client initial response and validates the Bearer token. It also
+ * handles the dummy error response for a failed handshake, as described in
+ * Sec. 3.2.3.
+ *
+ * For a full description of the API, see libpq/sasl.h.
+ */
+static int
+oauth_exchange(void *opaq, const char *input, int inputlen,
+			   char **output, int *outputlen, const char **logdetail)
+{
+	char	   *input_copy;
+	char	   *p;
+	char		cbind_flag;
+	char	   *auth;
+	int			status;
+
+	struct oauth_ctx *ctx = opaq;
+
+	*output = NULL;
+	*outputlen = -1;
+
+	/*
+	 * If the client didn't include an "Initial Client Response" in the
+	 * SASLInitialResponse message, send an empty challenge, to which the
+	 * client will respond with the same data that usually comes in the
+	 * Initial Client Response.
+	 */
+	if (input == NULL)
+	{
+		Assert(ctx->state == OAUTH_STATE_INIT);
+
+		*output = pstrdup("");
+		*outputlen = 0;
+		return PG_SASL_EXCHANGE_CONTINUE;
+	}
+
+	/*
+	 * Check that the input length agrees with the string length of the input.
+	 */
+	if (inputlen == 0)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("The message is empty."));
+	if (inputlen != strlen(input))
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message length does not match input length."));
+
+	switch (ctx->state)
+	{
+		case OAUTH_STATE_INIT:
+			/* Handle this case below. */
+			break;
+
+		case OAUTH_STATE_ERROR:
+
+			/*
+			 * Only one response is valid for the client during authentication
+			 * failure: a single kvsep.
+			 */
+			if (inputlen != 1 || *input != KVSEP)
+				ereport(ERROR,
+						errcode(ERRCODE_PROTOCOL_VIOLATION),
+						errmsg("malformed OAUTHBEARER message"),
+						errdetail("Client did not send a kvsep response."));
+
+			/* The (failed) handshake is now complete. */
+			ctx->state = OAUTH_STATE_FINISHED;
+			return PG_SASL_EXCHANGE_FAILURE;
+
+		default:
+			elog(ERROR, "invalid OAUTHBEARER exchange state");
+			return PG_SASL_EXCHANGE_FAILURE;
+	}
+
+	/* Handle the client's initial message. */
+	p = input_copy = pstrdup(input);
+
+	/*
+	 * OAUTHBEARER does not currently define a channel binding (so there is no
+	 * OAUTHBEARER-PLUS, and we do not accept a 'p' specifier). We accept a
+	 * 'y' specifier purely for the remote chance that a future specification
+	 * could define one; then future clients can still interoperate with this
+	 * server implementation. 'n' is the expected case.
+	 */
+	cbind_flag = *p;
+	switch (cbind_flag)
+	{
+		case 'p':
+			ereport(ERROR,
+					errcode(ERRCODE_PROTOCOL_VIOLATION),
+					errmsg("malformed OAUTHBEARER message"),
+					errdetail("The server does not support channel binding for OAuth, but the client message includes channel binding data."));
+			break;
+
+		case 'y':				/* fall through */
+		case 'n':
+			p++;
+			if (*p != ',')
+				ereport(ERROR,
+						errcode(ERRCODE_PROTOCOL_VIOLATION),
+						errmsg("malformed OAUTHBEARER message"),
+						errdetail("Comma expected, but found character \"%s\".",
+								  sanitize_char(*p)));
+			p++;
+			break;
+
+		default:
+			ereport(ERROR,
+					errcode(ERRCODE_PROTOCOL_VIOLATION),
+					errmsg("malformed OAUTHBEARER message"),
+					errdetail("Unexpected channel-binding flag \"%s\".",
+							  sanitize_char(cbind_flag)));
+	}
+
+	/*
+	 * Forbid optional authzid (authorization identity).  We don't support it.
+	 */
+	if (*p == 'a')
+		ereport(ERROR,
+				errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				errmsg("client uses authorization identity, but it is not supported"));
+	if (*p != ',')
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Unexpected attribute \"%s\" in client-first-message.",
+						  sanitize_char(*p)));
+	p++;
+
+	/* All remaining fields are separated by the RFC's kvsep (\x01). */
+	if (*p != KVSEP)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Key-value separator expected, but found character \"%s\".",
+						  sanitize_char(*p)));
+	p++;
+
+	auth = parse_kvpairs_for_auth(&p);
+	if (!auth)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message does not contain an auth value."));
+
+	/* We should be at the end of our message. */
+	if (*p)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message contains additional data after the final terminator."));
+
+	if (!validate(ctx->port, auth))
+	{
+		generate_error_response(ctx, output, outputlen);
+
+		ctx->state = OAUTH_STATE_ERROR;
+		status = PG_SASL_EXCHANGE_CONTINUE;
+	}
+	else
+	{
+		ctx->state = OAUTH_STATE_FINISHED;
+		status = PG_SASL_EXCHANGE_SUCCESS;
+	}
+
+	/* Don't let extra copies of the bearer token hang around. */
+	explicit_bzero(input_copy, inputlen);
+
+	return status;
+}
+
+/*
+ * Convert an arbitrary byte to printable form.  For error messages.
+ *
+ * If it's a printable ASCII character, print it as a single character.
+ * otherwise, print it in hex.
+ *
+ * The returned pointer points to a static buffer.
+ */
+static char *
+sanitize_char(char c)
+{
+	static char buf[5];
+
+	if (c >= 0x21 && c <= 0x7E)
+		snprintf(buf, sizeof(buf), "'%c'", c);
+	else
+		snprintf(buf, sizeof(buf), "0x%02x", (unsigned char) c);
+	return buf;
+}
+
+/*
+ * Performs syntactic validation of a key and value from the initial client
+ * response. (Semantic validation of interesting values must be performed
+ * later.)
+ */
+static void
+validate_kvpair(const char *key, const char *val)
+{
+	/*-----
+	 * From Sec 3.1:
+	 *     key            = 1*(ALPHA)
+	 */
+	static const char *key_allowed_set =
+		"abcdefghijklmnopqrstuvwxyz"
+		"ABCDEFGHIJKLMNOPQRSTUVWXYZ";
+
+	size_t		span;
+
+	if (!key[0])
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message contains an empty key name."));
+
+	span = strspn(key, key_allowed_set);
+	if (key[span] != '\0')
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message contains an invalid key name."));
+
+	/*-----
+	 * From Sec 3.1:
+	 *     value          = *(VCHAR / SP / HTAB / CR / LF )
+	 *
+	 * The VCHAR (visible character) class is large; a loop is more
+	 * straightforward than strspn().
+	 */
+	for (; *val; ++val)
+	{
+		if (0x21 <= *val && *val <= 0x7E)
+			continue;			/* VCHAR */
+
+		switch (*val)
+		{
+			case ' ':
+			case '\t':
+			case '\r':
+			case '\n':
+				continue;		/* SP, HTAB, CR, LF */
+
+			default:
+				ereport(ERROR,
+						errcode(ERRCODE_PROTOCOL_VIOLATION),
+						errmsg("malformed OAUTHBEARER message"),
+						errdetail("Message contains an invalid value."));
+		}
+	}
+}
+
+/*
+ * Consumes all kvpairs in an OAUTHBEARER exchange message. If the "auth" key is
+ * found, its value is returned.
+ */
+static char *
+parse_kvpairs_for_auth(char **input)
+{
+	char	   *pos = *input;
+	char	   *auth = NULL;
+
+	/*----
+	 * The relevant ABNF, from Sec. 3.1:
+	 *
+	 *     kvsep          = %x01
+	 *     key            = 1*(ALPHA)
+	 *     value          = *(VCHAR / SP / HTAB / CR / LF )
+	 *     kvpair         = key "=" value kvsep
+	 *   ;;gs2-header     = See RFC 5801
+	 *     client-resp    = (gs2-header kvsep *kvpair kvsep) / kvsep
+	 *
+	 * By the time we reach this code, the gs2-header and initial kvsep have
+	 * already been validated. We start at the beginning of the first kvpair.
+	 */
+
+	while (*pos)
+	{
+		char	   *end;
+		char	   *sep;
+		char	   *key;
+		char	   *value;
+
+		/*
+		 * Find the end of this kvpair. Note that input is null-terminated by
+		 * the SASL code, so the strchr() is bounded.
+		 */
+		end = strchr(pos, KVSEP);
+		if (!end)
+			ereport(ERROR,
+					errcode(ERRCODE_PROTOCOL_VIOLATION),
+					errmsg("malformed OAUTHBEARER message"),
+					errdetail("Message contains an unterminated key/value pair."));
+		*end = '\0';
+
+		if (pos == end)
+		{
+			/* Empty kvpair, signifying the end of the list. */
+			*input = pos + 1;
+			return auth;
+		}
+
+		/*
+		 * Find the end of the key name.
+		 */
+		sep = strchr(pos, '=');
+		if (!sep)
+			ereport(ERROR,
+					errcode(ERRCODE_PROTOCOL_VIOLATION),
+					errmsg("malformed OAUTHBEARER message"),
+					errdetail("Message contains a key without a value."));
+		*sep = '\0';
+
+		/* Both key and value are now safely terminated. */
+		key = pos;
+		value = sep + 1;
+		validate_kvpair(key, value);
+
+		if (strcmp(key, AUTH_KEY) == 0)
+		{
+			if (auth)
+				ereport(ERROR,
+						errcode(ERRCODE_PROTOCOL_VIOLATION),
+						errmsg("malformed OAUTHBEARER message"),
+						errdetail("Message contains multiple auth values."));
+
+			auth = value;
+		}
+		else
+		{
+			/*
+			 * The RFC also defines the host and port keys, but they are not
+			 * required for OAUTHBEARER and we do not use them. Also, per Sec.
+			 * 3.1, any key/value pairs we don't recognize must be ignored.
+			 */
+		}
+
+		/* Move to the next pair. */
+		pos = end + 1;
+	}
+
+	ereport(ERROR,
+			errcode(ERRCODE_PROTOCOL_VIOLATION),
+			errmsg("malformed OAUTHBEARER message"),
+			errdetail("Message did not contain a final terminator."));
+
+	pg_unreachable();
+	return NULL;
+}
+
+/*
+ * Builds the JSON response for failed authentication (RFC 7628, Sec. 3.2.2).
+ * This contains the required scopes for entry and a pointer to the OAuth/OpenID
+ * discovery document, which the client may use to conduct its OAuth flow.
+ */
+static void
+generate_error_response(struct oauth_ctx *ctx, char **output, int *outputlen)
+{
+	StringInfoData buf;
+	StringInfoData issuer;
+
+	/*
+	 * The admin needs to set an issuer and scope for OAuth to work. There's
+	 * not really a way to hide this from the user, either, because we can't
+	 * choose a "default" issuer, so be honest in the failure message. (In
+	 * practice such configurations are rejected during HBA parsing.)
+	 */
+	if (!ctx->issuer || !ctx->scope)
+		ereport(FATAL,
+				errcode(ERRCODE_INTERNAL_ERROR),
+				errmsg("OAuth is not properly configured for this user"),
+				errdetail_log("The issuer and scope parameters must be set in pg_hba.conf."));
+
+	/*
+	 * Build a default .well-known URI based on our issuer, unless the HBA has
+	 * already provided one.
+	 */
+	initStringInfo(&issuer);
+	appendStringInfoString(&issuer, ctx->issuer);
+	if (strstr(ctx->issuer, "/.well-known/") == NULL)
+		appendStringInfoString(&issuer, "/.well-known/openid-configuration");
+
+	initStringInfo(&buf);
+
+	/*
+	 * Escaping the string here is belt-and-suspenders defensive programming
+	 * since escapable characters aren't valid in either the issuer URI or the
+	 * scope list, but the HBA doesn't enforce that yet.
+	 */
+	appendStringInfoString(&buf, "{ \"status\": \"invalid_token\", ");
+
+	appendStringInfoString(&buf, "\"openid-configuration\": ");
+	escape_json(&buf, issuer.data);
+	pfree(issuer.data);
+
+	appendStringInfoString(&buf, ", \"scope\": ");
+	escape_json(&buf, ctx->scope);
+
+	appendStringInfoString(&buf, " }");
+
+	*output = buf.data;
+	*outputlen = buf.len;
+}
+
+/*-----
+ * Validates the provided Authorization header and returns the token from
+ * within it. NULL is returned on validation failure.
+ *
+ * Only Bearer tokens are accepted. The ABNF is defined in RFC 6750, Sec.
+ * 2.1:
+ *
+ *      b64token    = 1*( ALPHA / DIGIT /
+ *                        "-" / "." / "_" / "~" / "+" / "/" ) *"="
+ *      credentials = "Bearer" 1*SP b64token
+ *
+ * The "credentials" construction is what we receive in our auth value.
+ *
+ * Since that spec is subordinate to HTTP (i.e. the HTTP Authorization
+ * header format; RFC 9110 Sec. 11), the "Bearer" scheme string must be
+ * compared case-insensitively. (This is not mentioned in RFC 6750, but the
+ * OAUTHBEARER spec points it out: RFC 7628 Sec. 4.)
+ *
+ * Invalid formats are technically a protocol violation, but we shouldn't
+ * reflect any information about the sensitive Bearer token back to the
+ * client; log at COMMERROR instead.
+ */
+static const char *
+validate_token_format(const char *header)
+{
+	size_t		span;
+	const char *token;
+	static const char *const b64token_allowed_set =
+		"abcdefghijklmnopqrstuvwxyz"
+		"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+		"0123456789-._~+/";
+
+	/* Missing auth headers should be handled by the caller. */
+	Assert(header);
+
+	if (header[0] == '\0')
+	{
+		/*
+		 * A completely empty auth header represents a query for
+		 * authentication parameters. The client expects it to fail; there's
+		 * no need to make any extra noise in the logs.
+		 *
+		 * TODO: should we find a way to return STATUS_EOF at the top level,
+		 * to suppress the authentication error entirely?
+		 */
+		return NULL;
+	}
+
+	if (pg_strncasecmp(header, BEARER_SCHEME, strlen(BEARER_SCHEME)))
+	{
+		ereport(COMMERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAuth bearer token"),
+				errdetail_log("Client response indicated a non-Bearer authentication scheme."));
+		return NULL;
+	}
+
+	/* Pull the bearer token out of the auth value. */
+	token = header + strlen(BEARER_SCHEME);
+
+	/* Swallow any additional spaces. */
+	while (*token == ' ')
+		token++;
+
+	/* Tokens must not be empty. */
+	if (!*token)
+	{
+		ereport(COMMERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAuth bearer token"),
+				errdetail_log("Bearer token is empty."));
+		return NULL;
+	}
+
+	/*
+	 * Make sure the token contains only allowed characters. Tokens may end
+	 * with any number of '=' characters.
+	 */
+	span = strspn(token, b64token_allowed_set);
+	while (token[span] == '=')
+		span++;
+
+	if (token[span] != '\0')
+	{
+		/*
+		 * This error message could be more helpful by printing the
+		 * problematic character(s), but that'd be a bit like printing a piece
+		 * of someone's password into the logs.
+		 */
+		ereport(COMMERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAuth bearer token"),
+				errdetail_log("Bearer token is not in the correct format."));
+		return NULL;
+	}
+
+	return token;
+}
+
+/*
+ * Checks that the "auth" kvpair in the client response contains a syntactically
+ * valid Bearer token, then passes it along to the loaded validator module for
+ * authorization. Returns true if validation succeeds.
+ */
+static bool
+validate(Port *port, const char *auth)
+{
+	int			map_status;
+	ValidatorModuleResult *ret;
+	const char *token;
+	bool		status;
+
+	/* Ensure that we have a correct token to validate */
+	if (!(token = validate_token_format(auth)))
+		return false;
+
+	/*
+	 * Ensure that we have a validation library loaded, this should always be
+	 * the case and an error here is indicative of a bug.
+	 */
+	if (!ValidatorCallbacks || !ValidatorCallbacks->validate_cb)
+		ereport(FATAL,
+				errcode(ERRCODE_INTERNAL_ERROR),
+				errmsg("validation of OAuth token requested without a validator loaded"));
+
+	/* Call the validation function from the validator module */
+	ret = ValidatorCallbacks->validate_cb(validator_module_state,
+										  token, port->user_name);
+	if (ret == NULL)
+	{
+		ereport(LOG, errmsg("internal error in OAuth validator module"));
+		return false;
+	}
+
+	/*
+	 * Log any authentication results even if the token isn't authorized; it
+	 * might be useful for auditing or troubleshooting.
+	 */
+	if (ret->authn_id)
+		set_authn_id(port, ret->authn_id);
+
+	if (!ret->authorized)
+	{
+		ereport(LOG,
+				errmsg("OAuth bearer authentication failed for user \"%s\"",
+					   port->user_name),
+				errdetail_log("Validator failed to authorize the provided token."));
+
+		status = false;
+		goto cleanup;
+	}
+
+	if (port->hba->oauth_skip_usermap)
+	{
+		/*
+		 * If the validator is our authorization authority, we're done.
+		 * Authentication may or may not have been performed depending on the
+		 * validator implementation; all that matters is that the validator
+		 * says the user can log in with the target role.
+		 */
+		status = true;
+		goto cleanup;
+	}
+
+	/* Make sure the validator authenticated the user. */
+	if (ret->authn_id == NULL || ret->authn_id[0] == '\0')
+	{
+		ereport(LOG,
+				errmsg("OAuth bearer authentication failed for user \"%s\"",
+					   port->user_name),
+				errdetail_log("Validator provided no identity."));
+
+		status = false;
+		goto cleanup;
+	}
+
+	/* Finally, check the user map. */
+	map_status = check_usermap(port->hba->usermap, port->user_name,
+							   MyClientConnectionInfo.authn_id, false);
+	status = (map_status == STATUS_OK);
+
+cleanup:
+
+	/*
+	 * Clear and free the validation result from the validator module once
+	 * we're done with it.
+	 */
+	if (ret->authn_id != NULL)
+		pfree(ret->authn_id);
+	pfree(ret);
+
+	return status;
+}
+
+/*
+ * load_validator_library
+ *
+ * Load the configured validator library in order to perform token validation.
+ * There is no built-in fallback since validation is implementation specific. If
+ * no validator library is configured, or if it fails to load, then error out
+ * since token validation won't be possible.
+ */
+static void
+load_validator_library(const char *libname)
+{
+	OAuthValidatorModuleInit validator_init;
+	MemoryContextCallback *mcb;
+
+	Assert(libname && *libname);
+
+	validator_init = (OAuthValidatorModuleInit)
+		load_external_function(libname, "_PG_oauth_validator_module_init",
+							   false, NULL);
+
+	/*
+	 * The validator init function is required since it will set the callbacks
+	 * for the validator library.
+	 */
+	if (validator_init == NULL)
+		ereport(ERROR,
+				errmsg("%s module \"%s\" must define the symbol %s",
+					   "OAuth validator", libname, "_PG_oauth_validator_module_init"));
+
+	ValidatorCallbacks = (*validator_init) ();
+	Assert(ValidatorCallbacks);
+
+	/* Allocate memory for validator library private state data */
+	validator_module_state = (ValidatorModuleState *) palloc0(sizeof(ValidatorModuleState));
+	if (ValidatorCallbacks->startup_cb != NULL)
+		ValidatorCallbacks->startup_cb(validator_module_state);
+
+	/* Shut down the library before cleaning up its state. */
+	mcb = palloc0(sizeof(*mcb));
+	mcb->func = shutdown_validator_library;
+
+	MemoryContextRegisterResetCallback(CurrentMemoryContext, mcb);
+}
+
+/*
+ * Call the validator module's shutdown callback, if one is provided. This is
+ * invoked during memory context reset.
+ */
+static void
+shutdown_validator_library(void *arg)
+{
+	if (ValidatorCallbacks->shutdown_cb != NULL)
+		ValidatorCallbacks->shutdown_cb(validator_module_state);
+}
+
+/*
+ * Ensure an OAuth validator named in the HBA is permitted by the configuration.
+ *
+ * If the validator is currently unset and exactly one library is declared in
+ * oauth_validator_libraries, then that library will be used as the validator.
+ * Otherwise the name must be present in the list of oauth_validator_libraries.
+ */
+bool
+check_oauth_validator(HbaLine *hbaline, int elevel, char **err_msg)
+{
+	int			line_num = hbaline->linenumber;
+	char	   *file_name = hbaline->sourcefile;
+	char	   *rawstring;
+	List	   *elemlist = NIL;
+
+	*err_msg = NULL;
+
+	if (oauth_validator_libraries_string[0] == '\0')
+	{
+		ereport(elevel,
+				errcode(ERRCODE_CONFIG_FILE_ERROR),
+				errmsg("oauth_validator_libraries must be set for authentication method %s",
+					   "oauth"),
+				errcontext("line %d of configuration file \"%s\"",
+						   line_num, file_name));
+		*err_msg = psprintf("oauth_validator_libraries must be set for authentication method %s",
+							"oauth");
+		return false;
+	}
+
+	/* SplitDirectoriesString needs a modifiable copy */
+	rawstring = pstrdup(oauth_validator_libraries_string);
+
+	if (!SplitDirectoriesString(rawstring, ',', &elemlist))
+	{
+		/* syntax error in list */
+		ereport(elevel,
+				errcode(ERRCODE_CONFIG_FILE_ERROR),
+				errmsg("invalid list syntax in parameter \"%s\"",
+					   "oauth_validator_libraries"));
+		*err_msg = psprintf("invalid list syntax in parameter \"%s\"",
+							"oauth_validator_libraries");
+		goto done;
+	}
+
+	if (!hbaline->oauth_validator)
+	{
+		if (elemlist->length == 1)
+		{
+			hbaline->oauth_validator = pstrdup(linitial(elemlist));
+			goto done;
+		}
+
+		ereport(elevel,
+				errcode(ERRCODE_CONFIG_FILE_ERROR),
+				errmsg("authentication method \"oauth\" requires argument \"validator\" to be set when oauth_validator_libraries contains multiple options"),
+				errcontext("line %d of configuration file \"%s\"",
+						   line_num, file_name));
+		*err_msg = "authentication method \"oauth\" requires argument \"validator\" to be set when oauth_validator_libraries contains multiple options";
+		goto done;
+	}
+
+	foreach_ptr(char, allowed, elemlist)
+	{
+		if (strcmp(allowed, hbaline->oauth_validator) == 0)
+			goto done;
+	}
+
+	ereport(elevel,
+			errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+			errmsg("validator \"%s\" is not permitted by %s",
+				   hbaline->oauth_validator, "oauth_validator_libraries"),
+			errcontext("line %d of configuration file \"%s\"",
+					   line_num, file_name));
+	*err_msg = psprintf("validator \"%s\" is not permitted by %s",
+						hbaline->oauth_validator, "oauth_validator_libraries");
+
+done:
+	list_free_deep(elemlist);
+	pfree(rawstring);
+
+	return (*err_msg == NULL);
+}
diff --git a/src/backend/libpq/auth.c b/src/backend/libpq/auth.c
index d6ef32cc823..0f65014e64f 100644
--- a/src/backend/libpq/auth.c
+++ b/src/backend/libpq/auth.c
@@ -29,6 +29,7 @@
 #include "libpq/auth.h"
 #include "libpq/crypt.h"
 #include "libpq/libpq.h"
+#include "libpq/oauth.h"
 #include "libpq/pqformat.h"
 #include "libpq/sasl.h"
 #include "libpq/scram.h"
@@ -45,7 +46,6 @@
  */
 static void auth_failed(Port *port, int status, const char *logdetail);
 static char *recv_password_packet(Port *port);
-static void set_authn_id(Port *port, const char *id);
 
 
 /*----------------------------------------------------------------
@@ -289,6 +289,9 @@ auth_failed(Port *port, int status, const char *logdetail)
 		case uaRADIUS:
 			errstr = gettext_noop("RADIUS authentication failed for user \"%s\"");
 			break;
+		case uaOAuth:
+			errstr = gettext_noop("OAuth bearer authentication failed for user \"%s\"");
+			break;
 		default:
 			errstr = gettext_noop("authentication failed for user \"%s\": invalid authentication method");
 			break;
@@ -324,7 +327,7 @@ auth_failed(Port *port, int status, const char *logdetail)
  * lifetime of MyClientConnectionInfo, so it is safe to pass a string that is
  * managed by an external library.
  */
-static void
+void
 set_authn_id(Port *port, const char *id)
 {
 	Assert(id);
@@ -611,6 +614,9 @@ ClientAuthentication(Port *port)
 		case uaTrust:
 			status = STATUS_OK;
 			break;
+		case uaOAuth:
+			status = CheckSASLAuth(&pg_be_oauth_mech, port, NULL, NULL);
+			break;
 	}
 
 	if ((status == STATUS_OK && port->hba->clientcert == clientCertFull)
diff --git a/src/backend/libpq/hba.c b/src/backend/libpq/hba.c
index 510c9ffc6d7..332fad27835 100644
--- a/src/backend/libpq/hba.c
+++ b/src/backend/libpq/hba.c
@@ -32,6 +32,7 @@
 #include "libpq/hba.h"
 #include "libpq/ifaddr.h"
 #include "libpq/libpq-be.h"
+#include "libpq/oauth.h"
 #include "postmaster/postmaster.h"
 #include "regex/regex.h"
 #include "replication/walsender.h"
@@ -114,7 +115,8 @@ static const char *const UserAuthName[] =
 	"ldap",
 	"cert",
 	"radius",
-	"peer"
+	"peer",
+	"oauth",
 };
 
 /*
@@ -1747,6 +1749,8 @@ parse_hba_line(TokenizedAuthLine *tok_line, int elevel)
 #endif
 	else if (strcmp(token->string, "radius") == 0)
 		parsedline->auth_method = uaRADIUS;
+	else if (strcmp(token->string, "oauth") == 0)
+		parsedline->auth_method = uaOAuth;
 	else
 	{
 		ereport(elevel,
@@ -2039,6 +2043,36 @@ parse_hba_line(TokenizedAuthLine *tok_line, int elevel)
 		parsedline->clientcert = clientCertFull;
 	}
 
+	/*
+	 * Enforce proper configuration of OAuth authentication.
+	 */
+	if (parsedline->auth_method == uaOAuth)
+	{
+		MANDATORY_AUTH_ARG(parsedline->oauth_scope, "scope", "oauth");
+		MANDATORY_AUTH_ARG(parsedline->oauth_issuer, "issuer", "oauth");
+
+		/* Ensure a validator library is set and permitted by the config. */
+		if (!check_oauth_validator(parsedline, elevel, err_msg))
+			return NULL;
+
+		/*
+		 * Supplying a usermap combined with the option to skip usermapping is
+		 * nonsensical and indicates a configuration error.
+		 */
+		if (parsedline->oauth_skip_usermap && parsedline->usermap != NULL)
+		{
+			ereport(elevel,
+					errcode(ERRCODE_CONFIG_FILE_ERROR),
+			/* translator: strings are replaced with hba options */
+					errmsg("%s cannot be used in combination with %s",
+						   "map", "delegate_ident_mapping"),
+					errcontext("line %d of configuration file \"%s\"",
+							   line_num, file_name));
+			*err_msg = "map cannot be used in combination with delegate_ident_mapping";
+			return NULL;
+		}
+	}
+
 	return parsedline;
 }
 
@@ -2066,8 +2100,9 @@ parse_hba_auth_opt(char *name, char *val, HbaLine *hbaline,
 			hbaline->auth_method != uaPeer &&
 			hbaline->auth_method != uaGSS &&
 			hbaline->auth_method != uaSSPI &&
-			hbaline->auth_method != uaCert)
-			INVALID_AUTH_OPTION("map", gettext_noop("ident, peer, gssapi, sspi, and cert"));
+			hbaline->auth_method != uaCert &&
+			hbaline->auth_method != uaOAuth)
+			INVALID_AUTH_OPTION("map", gettext_noop("ident, peer, gssapi, sspi, cert, and oauth"));
 		hbaline->usermap = pstrdup(val);
 	}
 	else if (strcmp(name, "clientcert") == 0)
@@ -2450,6 +2485,29 @@ parse_hba_auth_opt(char *name, char *val, HbaLine *hbaline,
 		hbaline->radiusidentifiers = parsed_identifiers;
 		hbaline->radiusidentifiers_s = pstrdup(val);
 	}
+	else if (strcmp(name, "issuer") == 0)
+	{
+		REQUIRE_AUTH_OPTION(uaOAuth, "issuer", "oauth");
+		hbaline->oauth_issuer = pstrdup(val);
+	}
+	else if (strcmp(name, "scope") == 0)
+	{
+		REQUIRE_AUTH_OPTION(uaOAuth, "scope", "oauth");
+		hbaline->oauth_scope = pstrdup(val);
+	}
+	else if (strcmp(name, "validator") == 0)
+	{
+		REQUIRE_AUTH_OPTION(uaOAuth, "validator", "oauth");
+		hbaline->oauth_validator = pstrdup(val);
+	}
+	else if (strcmp(name, "delegate_ident_mapping") == 0)
+	{
+		REQUIRE_AUTH_OPTION(uaOAuth, "delegate_ident_mapping", "oauth");
+		if (strcmp(val, "1") == 0)
+			hbaline->oauth_skip_usermap = true;
+		else
+			hbaline->oauth_skip_usermap = false;
+	}
 	else
 	{
 		ereport(elevel,
diff --git a/src/backend/libpq/meson.build b/src/backend/libpq/meson.build
index 0f0421037e4..31aa2faae1e 100644
--- a/src/backend/libpq/meson.build
+++ b/src/backend/libpq/meson.build
@@ -1,6 +1,7 @@
 # Copyright (c) 2022-2025, PostgreSQL Global Development Group
 
 backend_sources += files(
+  'auth-oauth.c',
   'auth-sasl.c',
   'auth-scram.c',
   'auth.c',
diff --git a/src/backend/libpq/pg_hba.conf.sample b/src/backend/libpq/pg_hba.conf.sample
index bad13497a34..b64c8dea97c 100644
--- a/src/backend/libpq/pg_hba.conf.sample
+++ b/src/backend/libpq/pg_hba.conf.sample
@@ -53,8 +53,8 @@
 # directly connected to.
 #
 # METHOD can be "trust", "reject", "md5", "password", "scram-sha-256",
-# "gss", "sspi", "ident", "peer", "pam", "ldap", "radius" or "cert".
-# Note that "password" sends passwords in clear text; "md5" or
+# "gss", "sspi", "ident", "peer", "pam", "oauth", "ldap", "radius" or
+# "cert".  Note that "password" sends passwords in clear text; "md5" or
 # "scram-sha-256" are preferred since they send encrypted passwords.
 #
 # OPTIONS are a set of options for the authentication in the format
diff --git a/src/backend/utils/adt/hbafuncs.c b/src/backend/utils/adt/hbafuncs.c
index 03c38e8c451..b62c3d944cf 100644
--- a/src/backend/utils/adt/hbafuncs.c
+++ b/src/backend/utils/adt/hbafuncs.c
@@ -152,6 +152,25 @@ get_hba_options(HbaLine *hba)
 				CStringGetTextDatum(psprintf("radiusports=%s", hba->radiusports_s));
 	}
 
+	if (hba->auth_method == uaOAuth)
+	{
+		if (hba->oauth_issuer)
+			options[noptions++] =
+				CStringGetTextDatum(psprintf("issuer=%s", hba->oauth_issuer));
+
+		if (hba->oauth_scope)
+			options[noptions++] =
+				CStringGetTextDatum(psprintf("scope=%s", hba->oauth_scope));
+
+		if (hba->oauth_validator)
+			options[noptions++] =
+				CStringGetTextDatum(psprintf("validator=%s", hba->oauth_validator));
+
+		if (hba->oauth_skip_usermap)
+			options[noptions++] =
+				CStringGetTextDatum(psprintf("delegate_ident_mapping=true"));
+	}
+
 	/* If you add more options, consider increasing MAX_HBA_OPTIONS. */
 	Assert(noptions <= MAX_HBA_OPTIONS);
 
diff --git a/src/backend/utils/misc/guc_tables.c b/src/backend/utils/misc/guc_tables.c
index 226af43fe23..68833ca5fa3 100644
--- a/src/backend/utils/misc/guc_tables.c
+++ b/src/backend/utils/misc/guc_tables.c
@@ -49,6 +49,7 @@
 #include "jit/jit.h"
 #include "libpq/auth.h"
 #include "libpq/libpq.h"
+#include "libpq/oauth.h"
 #include "libpq/scram.h"
 #include "nodes/queryjumble.h"
 #include "optimizer/cost.h"
@@ -4852,6 +4853,17 @@ struct config_string ConfigureNamesString[] =
 		check_restrict_nonsystem_relation_kind, assign_restrict_nonsystem_relation_kind, NULL
 	},
 
+	{
+		{"oauth_validator_libraries", PGC_SIGHUP, CONN_AUTH_AUTH,
+			gettext_noop("Lists libraries that may be called to validate OAuth v2 bearer tokens."),
+			NULL,
+			GUC_LIST_INPUT | GUC_LIST_QUOTE | GUC_SUPERUSER_ONLY
+		},
+		&oauth_validator_libraries_string,
+		"",
+		NULL, NULL, NULL
+	},
+
 	/* End-of-list marker */
 	{
 		{NULL, 0, 0, NULL, NULL}, NULL, NULL, NULL, NULL, NULL
diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample
index d472987ed46..ccefd214143 100644
--- a/src/backend/utils/misc/postgresql.conf.sample
+++ b/src/backend/utils/misc/postgresql.conf.sample
@@ -121,6 +121,9 @@
 #ssl_passphrase_command = ''
 #ssl_passphrase_command_supports_reload = off
 
+# OAuth
+#oauth_validator_libraries = ''	# comma-separated list of trusted validator modules
+
 
 #------------------------------------------------------------------------------
 # RESOURCE USAGE (except WAL)
diff --git a/src/include/common/oauth-common.h b/src/include/common/oauth-common.h
new file mode 100644
index 00000000000..8fe56267780
--- /dev/null
+++ b/src/include/common/oauth-common.h
@@ -0,0 +1,19 @@
+/*-------------------------------------------------------------------------
+ *
+ * oauth-common.h
+ *		Declarations for helper functions used for OAuth/OIDC authentication
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/include/common/oauth-common.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef OAUTH_COMMON_H
+#define OAUTH_COMMON_H
+
+/* Name of SASL mechanism per IANA */
+#define OAUTHBEARER_NAME "OAUTHBEARER"
+
+#endif							/* OAUTH_COMMON_H */
diff --git a/src/include/libpq/auth.h b/src/include/libpq/auth.h
index 902c5f6de32..25b5742068f 100644
--- a/src/include/libpq/auth.h
+++ b/src/include/libpq/auth.h
@@ -39,6 +39,7 @@ extern PGDLLIMPORT bool pg_gss_accept_delegation;
 extern void ClientAuthentication(Port *port);
 extern void sendAuthRequest(Port *port, AuthRequest areq, const char *extradata,
 							int extralen);
+extern void set_authn_id(Port *port, const char *id);
 
 /* Hook for plugins to get control in ClientAuthentication() */
 typedef void (*ClientAuthentication_hook_type) (Port *, int);
diff --git a/src/include/libpq/hba.h b/src/include/libpq/hba.h
index b20d0051f7d..3657f182db3 100644
--- a/src/include/libpq/hba.h
+++ b/src/include/libpq/hba.h
@@ -39,7 +39,8 @@ typedef enum UserAuth
 	uaCert,
 	uaRADIUS,
 	uaPeer,
-#define USER_AUTH_LAST uaPeer	/* Must be last value of this enum */
+	uaOAuth,
+#define USER_AUTH_LAST uaOAuth	/* Must be last value of this enum */
 } UserAuth;
 
 /*
@@ -135,6 +136,10 @@ typedef struct HbaLine
 	char	   *radiusidentifiers_s;
 	List	   *radiusports;
 	char	   *radiusports_s;
+	char	   *oauth_issuer;
+	char	   *oauth_scope;
+	char	   *oauth_validator;
+	bool		oauth_skip_usermap;
 } HbaLine;
 
 typedef struct IdentLine
diff --git a/src/include/libpq/oauth.h b/src/include/libpq/oauth.h
new file mode 100644
index 00000000000..4fcdda74305
--- /dev/null
+++ b/src/include/libpq/oauth.h
@@ -0,0 +1,54 @@
+/*-------------------------------------------------------------------------
+ *
+ * oauth.h
+ *	  Interface to libpq/auth-oauth.c
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/include/libpq/oauth.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PG_OAUTH_H
+#define PG_OAUTH_H
+
+#include "libpq/libpq-be.h"
+#include "libpq/sasl.h"
+
+extern PGDLLIMPORT char *oauth_validator_libraries_string;
+
+typedef struct ValidatorModuleState
+{
+	void	   *private_data;
+} ValidatorModuleState;
+
+typedef struct ValidatorModuleResult
+{
+	bool		authorized;
+	char	   *authn_id;
+} ValidatorModuleResult;
+
+typedef void (*ValidatorStartupCB) (ValidatorModuleState *state);
+typedef void (*ValidatorShutdownCB) (ValidatorModuleState *state);
+typedef ValidatorModuleResult *(*ValidatorValidateCB) (ValidatorModuleState *state, const char *token, const char *role);
+
+typedef struct OAuthValidatorCallbacks
+{
+	ValidatorStartupCB startup_cb;
+	ValidatorShutdownCB shutdown_cb;
+	ValidatorValidateCB validate_cb;
+} OAuthValidatorCallbacks;
+
+typedef const OAuthValidatorCallbacks *(*OAuthValidatorModuleInit) (void);
+extern PGDLLEXPORT const OAuthValidatorCallbacks *_PG_oauth_validator_module_init(void);
+
+/* Implementation */
+extern const pg_be_sasl_mech pg_be_oauth_mech;
+
+/*
+ * Ensure a validator named in the HBA is permitted by the configuration.
+ */
+extern bool check_oauth_validator(HbaLine *hba, int elevel, char **err_msg);
+
+#endif							/* PG_OAUTH_H */
diff --git a/src/include/pg_config.h.in b/src/include/pg_config.h.in
index 07b2f798abd..c04ee38d086 100644
--- a/src/include/pg_config.h.in
+++ b/src/include/pg_config.h.in
@@ -229,6 +229,9 @@
 /* Define to 1 if you have the `crypto' library (-lcrypto). */
 #undef HAVE_LIBCRYPTO
 
+/* Define to 1 if you have the `curl' library (-lcurl). */
+#undef HAVE_LIBCURL
+
 /* Define to 1 if you have the `ldap' library (-lldap). */
 #undef HAVE_LIBLDAP
 
@@ -442,6 +445,9 @@
 /* Define to 1 if you have the <termios.h> header file. */
 #undef HAVE_TERMIOS_H
 
+/* Define to 1 if curl_global_init() is guaranteed to be threadsafe. */
+#undef HAVE_THREADSAFE_CURL_GLOBAL_INIT
+
 /* Define to 1 if your compiler understands `typeof' or something similar. */
 #undef HAVE_TYPEOF
 
@@ -663,6 +669,9 @@
 /* Define to 1 to build with LDAP support. (--with-ldap) */
 #undef USE_LDAP
 
+/* Define to 1 to build with libcurl support. (--with-libcurl) */
+#undef USE_LIBCURL
+
 /* Define to 1 to build with XML support. (--with-libxml) */
 #undef USE_LIBXML
 
diff --git a/src/interfaces/libpq/Makefile b/src/interfaces/libpq/Makefile
index 701810a272a..90b0b65db6f 100644
--- a/src/interfaces/libpq/Makefile
+++ b/src/interfaces/libpq/Makefile
@@ -31,6 +31,7 @@ endif
 
 OBJS = \
 	$(WIN32RES) \
+	fe-auth-oauth.o \
 	fe-auth-scram.o \
 	fe-cancel.o \
 	fe-connect.o \
@@ -63,6 +64,10 @@ OBJS += \
 	fe-secure-gssapi.o
 endif
 
+ifeq ($(with_libcurl),yes)
+OBJS += fe-auth-oauth-curl.o
+endif
+
 ifeq ($(PORTNAME), cygwin)
 override shlib = cyg$(NAME)$(DLSUFFIX)
 endif
@@ -81,7 +86,7 @@ endif
 # that are built correctly for use in a shlib.
 SHLIB_LINK_INTERNAL = -lpgcommon_shlib -lpgport_shlib
 ifneq ($(PORTNAME), win32)
-SHLIB_LINK += $(filter -lcrypt -ldes -lcom_err -lcrypto -lk5crypto -lkrb5 -lgssapi_krb5 -lgss -lgssapi -lssl -lsocket -lnsl -lresolv -lintl -lm, $(LIBS)) $(LDAP_LIBS_FE) $(PTHREAD_LIBS)
+SHLIB_LINK += $(filter -lcrypt -ldes -lcom_err -lcrypto -lk5crypto -lkrb5 -lgssapi_krb5 -lgss -lgssapi -lssl -lcurl -lsocket -lnsl -lresolv -lintl -lm, $(LIBS)) $(LDAP_LIBS_FE) $(PTHREAD_LIBS)
 else
 SHLIB_LINK += $(filter -lcrypt -ldes -lcom_err -lcrypto -lk5crypto -lkrb5 -lgssapi32 -lssl -lsocket -lnsl -lresolv -lintl -lm $(PTHREAD_LIBS), $(LIBS)) $(LDAP_LIBS_FE)
 endif
@@ -110,6 +115,8 @@ backend_src = $(top_srcdir)/src/backend
 # which seems to insert references to that even in pure C code. Excluding
 # __tsan_func_exit is necessary when using ThreadSanitizer data race detector
 # which use this function for instrumentation of function exit.
+# libcurl registers an exit handler in the memory debugging code when running
+# with LeakSanitizer.
 # Skip the test when profiling, as gcc may insert exit() calls for that.
 # Also skip the test on platforms where libpq infrastructure may be provided
 # by statically-linked libraries, as we can't expect them to honor this
@@ -117,7 +124,7 @@ backend_src = $(top_srcdir)/src/backend
 libpq-refs-stamp: $(shlib)
 ifneq ($(enable_coverage), yes)
 ifeq (,$(filter solaris,$(PORTNAME)))
-	@if nm -A -u $< 2>/dev/null | grep -v -e __cxa_atexit -e __tsan_func_exit | grep exit; then \
+	@if nm -A -u $< 2>/dev/null | grep -v -e __cxa_atexit -e __tsan_func_exit -e _atexit | grep exit; then \
 		echo 'libpq must not be calling any function which invokes exit'; exit 1; \
 	fi
 endif
diff --git a/src/interfaces/libpq/exports.txt b/src/interfaces/libpq/exports.txt
index 2ad2cbf5ca3..9b789cbec0b 100644
--- a/src/interfaces/libpq/exports.txt
+++ b/src/interfaces/libpq/exports.txt
@@ -206,3 +206,6 @@ PQsocketPoll              203
 PQsetChunkedRowsMode      204
 PQgetCurrentTimeUSec      205
 PQservice                 206
+PQsetAuthDataHook         207
+PQgetAuthDataHook         208
+PQdefaultAuthDataHook     209
diff --git a/src/interfaces/libpq/fe-auth-oauth-curl.c b/src/interfaces/libpq/fe-auth-oauth-curl.c
new file mode 100644
index 00000000000..2179bb89800
--- /dev/null
+++ b/src/interfaces/libpq/fe-auth-oauth-curl.c
@@ -0,0 +1,2858 @@
+/*-------------------------------------------------------------------------
+ *
+ * fe-auth-oauth-curl.c
+ *	   The libcurl implementation of OAuth/OIDC authentication, using the
+ *	   OAuth Device Authorization Grant (RFC 8628).
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/interfaces/libpq/fe-auth-oauth-curl.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres_fe.h"
+
+#include <curl/curl.h>
+#include <math.h>
+#ifdef HAVE_SYS_EPOLL_H
+#include <sys/epoll.h>
+#include <sys/timerfd.h>
+#endif
+#ifdef HAVE_SYS_EVENT_H
+#include <sys/event.h>
+#endif
+#include <unistd.h>
+
+#include "common/jsonapi.h"
+#include "fe-auth.h"
+#include "fe-auth-oauth.h"
+#include "libpq-int.h"
+#include "mb/pg_wchar.h"
+
+#define MAX_OAUTH_RESPONSE_SIZE (1024 * 1024)
+
+/*
+ * Parsed JSON Representations
+ *
+ * As a general rule, we parse and cache only the fields we're currently using.
+ * When adding new fields, ensure the corresponding free_*() function is updated
+ * too.
+ */
+
+/*
+ * The OpenID Provider configuration (alternatively named "authorization server
+ * metadata") jointly described by OpenID Connect Discovery 1.0 and RFC 8414:
+ *
+ *     https://openid.net/specs/openid-connect-discovery-1_0.html
+ *     https://www.rfc-editor.org/rfc/rfc8414#section-3.2
+ */
+struct provider
+{
+	char	   *issuer;
+	char	   *token_endpoint;
+	char	   *device_authorization_endpoint;
+	struct curl_slist *grant_types_supported;
+};
+
+static void
+free_provider(struct provider *provider)
+{
+	free(provider->issuer);
+	free(provider->token_endpoint);
+	free(provider->device_authorization_endpoint);
+	curl_slist_free_all(provider->grant_types_supported);
+}
+
+/*
+ * The Device Authorization response, described by RFC 8628:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc8628#section-3.2
+ */
+struct device_authz
+{
+	char	   *device_code;
+	char	   *user_code;
+	char	   *verification_uri;
+	char	   *verification_uri_complete;
+	char	   *expires_in_str;
+	char	   *interval_str;
+
+	/* Fields below are parsed from the corresponding string above. */
+	int			expires_in;
+	int			interval;
+};
+
+static void
+free_device_authz(struct device_authz *authz)
+{
+	free(authz->device_code);
+	free(authz->user_code);
+	free(authz->verification_uri);
+	free(authz->verification_uri_complete);
+	free(authz->expires_in_str);
+	free(authz->interval_str);
+}
+
+/*
+ * The Token Endpoint error response, as described by RFC 6749:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc6749#section-5.2
+ *
+ * Note that this response type can also be returned from the Device
+ * Authorization Endpoint.
+ */
+struct token_error
+{
+	char	   *error;
+	char	   *error_description;
+};
+
+static void
+free_token_error(struct token_error *err)
+{
+	free(err->error);
+	free(err->error_description);
+}
+
+/*
+ * The Access Token response, as described by RFC 6749:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc6749#section-4.1.4
+ *
+ * During the Device Authorization flow, several temporary errors are expected
+ * as part of normal operation. To make it easy to handle these in the happy
+ * path, this contains an embedded token_error that is filled in if needed.
+ */
+struct token
+{
+	/* for successful responses */
+	char	   *access_token;
+	char	   *token_type;
+
+	/* for error responses */
+	struct token_error err;
+};
+
+static void
+free_token(struct token *tok)
+{
+	free(tok->access_token);
+	free(tok->token_type);
+	free_token_error(&tok->err);
+}
+
+/*
+ * Asynchronous State
+ */
+
+/* States for the overall async machine. */
+enum OAuthStep
+{
+	OAUTH_STEP_INIT = 0,
+	OAUTH_STEP_DISCOVERY,
+	OAUTH_STEP_DEVICE_AUTHORIZATION,
+	OAUTH_STEP_TOKEN_REQUEST,
+	OAUTH_STEP_WAIT_INTERVAL,
+};
+
+/*
+ * The async_ctx holds onto state that needs to persist across multiple calls
+ * to pg_fe_run_oauth_flow(). Almost everything interacts with this in some
+ * way.
+ */
+struct async_ctx
+{
+	enum OAuthStep step;		/* where are we in the flow? */
+
+	int			timerfd;		/* descriptor for signaling async timeouts */
+	pgsocket	mux;			/* the multiplexer socket containing all
+								 * descriptors tracked by libcurl, plus the
+								 * timerfd */
+	CURLM	   *curlm;			/* top-level multi handle for libcurl
+								 * operations */
+	CURL	   *curl;			/* the (single) easy handle for serial
+								 * requests */
+
+	struct curl_slist *headers; /* common headers for all requests */
+	PQExpBufferData work_data;	/* scratch buffer for general use (remember to
+								 * clear out prior contents first!) */
+
+	/*------
+	 * Since a single logical operation may stretch across multiple calls to
+	 * our entry point, errors have three parts:
+	 *
+	 * - errctx:	an optional static string, describing the global operation
+	 *				currently in progress. It'll be translated for you.
+	 *
+	 * - errbuf:	contains the actual error message. Generally speaking, use
+	 *				actx_error[_str] to manipulate this. This must be filled
+	 *				with something useful on an error.
+	 *
+	 * - curl_err:	an optional static error buffer used by libcurl to put
+	 *				detailed information about failures. Unfortunately
+	 *				untranslatable.
+	 *
+	 * These pieces will be combined into a single error message looking
+	 * something like the following, with errctx and/or curl_err omitted when
+	 * absent:
+	 *
+	 *     connection to server ... failed: errctx: errbuf (curl_err)
+	 */
+	const char *errctx;			/* not freed; must point to static allocation */
+	PQExpBufferData errbuf;
+	char		curl_err[CURL_ERROR_SIZE];
+
+	/*
+	 * These documents need to survive over multiple calls, and are therefore
+	 * cached directly in the async_ctx.
+	 */
+	struct provider provider;
+	struct device_authz authz;
+
+	int			running;		/* is asynchronous work in progress? */
+	bool		user_prompted;	/* have we already sent the authz prompt? */
+	bool		used_basic_auth;	/* did we send a client secret? */
+	bool		debugging;		/* can we give unsafe developer assistance? */
+};
+
+/*
+ * Tears down the Curl handles and frees the async_ctx.
+ */
+static void
+free_async_ctx(PGconn *conn, struct async_ctx *actx)
+{
+	/*
+	 * In general, none of the error cases below should ever happen if we have
+	 * no bugs above. But if we do hit them, surfacing those errors somehow
+	 * might be the only way to have a chance to debug them.
+	 *
+	 * TODO: At some point it'd be nice to have a standard way to warn about
+	 * teardown failures. Appending to the connection's error message only
+	 * helps if the bug caused a connection failure; otherwise it'll be
+	 * buried...
+	 */
+
+	if (actx->curlm && actx->curl)
+	{
+		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));
+	}
+
+	if (actx->curl)
+	{
+		/*
+		 * curl_multi_cleanup() doesn't free any associated easy handles; we
+		 * need to do that separately. We only ever have one easy handle per
+		 * multi handle.
+		 */
+		curl_easy_cleanup(actx->curl);
+	}
+
+	if (actx->curlm)
+	{
+		CURLMcode	err = curl_multi_cleanup(actx->curlm);
+
+		if (err)
+			libpq_append_conn_error(conn,
+									"libcurl multi handle cleanup failed: %s",
+									curl_multi_strerror(err));
+	}
+
+	free_provider(&actx->provider);
+	free_device_authz(&actx->authz);
+
+	curl_slist_free_all(actx->headers);
+	termPQExpBuffer(&actx->work_data);
+	termPQExpBuffer(&actx->errbuf);
+
+	if (actx->mux != PGINVALID_SOCKET)
+		close(actx->mux);
+	if (actx->timerfd >= 0)
+		close(actx->timerfd);
+
+	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.
+ */
+void
+pg_fe_cleanup_oauth_flow(PGconn *conn)
+{
+	fe_oauth_state *state = conn->sasl_state;
+
+	if (state->async_ctx)
+	{
+		free_async_ctx(conn, state->async_ctx);
+		state->async_ctx = NULL;
+	}
+
+	conn->altsock = PGINVALID_SOCKET;
+}
+
+/*
+ * Macros for manipulating actx->errbuf. actx_error() translates and formats a
+ * string for you; actx_error_str() appends a string directly without
+ * translation.
+ */
+
+#define actx_error(ACTX, FMT, ...) \
+	appendPQExpBuffer(&(ACTX)->errbuf, libpq_gettext(FMT), ##__VA_ARGS__)
+
+#define actx_error_str(ACTX, S) \
+	appendPQExpBufferStr(&(ACTX)->errbuf, S)
+
+/*
+ * Macros for getting and setting state for the connection's two libcurl
+ * handles, so you don't have to write out the error handling every time.
+ */
+
+#define CHECK_MSETOPT(ACTX, OPT, VAL, FAILACTION) \
+	do { \
+		struct async_ctx *_actx = (ACTX); \
+		CURLMcode	_setopterr = curl_multi_setopt(_actx->curlm, OPT, VAL); \
+		if (_setopterr) { \
+			actx_error(_actx, "failed to set %s on OAuth connection: %s",\
+					   #OPT, curl_multi_strerror(_setopterr)); \
+			FAILACTION; \
+		} \
+	} while (0)
+
+#define CHECK_SETOPT(ACTX, OPT, VAL, FAILACTION) \
+	do { \
+		struct async_ctx *_actx = (ACTX); \
+		CURLcode	_setopterr = curl_easy_setopt(_actx->curl, OPT, VAL); \
+		if (_setopterr) { \
+			actx_error(_actx, "failed to set %s on OAuth connection: %s",\
+					   #OPT, curl_easy_strerror(_setopterr)); \
+			FAILACTION; \
+		} \
+	} while (0)
+
+#define CHECK_GETINFO(ACTX, INFO, OUT, FAILACTION) \
+	do { \
+		struct async_ctx *_actx = (ACTX); \
+		CURLcode	_getinfoerr = curl_easy_getinfo(_actx->curl, INFO, OUT); \
+		if (_getinfoerr) { \
+			actx_error(_actx, "failed to get %s from OAuth response: %s",\
+					   #INFO, curl_easy_strerror(_getinfoerr)); \
+			FAILACTION; \
+		} \
+	} while (0)
+
+/*
+ * General JSON Parsing for OAuth Responses
+ */
+
+/*
+ * Represents a single name/value pair in a JSON object. This is the primary
+ * interface to parse_oauth_json().
+ *
+ * All fields are stored internally as strings or lists of strings, so clients
+ * have to explicitly parse other scalar types (though they will have gone
+ * through basic lexical validation). Storing nested objects is not currently
+ * supported, nor is parsing arrays of anything other than strings.
+ */
+struct json_field
+{
+	const char *name;			/* name (key) of the member */
+
+	JsonTokenType type;			/* currently supports JSON_TOKEN_STRING,
+								 * JSON_TOKEN_NUMBER, and
+								 * JSON_TOKEN_ARRAY_START */
+	union
+	{
+		char	  **scalar;		/* for all scalar types */
+		struct curl_slist **array;	/* for type == JSON_TOKEN_ARRAY_START */
+	}			target;
+
+	bool		required;		/* REQUIRED field, or just OPTIONAL? */
+};
+
+/* Documentation macros for json_field.required. */
+#define REQUIRED true
+#define OPTIONAL false
+
+/* Parse state for parse_oauth_json(). */
+struct oauth_parse
+{
+	PQExpBuffer errbuf;			/* detail message for JSON_SEM_ACTION_FAILED */
+	int			nested;			/* nesting level (zero is the top) */
+
+	const struct json_field *fields;	/* field definition array */
+	const struct json_field *active;	/* points inside the fields array */
+};
+
+#define oauth_parse_set_error(ctx, fmt, ...) \
+	appendPQExpBuffer((ctx)->errbuf, libpq_gettext(fmt), ##__VA_ARGS__)
+
+static void
+report_type_mismatch(struct oauth_parse *ctx)
+{
+	char	   *msgfmt;
+
+	Assert(ctx->active);
+
+	/*
+	 * At the moment, the only fields we're interested in are strings,
+	 * numbers, and arrays of strings.
+	 */
+	switch (ctx->active->type)
+	{
+		case JSON_TOKEN_STRING:
+			msgfmt = "field \"%s\" must be a string";
+			break;
+
+		case JSON_TOKEN_NUMBER:
+			msgfmt = "field \"%s\" must be a number";
+			break;
+
+		case JSON_TOKEN_ARRAY_START:
+			msgfmt = "field \"%s\" must be an array of strings";
+			break;
+
+		default:
+			Assert(false);
+			msgfmt = "field \"%s\" has unexpected type";
+	}
+
+	oauth_parse_set_error(ctx, msgfmt, ctx->active->name);
+}
+
+static JsonParseErrorType
+oauth_json_object_start(void *state)
+{
+	struct oauth_parse *ctx = state;
+
+	if (ctx->active)
+	{
+		/*
+		 * Currently, none of the fields we're interested in can be or contain
+		 * objects, so we can reject this case outright.
+		 */
+		report_type_mismatch(ctx);
+		return JSON_SEM_ACTION_FAILED;
+	}
+
+	++ctx->nested;
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_object_field_start(void *state, char *name, bool isnull)
+{
+	struct oauth_parse *ctx = state;
+
+	/* We care only about the top-level fields. */
+	if (ctx->nested == 1)
+	{
+		const struct json_field *field = ctx->fields;
+
+		/*
+		 * We should never start parsing a new field while a previous one is
+		 * still active.
+		 */
+		if (ctx->active)
+		{
+			Assert(false);
+			oauth_parse_set_error(ctx,
+								  "internal error: started field '%s' before field '%s' was finished",
+								  name, ctx->active->name);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		while (field->name)
+		{
+			if (strcmp(name, field->name) == 0)
+			{
+				ctx->active = field;
+				break;
+			}
+
+			++field;
+		}
+
+		/*
+		 * We don't allow duplicate field names; error out if the target has
+		 * already been set.
+		 */
+		if (ctx->active)
+		{
+			field = ctx->active;
+
+			if ((field->type == JSON_TOKEN_ARRAY_START && *field->target.array)
+				|| (field->type != JSON_TOKEN_ARRAY_START && *field->target.scalar))
+			{
+				oauth_parse_set_error(ctx, "field \"%s\" is duplicated",
+									  field->name);
+				return JSON_SEM_ACTION_FAILED;
+			}
+		}
+	}
+
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_object_end(void *state)
+{
+	struct oauth_parse *ctx = state;
+
+	--ctx->nested;
+
+	/*
+	 * All fields should be fully processed by the end of the top-level
+	 * object.
+	 */
+	if (!ctx->nested && ctx->active)
+	{
+		Assert(false);
+		oauth_parse_set_error(ctx,
+							  "internal error: field '%s' still active at end of object",
+							  ctx->active->name);
+		return JSON_SEM_ACTION_FAILED;
+	}
+
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_array_start(void *state)
+{
+	struct oauth_parse *ctx = state;
+
+	if (!ctx->nested)
+	{
+		oauth_parse_set_error(ctx, "top-level element must be an object");
+		return JSON_SEM_ACTION_FAILED;
+	}
+
+	if (ctx->active)
+	{
+		if (ctx->active->type != JSON_TOKEN_ARRAY_START
+		/* The arrays we care about must not have arrays as values. */
+			|| ctx->nested > 1)
+		{
+			report_type_mismatch(ctx);
+			return JSON_SEM_ACTION_FAILED;
+		}
+	}
+
+	++ctx->nested;
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_array_end(void *state)
+{
+	struct oauth_parse *ctx = state;
+
+	if (ctx->active)
+	{
+		/*
+		 * Clear the target (which should be an array inside the top-level
+		 * object). For this to be safe, no target arrays can contain other
+		 * arrays; we check for that in the array_start callback.
+		 */
+		if (ctx->nested != 2 || ctx->active->type != JSON_TOKEN_ARRAY_START)
+		{
+			Assert(false);
+			oauth_parse_set_error(ctx,
+								  "internal error: found unexpected array end while parsing field '%s'",
+								  ctx->active->name);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		ctx->active = NULL;
+	}
+
+	--ctx->nested;
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_scalar(void *state, char *token, JsonTokenType type)
+{
+	struct oauth_parse *ctx = state;
+
+	if (!ctx->nested)
+	{
+		oauth_parse_set_error(ctx, "top-level element must be an object");
+		return JSON_SEM_ACTION_FAILED;
+	}
+
+	if (ctx->active)
+	{
+		const struct json_field *field = ctx->active;
+		JsonTokenType expected = field->type;
+
+		/* Make sure this matches what the active field expects. */
+		if (expected == JSON_TOKEN_ARRAY_START)
+		{
+			/* Are we actually inside an array? */
+			if (ctx->nested < 2)
+			{
+				report_type_mismatch(ctx);
+				return JSON_SEM_ACTION_FAILED;
+			}
+
+			/* Currently, arrays can only contain strings. */
+			expected = JSON_TOKEN_STRING;
+		}
+
+		if (type != expected)
+		{
+			report_type_mismatch(ctx);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		if (field->type != JSON_TOKEN_ARRAY_START)
+		{
+			/* Ensure that we're parsing the top-level keys... */
+			if (ctx->nested != 1)
+			{
+				Assert(false);
+				oauth_parse_set_error(ctx,
+									  "internal error: scalar target found at nesting level %d",
+									  ctx->nested);
+				return JSON_SEM_ACTION_FAILED;
+			}
+
+			/* ...and that a result has not already been set. */
+			if (*field->target.scalar)
+			{
+				Assert(false);
+				oauth_parse_set_error(ctx,
+									  "internal error: scalar field '%s' would be assigned twice",
+									  ctx->active->name);
+				return JSON_SEM_ACTION_FAILED;
+			}
+
+			*field->target.scalar = strdup(token);
+			if (!*field->target.scalar)
+				return JSON_OUT_OF_MEMORY;
+
+			ctx->active = NULL;
+
+			return JSON_SUCCESS;
+		}
+		else
+		{
+			struct curl_slist *temp;
+
+			/* The target array should be inside the top-level object. */
+			if (ctx->nested != 2)
+			{
+				Assert(false);
+				oauth_parse_set_error(ctx,
+									  "internal error: array member found at nesting level %d",
+									  ctx->nested);
+				return JSON_SEM_ACTION_FAILED;
+			}
+
+			/* Note that curl_slist_append() makes a copy of the token. */
+			temp = curl_slist_append(*field->target.array, token);
+			if (!temp)
+				return JSON_OUT_OF_MEMORY;
+
+			*field->target.array = temp;
+		}
+	}
+	else
+	{
+		/* otherwise we just ignore it */
+	}
+
+	return JSON_SUCCESS;
+}
+
+/*
+ * Checks the Content-Type header against the expected type. Parameters are
+ * allowed but ignored.
+ */
+static bool
+check_content_type(struct async_ctx *actx, const char *type)
+{
+	const size_t type_len = strlen(type);
+	char	   *content_type;
+
+	CHECK_GETINFO(actx, CURLINFO_CONTENT_TYPE, &content_type, return false);
+
+	if (!content_type)
+	{
+		actx_error(actx, "no content type was provided");
+		return false;
+	}
+
+	/*
+	 * We need to perform a length limited comparison and not compare the
+	 * whole string.
+	 */
+	if (pg_strncasecmp(content_type, type, type_len) != 0)
+		goto fail;
+
+	/* On an exact match, we're done. */
+	Assert(strlen(content_type) >= type_len);
+	if (content_type[type_len] == '\0')
+		return true;
+
+	/*
+	 * Only a semicolon (optionally preceded by HTTP optional whitespace) is
+	 * acceptable after the prefix we checked. This marks the start of media
+	 * type parameters, which we currently have no use for.
+	 */
+	for (size_t i = type_len; content_type[i]; ++i)
+	{
+		switch (content_type[i])
+		{
+			case ';':
+				return true;	/* success! */
+
+			case ' ':
+			case '\t':
+				/* HTTP optional whitespace allows only spaces and htabs. */
+				break;
+
+			default:
+				goto fail;
+		}
+	}
+
+fail:
+	actx_error(actx, "unexpected content type: \"%s\"", content_type);
+	return false;
+}
+
+/*
+ * A helper function for general JSON parsing. fields is the array of field
+ * definitions with their backing pointers. The response will be parsed from
+ * actx->curl and actx->work_data (as set up by start_request()), and any
+ * parsing errors will be placed into actx->errbuf.
+ */
+static bool
+parse_oauth_json(struct async_ctx *actx, const struct json_field *fields)
+{
+	PQExpBuffer resp = &actx->work_data;
+	JsonLexContext lex = {0};
+	JsonSemAction sem = {0};
+	JsonParseErrorType err;
+	struct oauth_parse ctx = {0};
+	bool		success = false;
+
+	if (!check_content_type(actx, "application/json"))
+		return false;
+
+	if (strlen(resp->data) != resp->len)
+	{
+		actx_error(actx, "response contains embedded NULLs");
+		return false;
+	}
+
+	/*
+	 * pg_parse_json doesn't validate the incoming UTF-8, so we have to check
+	 * that up front.
+	 */
+	if (pg_encoding_verifymbstr(PG_UTF8, resp->data, resp->len) != resp->len)
+	{
+		actx_error(actx, "response is not valid UTF-8");
+		return false;
+	}
+
+	makeJsonLexContextCstringLen(&lex, resp->data, resp->len, PG_UTF8, true);
+	setJsonLexContextOwnsTokens(&lex, true);	/* must not leak on error */
+
+	ctx.errbuf = &actx->errbuf;
+	ctx.fields = fields;
+	sem.semstate = &ctx;
+
+	sem.object_start = oauth_json_object_start;
+	sem.object_field_start = oauth_json_object_field_start;
+	sem.object_end = oauth_json_object_end;
+	sem.array_start = oauth_json_array_start;
+	sem.array_end = oauth_json_array_end;
+	sem.scalar = oauth_json_scalar;
+
+	err = pg_parse_json(&lex, &sem);
+
+	if (err != JSON_SUCCESS)
+	{
+		/*
+		 * For JSON_SEM_ACTION_FAILED, we've already written the error
+		 * message. Other errors come directly from pg_parse_json(), already
+		 * translated.
+		 */
+		if (err != JSON_SEM_ACTION_FAILED)
+			actx_error_str(actx, json_errdetail(err, &lex));
+
+		goto cleanup;
+	}
+
+	/* Check all required fields. */
+	while (fields->name)
+	{
+		if (fields->required
+			&& !*fields->target.scalar
+			&& !*fields->target.array)
+		{
+			actx_error(actx, "field \"%s\" is missing", fields->name);
+			goto cleanup;
+		}
+
+		fields++;
+	}
+
+	success = true;
+
+cleanup:
+	freeJsonLexContext(&lex);
+	return success;
+}
+
+/*
+ * JSON Parser Definitions
+ */
+
+/*
+ * Parses authorization server metadata. Fields are defined by OIDC Discovery
+ * 1.0 and RFC 8414.
+ */
+static bool
+parse_provider(struct async_ctx *actx, struct provider *provider)
+{
+	struct json_field fields[] = {
+		{"issuer", JSON_TOKEN_STRING, {&provider->issuer}, REQUIRED},
+		{"token_endpoint", JSON_TOKEN_STRING, {&provider->token_endpoint}, REQUIRED},
+
+		/*----
+		 * The following fields are technically REQUIRED, but we don't use
+		 * them anywhere yet:
+		 *
+		 * - jwks_uri
+		 * - response_types_supported
+		 * - subject_types_supported
+		 * - id_token_signing_alg_values_supported
+		 */
+
+		{"device_authorization_endpoint", JSON_TOKEN_STRING, {&provider->device_authorization_endpoint}, OPTIONAL},
+		{"grant_types_supported", JSON_TOKEN_ARRAY_START, {.array = &provider->grant_types_supported}, OPTIONAL},
+
+		{0},
+	};
+
+	return parse_oauth_json(actx, fields);
+}
+
+/*
+ * Parses a valid JSON number into a double. The input must have come from
+ * pg_parse_json(), so that we know the lexer has validated it; there's no
+ * in-band signal for invalid formats.
+ */
+static double
+parse_json_number(const char *s)
+{
+	double		parsed;
+	int			cnt;
+
+	/*
+	 * The JSON lexer has already validated the number, which is stricter than
+	 * the %f format, so we should be good to use sscanf().
+	 */
+	cnt = sscanf(s, "%lf", &parsed);
+
+	if (cnt != 1)
+	{
+		/*
+		 * Either the lexer screwed up or our assumption above isn't true, and
+		 * either way a developer needs to take a look.
+		 */
+		Assert(false);
+		return 0;
+	}
+
+	return parsed;
+}
+
+/*
+ * Parses the "interval" JSON number, corresponding to the number of seconds to
+ * wait between token endpoint requests.
+ *
+ * RFC 8628 is pretty silent on sanity checks for the interval. As a matter of
+ * practicality, round any fractional intervals up to the next second, and clamp
+ * the result at a minimum of one. (Zero-second intervals would result in an
+ * expensive network polling loop.) Tests may remove the lower bound with
+ * PGOAUTHDEBUG, for improved performance.
+ */
+static int
+parse_interval(struct async_ctx *actx, const char *interval_str)
+{
+	double		parsed;
+
+	parsed = parse_json_number(interval_str);
+	parsed = ceil(parsed);
+
+	if (parsed < 1)
+		return actx->debugging ? 0 : 1;
+
+	else if (INT_MAX <= parsed)
+		return INT_MAX;
+
+	return parsed;
+}
+
+/*
+ * Parses the "expires_in" JSON number, corresponding to the number of seconds
+ * remaining in the lifetime of the device code request.
+ *
+ * Similar to parse_interval, but we have even fewer requirements for reasonable
+ * values since we don't use the expiration time directly (it's passed to the
+ * PQAUTHDATA_PROMPT_OAUTH_DEVICE hook, in case the application wants to do
+ * something with it). We simply round and clamp to int range.
+ */
+static int
+parse_expires_in(struct async_ctx *actx, const char *expires_in_str)
+{
+	double		parsed;
+
+	parsed = parse_json_number(expires_in_str);
+	parsed = round(parsed);
+
+	if (INT_MAX <= parsed)
+		return INT_MAX;
+	else if (parsed <= INT_MIN)
+		return INT_MIN;
+
+	return parsed;
+}
+
+/*
+ * Parses the Device Authorization Response (RFC 8628, Sec. 3.2).
+ */
+static bool
+parse_device_authz(struct async_ctx *actx, struct device_authz *authz)
+{
+	struct json_field fields[] = {
+		{"device_code", JSON_TOKEN_STRING, {&authz->device_code}, REQUIRED},
+		{"user_code", JSON_TOKEN_STRING, {&authz->user_code}, REQUIRED},
+		{"verification_uri", JSON_TOKEN_STRING, {&authz->verification_uri}, REQUIRED},
+		{"expires_in", JSON_TOKEN_NUMBER, {&authz->expires_in_str}, REQUIRED},
+
+		/*
+		 * Some services (Google, Azure) spell verification_uri differently.
+		 * We accept either.
+		 */
+		{"verification_url", JSON_TOKEN_STRING, {&authz->verification_uri}, REQUIRED},
+
+		{"verification_uri_complete", JSON_TOKEN_STRING, {&authz->verification_uri_complete}, OPTIONAL},
+		{"interval", JSON_TOKEN_NUMBER, {&authz->interval_str}, OPTIONAL},
+
+		{0},
+	};
+
+	if (!parse_oauth_json(actx, fields))
+		return false;
+
+	/*
+	 * Parse our numeric fields. Lexing has already completed by this time, so
+	 * we at least know they're valid JSON numbers.
+	 */
+	if (authz->interval_str)
+		authz->interval = parse_interval(actx, authz->interval_str);
+	else
+	{
+		/*
+		 * RFC 8628 specifies 5 seconds as the default value if the server
+		 * doesn't provide an interval.
+		 */
+		authz->interval = 5;
+	}
+
+	Assert(authz->expires_in_str);	/* ensured by parse_oauth_json() */
+	authz->expires_in = parse_expires_in(actx, authz->expires_in_str);
+
+	return true;
+}
+
+/*
+ * Parses the device access token error response (RFC 8628, Sec. 3.5, which
+ * uses the error response defined in RFC 6749, Sec. 5.2).
+ */
+static bool
+parse_token_error(struct async_ctx *actx, struct token_error *err)
+{
+	bool		result;
+	struct json_field fields[] = {
+		{"error", JSON_TOKEN_STRING, {&err->error}, REQUIRED},
+
+		{"error_description", JSON_TOKEN_STRING, {&err->error_description}, OPTIONAL},
+
+		{0},
+	};
+
+	result = parse_oauth_json(actx, fields);
+
+	/*
+	 * Since token errors are parsed during other active error paths, only
+	 * override the errctx if parsing explicitly fails.
+	 */
+	if (!result)
+		actx->errctx = "failed to parse token error response";
+
+	return result;
+}
+
+/*
+ * Constructs a message from the token error response and puts it into
+ * actx->errbuf.
+ */
+static void
+record_token_error(struct async_ctx *actx, const struct token_error *err)
+{
+	if (err->error_description)
+		appendPQExpBuffer(&actx->errbuf, "%s ", err->error_description);
+	else
+	{
+		/*
+		 * Try to get some more helpful detail into the error string. A 401
+		 * status in particular implies that the oauth_client_secret is
+		 * missing or wrong.
+		 */
+		long		response_code;
+
+		CHECK_GETINFO(actx, CURLINFO_RESPONSE_CODE, &response_code, response_code = 0);
+
+		if (response_code == 401)
+		{
+			actx_error(actx, actx->used_basic_auth
+					   ? "provider rejected the oauth_client_secret"
+					   : "provider requires client authentication, and no oauth_client_secret is set");
+			actx_error_str(actx, " ");
+		}
+	}
+
+	appendPQExpBuffer(&actx->errbuf, "(%s)", err->error);
+}
+
+/*
+ * Parses the device access token response (RFC 8628, Sec. 3.5, which uses the
+ * success response defined in RFC 6749, Sec. 5.1).
+ */
+static bool
+parse_access_token(struct async_ctx *actx, struct token *tok)
+{
+	struct json_field fields[] = {
+		{"access_token", JSON_TOKEN_STRING, {&tok->access_token}, REQUIRED},
+		{"token_type", JSON_TOKEN_STRING, {&tok->token_type}, REQUIRED},
+
+		/*---
+		 * We currently have no use for the following OPTIONAL fields:
+		 *
+		 * - expires_in: This will be important for maintaining a token cache,
+		 *               but we do not yet implement one.
+		 *
+		 * - refresh_token: Ditto.
+		 *
+		 * - scope: This is only sent when the authorization server sees fit to
+		 *          change our scope request. It's not clear what we should do
+		 *          about this; either it's been done as a matter of policy, or
+		 *          the user has explicitly denied part of the authorization,
+		 *          and either way the server-side validator is in a better
+		 *          place to complain if the change isn't acceptable.
+		 */
+
+		{0},
+	};
+
+	return parse_oauth_json(actx, fields);
+}
+
+/*
+ * libcurl Multi Setup/Callbacks
+ */
+
+/*
+ * Sets up the actx->mux, which is the altsock that PQconnectPoll clients will
+ * select() on instead of the Postgres socket during OAuth negotiation.
+ *
+ * This is just an epoll set or kqueue abstracting multiple other descriptors.
+ * For epoll, the timerfd is always part of the set; it's just disabled when
+ * we're not using it. For kqueue, the "timerfd" is actually a second kqueue
+ * instance which is only added to the set when needed.
+ */
+static bool
+setup_multiplexer(struct async_ctx *actx)
+{
+#ifdef HAVE_SYS_EPOLL_H
+	struct epoll_event ev = {.events = EPOLLIN};
+
+	actx->mux = epoll_create1(EPOLL_CLOEXEC);
+	if (actx->mux < 0)
+	{
+		actx_error(actx, "failed to create epoll set: %m");
+		return false;
+	}
+
+	actx->timerfd = timerfd_create(CLOCK_MONOTONIC, TFD_CLOEXEC);
+	if (actx->timerfd < 0)
+	{
+		actx_error(actx, "failed to create timerfd: %m");
+		return false;
+	}
+
+	if (epoll_ctl(actx->mux, EPOLL_CTL_ADD, actx->timerfd, &ev) < 0)
+	{
+		actx_error(actx, "failed to add timerfd to epoll set: %m");
+		return false;
+	}
+
+	return true;
+#endif
+#ifdef HAVE_SYS_EVENT_H
+	actx->mux = kqueue();
+	if (actx->mux < 0)
+	{
+		/*- translator: the term "kqueue" (kernel queue) should not be translated */
+		actx_error(actx, "failed to create kqueue: %m");
+		return false;
+	}
+
+	/*
+	 * Originally, we set EVFILT_TIMER directly on the top-level multiplexer.
+	 * This makes it difficult to implement timer_expired(), though, so now we
+	 * set EVFILT_TIMER on a separate actx->timerfd, which is chained to
+	 * actx->mux while the timer is active.
+	 */
+	actx->timerfd = kqueue();
+	if (actx->timerfd < 0)
+	{
+		actx_error(actx, "failed to create timer kqueue: %m");
+		return false;
+	}
+
+	return true;
+#endif
+
+	actx_error(actx, "libpq does not support the Device Authorization flow on this platform");
+	return false;
+}
+
+/*
+ * Adds and removes sockets from the multiplexer set, as directed by the
+ * libcurl multi handle.
+ */
+static int
+register_socket(CURL *curl, curl_socket_t socket, int what, void *ctx,
+				void *socketp)
+{
+#ifdef HAVE_SYS_EPOLL_H
+	struct async_ctx *actx = ctx;
+	struct epoll_event ev = {0};
+	int			res;
+	int			op = EPOLL_CTL_ADD;
+
+	switch (what)
+	{
+		case CURL_POLL_IN:
+			ev.events = EPOLLIN;
+			break;
+
+		case CURL_POLL_OUT:
+			ev.events = EPOLLOUT;
+			break;
+
+		case CURL_POLL_INOUT:
+			ev.events = EPOLLIN | EPOLLOUT;
+			break;
+
+		case CURL_POLL_REMOVE:
+			op = EPOLL_CTL_DEL;
+			break;
+
+		default:
+			actx_error(actx, "unknown libcurl socket operation: %d", what);
+			return -1;
+	}
+
+	res = epoll_ctl(actx->mux, op, socket, &ev);
+	if (res < 0 && errno == EEXIST)
+	{
+		/* We already had this socket in the pollset. */
+		op = EPOLL_CTL_MOD;
+		res = epoll_ctl(actx->mux, op, socket, &ev);
+	}
+
+	if (res < 0)
+	{
+		switch (op)
+		{
+			case EPOLL_CTL_ADD:
+				actx_error(actx, "could not add to epoll set: %m");
+				break;
+
+			case EPOLL_CTL_DEL:
+				actx_error(actx, "could not delete from epoll set: %m");
+				break;
+
+			default:
+				actx_error(actx, "could not update epoll set: %m");
+		}
+
+		return -1;
+	}
+#endif
+#ifdef HAVE_SYS_EVENT_H
+	struct async_ctx *actx = ctx;
+	struct kevent ev[2] = {{0}};
+	struct kevent ev_out[2];
+	struct timespec timeout = {0};
+	int			nev = 0;
+	int			res;
+
+	switch (what)
+	{
+		case CURL_POLL_IN:
+			EV_SET(&ev[nev], socket, EVFILT_READ, EV_ADD | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			break;
+
+		case CURL_POLL_OUT:
+			EV_SET(&ev[nev], socket, EVFILT_WRITE, EV_ADD | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			break;
+
+		case CURL_POLL_INOUT:
+			EV_SET(&ev[nev], socket, EVFILT_READ, EV_ADD | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			EV_SET(&ev[nev], socket, EVFILT_WRITE, EV_ADD | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			break;
+
+		case CURL_POLL_REMOVE:
+
+			/*
+			 * We don't know which of these is currently registered, perhaps
+			 * both, so we try to remove both.  This means we need to tolerate
+			 * ENOENT below.
+			 */
+			EV_SET(&ev[nev], socket, EVFILT_READ, EV_DELETE | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			EV_SET(&ev[nev], socket, EVFILT_WRITE, EV_DELETE | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			break;
+
+		default:
+			actx_error(actx, "unknown libcurl socket operation: %d", what);
+			return -1;
+	}
+
+	res = kevent(actx->mux, ev, nev, ev_out, lengthof(ev_out), &timeout);
+	if (res < 0)
+	{
+		actx_error(actx, "could not modify kqueue: %m");
+		return -1;
+	}
+
+	/*
+	 * We can't use the simple errno version of kevent, because we need to
+	 * skip over ENOENT while still allowing a second change to be processed.
+	 * So we need a longer-form error checking loop.
+	 */
+	for (int i = 0; i < res; ++i)
+	{
+		/*
+		 * EV_RECEIPT should guarantee one EV_ERROR result for every change,
+		 * whether successful or not. Failed entries contain a non-zero errno
+		 * in the data field.
+		 */
+		Assert(ev_out[i].flags & EV_ERROR);
+
+		errno = ev_out[i].data;
+		if (errno && errno != ENOENT)
+		{
+			switch (what)
+			{
+				case CURL_POLL_REMOVE:
+					actx_error(actx, "could not delete from kqueue: %m");
+					break;
+				default:
+					actx_error(actx, "could not add to kqueue: %m");
+			}
+			return -1;
+		}
+	}
+#endif
+
+	return 0;
+}
+
+/*
+ * Enables or disables the timer in the multiplexer set. The timeout value is
+ * in milliseconds (negative values disable the timer).
+ *
+ * For epoll, rather than continually adding and removing the timer, we keep it
+ * in the set at all times and just disarm it when it's not needed. For kqueue,
+ * the timer is removed completely when disabled to prevent stale timeouts from
+ * remaining in the queue.
+ */
+static bool
+set_timer(struct async_ctx *actx, long timeout)
+{
+#if HAVE_SYS_EPOLL_H
+	struct itimerspec spec = {0};
+
+	if (timeout < 0)
+	{
+		/* the zero itimerspec will disarm the timer below */
+	}
+	else if (timeout == 0)
+	{
+		/*
+		 * A zero timeout means libcurl wants us to call back immediately.
+		 * That's not technically an option for timerfd, but we can make the
+		 * timeout ridiculously short.
+		 */
+		spec.it_value.tv_nsec = 1;
+	}
+	else
+	{
+		spec.it_value.tv_sec = timeout / 1000;
+		spec.it_value.tv_nsec = (timeout % 1000) * 1000000;
+	}
+
+	if (timerfd_settime(actx->timerfd, 0 /* no flags */ , &spec, NULL) < 0)
+	{
+		actx_error(actx, "setting timerfd to %ld: %m", timeout);
+		return false;
+	}
+
+	return true;
+#endif
+#ifdef HAVE_SYS_EVENT_H
+	struct kevent ev;
+
+	/* Enable/disable the timer itself. */
+	EV_SET(&ev, 1, EVFILT_TIMER, timeout < 0 ? EV_DELETE : (EV_ADD | EV_ONESHOT),
+		   0, timeout, 0);
+	if (kevent(actx->timerfd, &ev, 1, NULL, 0, NULL) < 0 && errno != ENOENT)
+	{
+		actx_error(actx, "setting kqueue timer to %ld: %m", timeout);
+		return false;
+	}
+
+	/*
+	 * Add/remove the timer to/from the mux. (In contrast with epoll, if we
+	 * allowed the timer to remain registered here after being disabled, the
+	 * mux queue would retain any previous stale timeout notifications and
+	 * remain readable.)
+	 */
+	EV_SET(&ev, actx->timerfd, EVFILT_READ, timeout < 0 ? EV_DELETE : EV_ADD,
+		   0, 0, 0);
+	if (kevent(actx->mux, &ev, 1, NULL, 0, NULL) < 0 && errno != ENOENT)
+	{
+		actx_error(actx, "could not update timer on kqueue: %m");
+		return false;
+	}
+
+	return true;
+#endif
+
+	actx_error(actx, "libpq does not support timers on this platform");
+	return false;
+}
+
+/*
+ * Returns 1 if the timeout in the multiplexer set has expired since the last
+ * call to set_timer(), 0 if the timer is still running, or -1 (with an
+ * actx_error() report) if the timer cannot be queried.
+ */
+static int
+timer_expired(struct async_ctx *actx)
+{
+#if HAVE_SYS_EPOLL_H
+	struct itimerspec spec = {0};
+
+	if (timerfd_gettime(actx->timerfd, &spec) < 0)
+	{
+		actx_error(actx, "getting timerfd value: %m");
+		return -1;
+	}
+
+	/*
+	 * This implementation assumes we're using single-shot timers. If you
+	 * change to using intervals, you'll need to reimplement this function
+	 * too, possibly with the read() or select() interfaces for timerfd.
+	 */
+	Assert(spec.it_interval.tv_sec == 0
+		   && spec.it_interval.tv_nsec == 0);
+
+	/* If the remaining time to expiration is zero, we're done. */
+	return (spec.it_value.tv_sec == 0
+			&& spec.it_value.tv_nsec == 0);
+#endif
+#ifdef HAVE_SYS_EVENT_H
+	int			res;
+
+	/* Is the timer queue ready? */
+	res = PQsocketPoll(actx->timerfd, 1 /* forRead */ , 0, 0);
+	if (res < 0)
+	{
+		actx_error(actx, "checking kqueue for timeout: %m");
+		return -1;
+	}
+
+	return (res > 0);
+#endif
+
+	actx_error(actx, "libpq does not support timers on this platform");
+	return -1;
+}
+
+/*
+ * Adds or removes timeouts from the multiplexer set, as directed by the
+ * libcurl multi handle.
+ */
+static int
+register_timer(CURLM *curlm, long timeout, void *ctx)
+{
+	struct async_ctx *actx = ctx;
+
+	/*
+	 * There might be an optimization opportunity here: if timeout == 0, we
+	 * could signal drive_request to immediately call
+	 * curl_multi_socket_action, rather than returning all the way up the
+	 * stack only to come right back. But it's not clear that the additional
+	 * code complexity is worth it.
+	 */
+	if (!set_timer(actx, timeout))
+		return -1;				/* actx_error already called */
+
+	return 0;
+}
+
+/*
+ * Prints Curl request debugging information to stderr.
+ *
+ * Note that this will expose a number of critical secrets, so users have to opt
+ * into this (see PGOAUTHDEBUG).
+ */
+static int
+debug_callback(CURL *handle, curl_infotype type, char *data, size_t size,
+			   void *clientp)
+{
+	const char *prefix;
+	bool		printed_prefix = false;
+	PQExpBufferData buf;
+
+	/* Prefixes are modeled off of the default libcurl debug output. */
+	switch (type)
+	{
+		case CURLINFO_TEXT:
+			prefix = "*";
+			break;
+
+		case CURLINFO_HEADER_IN:	/* fall through */
+		case CURLINFO_DATA_IN:
+			prefix = "<";
+			break;
+
+		case CURLINFO_HEADER_OUT:	/* fall through */
+		case CURLINFO_DATA_OUT:
+			prefix = ">";
+			break;
+
+		default:
+			return 0;
+	}
+
+	initPQExpBuffer(&buf);
+
+	/*
+	 * Split the output into lines for readability; sometimes multiple headers
+	 * are included in a single call. We also don't allow unprintable ASCII
+	 * through without a basic <XX> escape.
+	 */
+	for (int i = 0; i < size; i++)
+	{
+		char		c = data[i];
+
+		if (!printed_prefix)
+		{
+			appendPQExpBuffer(&buf, "[libcurl] %s ", prefix);
+			printed_prefix = true;
+		}
+
+		if (c >= 0x20 && c <= 0x7E)
+			appendPQExpBufferChar(&buf, c);
+		else if ((type == CURLINFO_HEADER_IN
+				  || type == CURLINFO_HEADER_OUT
+				  || type == CURLINFO_TEXT)
+				 && (c == '\r' || c == '\n'))
+		{
+			/*
+			 * Don't bother emitting <0D><0A> for headers and text; it's not
+			 * helpful noise.
+			 */
+		}
+		else
+			appendPQExpBuffer(&buf, "<%02X>", c);
+
+		if (c == '\n')
+		{
+			appendPQExpBufferChar(&buf, c);
+			printed_prefix = false;
+		}
+	}
+
+	if (printed_prefix)
+		appendPQExpBufferChar(&buf, '\n');	/* finish the line */
+
+	fprintf(stderr, "%s", buf.data);
+	termPQExpBuffer(&buf);
+	return 0;
+}
+
+/*
+ * Initializes the two libcurl handles in the async_ctx. The multi handle,
+ * actx->curlm, is what drives the asynchronous engine and tells us what to do
+ * next. The easy handle, actx->curl, encapsulates the state for a single
+ * request/response. It's added to the multi handle as needed, during
+ * start_request().
+ */
+static bool
+setup_curl_handles(struct async_ctx *actx)
+{
+	/*
+	 * Create our multi handle. This encapsulates the entire conversation with
+	 * libcurl for this connection.
+	 */
+	actx->curlm = curl_multi_init();
+	if (!actx->curlm)
+	{
+		/* We don't get a lot of feedback on the failure reason. */
+		actx_error(actx, "failed to create libcurl multi handle");
+		return false;
+	}
+
+	/*
+	 * The multi handle tells us what to wait on using two callbacks. These
+	 * will manipulate actx->mux as needed.
+	 */
+	CHECK_MSETOPT(actx, CURLMOPT_SOCKETFUNCTION, register_socket, return false);
+	CHECK_MSETOPT(actx, CURLMOPT_SOCKETDATA, actx, return false);
+	CHECK_MSETOPT(actx, CURLMOPT_TIMERFUNCTION, register_timer, return false);
+	CHECK_MSETOPT(actx, CURLMOPT_TIMERDATA, actx, return false);
+
+	/*
+	 * Set up an easy handle. All of our requests are made serially, so we
+	 * only ever need to keep track of one.
+	 */
+	actx->curl = curl_easy_init();
+	if (!actx->curl)
+	{
+		actx_error(actx, "failed to create libcurl handle");
+		return false;
+	}
+
+	/*
+	 * Multi-threaded applications must set CURLOPT_NOSIGNAL. This requires us
+	 * to handle the possibility of SIGPIPE ourselves using pq_block_sigpipe;
+	 * see pg_fe_run_oauth_flow().
+	 *
+	 * NB: If libcurl is not built against a friendly DNS resolver (c-ares or
+	 * threaded), setting this option prevents DNS lookups from timing out
+	 * correctly. We warn about this situation at configure time.
+	 *
+	 * TODO: Perhaps there's a clever way to warn the user about synchronous
+	 * DNS at runtime too? It's not immediately clear how to do that in a
+	 * helpful way: for many standard single-threaded use cases, the user
+	 * might not care at all, so spraying warnings to stderr would probably do
+	 * more harm than good.
+	 */
+	CHECK_SETOPT(actx, CURLOPT_NOSIGNAL, 1L, return false);
+
+	if (actx->debugging)
+	{
+		/*
+		 * Set a callback for retrieving error information from libcurl, the
+		 * function only takes effect when CURLOPT_VERBOSE has been set so
+		 * make sure the order is kept.
+		 */
+		CHECK_SETOPT(actx, CURLOPT_DEBUGFUNCTION, debug_callback, return false);
+		CHECK_SETOPT(actx, CURLOPT_VERBOSE, 1L, return false);
+	}
+
+	CHECK_SETOPT(actx, CURLOPT_ERRORBUFFER, actx->curl_err, return false);
+
+	/*
+	 * Only HTTPS is allowed. (Debug mode additionally allows HTTP; this is
+	 * intended for testing only.)
+	 *
+	 * There's a bit of unfortunate complexity around the choice of
+	 * CURLoption. CURLOPT_PROTOCOLS is deprecated in modern Curls, but its
+	 * replacement didn't show up until relatively recently.
+	 */
+	{
+#if CURL_AT_LEAST_VERSION(7, 85, 0)
+		const CURLoption popt = CURLOPT_PROTOCOLS_STR;
+		const char *protos = "https";
+		const char *const unsafe = "https,http";
+#else
+		const CURLoption popt = CURLOPT_PROTOCOLS;
+		long		protos = CURLPROTO_HTTPS;
+		const long	unsafe = CURLPROTO_HTTPS | CURLPROTO_HTTP;
+#endif
+
+		if (actx->debugging)
+			protos = unsafe;
+
+		CHECK_SETOPT(actx, popt, protos, return false);
+	}
+
+	/*
+	 * If we're in debug mode, allow the developer to change the trusted CA
+	 * list. For now, this is not something we expose outside of the UNSAFE
+	 * mode, because it's not clear that it's useful in production: both libpq
+	 * and the user's browser must trust the same authorization servers for
+	 * the flow to work at all, so any changes to the roots are likely to be
+	 * done system-wide.
+	 */
+	if (actx->debugging)
+	{
+		const char *env;
+
+		if ((env = getenv("PGOAUTHCAFILE")) != NULL)
+			CHECK_SETOPT(actx, CURLOPT_CAINFO, env, return false);
+	}
+
+	/*
+	 * Suppress the Accept header to make our request as minimal as possible.
+	 * (Ideally we would set it to "application/json" instead, but OpenID is
+	 * pretty strict when it comes to provider behavior, so we have to check
+	 * what comes back anyway.)
+	 */
+	actx->headers = curl_slist_append(actx->headers, "Accept:");
+	if (actx->headers == NULL)
+	{
+		actx_error(actx, "out of memory");
+		return false;
+	}
+	CHECK_SETOPT(actx, CURLOPT_HTTPHEADER, actx->headers, return false);
+
+	return true;
+}
+
+/*
+ * Generic HTTP Request Handlers
+ */
+
+/*
+ * Response callback from libcurl which appends the response body into
+ * actx->work_data (see start_request()). The maximum size of the data is
+ * defined by CURL_MAX_WRITE_SIZE which by default is 16kb (and can only be
+ * changed by recompiling libcurl).
+ */
+static size_t
+append_data(char *buf, size_t size, size_t nmemb, void *userdata)
+{
+	struct async_ctx *actx = userdata;
+	PQExpBuffer resp = &actx->work_data;
+	size_t		len = size * nmemb;
+
+	/* In case we receive data over the threshold, abort the transfer */
+	if ((resp->len + len) > MAX_OAUTH_RESPONSE_SIZE)
+	{
+		actx_error(actx, "response is too large");
+		return 0;
+	}
+
+	/* The data passed from libcurl is not null-terminated */
+	appendBinaryPQExpBuffer(resp, buf, len);
+
+	/*
+	 * Signal an error in order to abort the transfer in case we ran out of
+	 * memory in accepting the data.
+	 */
+	if (PQExpBufferBroken(resp))
+	{
+		actx_error(actx, "out of memory");
+		return 0;
+	}
+
+	return len;
+}
+
+/*
+ * Begins an HTTP request on the multi handle. The caller should have set up all
+ * request-specific options on actx->curl first. The server's response body will
+ * be accumulated in actx->work_data (which will be reset, so don't store
+ * anything important there across this call).
+ *
+ * Once a request is queued, it can be driven to completion via drive_request().
+ * If actx->running is zero upon return, the request has already finished and
+ * drive_request() can be called without returning control to the client.
+ */
+static bool
+start_request(struct async_ctx *actx)
+{
+	CURLMcode	err;
+
+	resetPQExpBuffer(&actx->work_data);
+	CHECK_SETOPT(actx, CURLOPT_WRITEFUNCTION, append_data, return false);
+	CHECK_SETOPT(actx, CURLOPT_WRITEDATA, actx, return false);
+
+	err = curl_multi_add_handle(actx->curlm, actx->curl);
+	if (err)
+	{
+		actx_error(actx, "failed to queue HTTP request: %s",
+				   curl_multi_strerror(err));
+		return false;
+	}
+
+	/*
+	 * actx->running tracks the number of running handles, so we can
+	 * immediately call back if no waiting is needed.
+	 *
+	 * Even though this is nominally an asynchronous process, there are some
+	 * operations that can synchronously fail by this point (e.g. connections
+	 * to closed local ports) or even synchronously succeed if the stars align
+	 * (all the libcurl connection caches hit and the server is fast).
+	 */
+	err = curl_multi_socket_action(actx->curlm, CURL_SOCKET_TIMEOUT, 0, &actx->running);
+	if (err)
+	{
+		actx_error(actx, "asynchronous HTTP request failed: %s",
+				   curl_multi_strerror(err));
+		return false;
+	}
+
+	return true;
+}
+
+/*
+ * CURL_IGNORE_DEPRECATION was added in 7.87.0. If it's not defined, we can make
+ * it a no-op.
+ */
+#ifndef CURL_IGNORE_DEPRECATION
+#define CURL_IGNORE_DEPRECATION(x) x
+#endif
+
+/*
+ * Drives the multi handle towards completion. The caller should have already
+ * set up an asynchronous request via start_request().
+ */
+static PostgresPollingStatusType
+drive_request(struct async_ctx *actx)
+{
+	CURLMcode	err;
+	CURLMsg    *msg;
+	int			msgs_left;
+	bool		done;
+
+	if (actx->running)
+	{
+		/*---
+		 * There's an async request in progress. Pump the multi handle.
+		 *
+		 * curl_multi_socket_all() is officially deprecated, because it's
+		 * inefficient and pointless if your event loop has already handed you
+		 * the exact sockets that are ready. But that's not our use case --
+		 * our client has no way to tell us which sockets are ready. (They
+		 * don't even know there are sockets to begin with.)
+		 *
+		 * We can grab the list of triggered events from the multiplexer
+		 * ourselves, but that's effectively what curl_multi_socket_all() is
+		 * going to do. And there are currently no plans for the Curl project
+		 * to remove or break this API, so ignore the deprecation. See
+		 *
+		 *    https://curl.se/mail/lib-2024-11/0028.html
+		 *
+		 */
+		CURL_IGNORE_DEPRECATION(
+			err = curl_multi_socket_all(actx->curlm, &actx->running);
+		)
+
+		if (err)
+		{
+			actx_error(actx, "asynchronous HTTP request failed: %s",
+					   curl_multi_strerror(err));
+			return PGRES_POLLING_FAILED;
+		}
+
+		if (actx->running)
+		{
+			/* We'll come back again. */
+			return PGRES_POLLING_READING;
+		}
+	}
+
+	done = false;
+	while ((msg = curl_multi_info_read(actx->curlm, &msgs_left)) != NULL)
+	{
+		if (msg->msg != CURLMSG_DONE)
+		{
+			/*
+			 * Future libcurl versions may define new message types; we don't
+			 * know how to handle them, so we'll ignore them.
+			 */
+			continue;
+		}
+
+		/* First check the status of the request itself. */
+		if (msg->data.result != CURLE_OK)
+		{
+			/*
+			 * If a more specific error hasn't already been reported, use
+			 * libcurl's description.
+			 */
+			if (actx->errbuf.len == 0)
+				actx_error_str(actx, curl_easy_strerror(msg->data.result));
+
+			return PGRES_POLLING_FAILED;
+		}
+
+		/* Now remove the finished handle; we'll add it back later if needed. */
+		err = curl_multi_remove_handle(actx->curlm, msg->easy_handle);
+		if (err)
+		{
+			actx_error(actx, "libcurl easy handle removal failed: %s",
+					   curl_multi_strerror(err));
+			return PGRES_POLLING_FAILED;
+		}
+
+		done = true;
+	}
+
+	/* Sanity check. */
+	if (!done)
+	{
+		actx_error(actx, "no result was retrieved for the finished handle");
+		return PGRES_POLLING_FAILED;
+	}
+
+	return PGRES_POLLING_OK;
+}
+
+/*
+ * URL-Encoding Helpers
+ */
+
+/*
+ * Encodes a string using the application/x-www-form-urlencoded format, and
+ * appends it to the given buffer.
+ */
+static void
+append_urlencoded(PQExpBuffer buf, const char *s)
+{
+	char	   *escaped;
+	char	   *haystack;
+	char	   *match;
+
+	escaped = curl_easy_escape(NULL, s, 0);
+	if (!escaped)
+	{
+		termPQExpBuffer(buf);	/* mark the buffer broken */
+		return;
+	}
+
+	/*
+	 * curl_easy_escape() almost does what we want, but we need the
+	 * query-specific flavor which uses '+' instead of '%20' for spaces. The
+	 * Curl command-line tool does this with a simple search-and-replace, so
+	 * follow its lead.
+	 */
+	haystack = escaped;
+
+	while ((match = strstr(haystack, "%20")) != NULL)
+	{
+		/* Append the unmatched portion, followed by the plus sign. */
+		appendBinaryPQExpBuffer(buf, haystack, match - haystack);
+		appendPQExpBufferChar(buf, '+');
+
+		/* Keep searching after the match. */
+		haystack = match + 3 /* strlen("%20") */ ;
+	}
+
+	/* Push the remainder of the string onto the buffer. */
+	appendPQExpBufferStr(buf, haystack);
+
+	curl_free(escaped);
+}
+
+/*
+ * Convenience wrapper for encoding a single string. Returns NULL on allocation
+ * failure.
+ */
+static char *
+urlencode(const char *s)
+{
+	PQExpBufferData buf;
+
+	initPQExpBuffer(&buf);
+	append_urlencoded(&buf, s);
+
+	return PQExpBufferDataBroken(buf) ? NULL : buf.data;
+}
+
+/*
+ * Appends a key/value pair to the end of an application/x-www-form-urlencoded
+ * list.
+ */
+static void
+build_urlencoded(PQExpBuffer buf, const char *key, const char *value)
+{
+	if (buf->len)
+		appendPQExpBufferChar(buf, '&');
+
+	append_urlencoded(buf, key);
+	appendPQExpBufferChar(buf, '=');
+	append_urlencoded(buf, value);
+}
+
+/*
+ * Specific HTTP Request Handlers
+ *
+ * This is finally the beginning of the actual application logic. Generally
+ * speaking, a single request consists of a start_* and a finish_* step, with
+ * drive_request() pumping the machine in between.
+ */
+
+/*
+ * Queue an OpenID Provider Configuration Request:
+ *
+ *     https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationRequest
+ *     https://www.rfc-editor.org/rfc/rfc8414#section-3.1
+ *
+ * This is done first to get the endpoint URIs we need to contact and to make
+ * sure the provider provides a device authorization flow. finish_discovery()
+ * will fill in actx->provider.
+ */
+static bool
+start_discovery(struct async_ctx *actx, const char *discovery_uri)
+{
+	CHECK_SETOPT(actx, CURLOPT_HTTPGET, 1L, return false);
+	CHECK_SETOPT(actx, CURLOPT_URL, discovery_uri, return false);
+
+	return start_request(actx);
+}
+
+static bool
+finish_discovery(struct async_ctx *actx)
+{
+	long		response_code;
+
+	/*----
+	 * Now check the response. OIDC Discovery 1.0 is pretty strict:
+	 *
+	 *     A successful response MUST use the 200 OK HTTP status code and
+	 *     return a JSON object using the application/json content type that
+	 *     contains a set of Claims as its members that are a subset of the
+	 *     Metadata values defined in Section 3.
+	 *
+	 * Compared to standard HTTP semantics, this makes life easy -- we don't
+	 * need to worry about redirections (which would call the Issuer host
+	 * validation into question), or non-authoritative responses, or any other
+	 * complications.
+	 */
+	CHECK_GETINFO(actx, CURLINFO_RESPONSE_CODE, &response_code, return false);
+
+	if (response_code != 200)
+	{
+		actx_error(actx, "unexpected response code %ld", response_code);
+		return false;
+	}
+
+	/*
+	 * Pull the fields we care about from the document.
+	 */
+	actx->errctx = "failed to parse OpenID discovery document";
+	if (!parse_provider(actx, &actx->provider))
+		return false;			/* error message already set */
+
+	/*
+	 * Fill in any defaults for OPTIONAL/RECOMMENDED fields we care about.
+	 */
+	if (!actx->provider.grant_types_supported)
+	{
+		/*
+		 * Per Section 3, the default is ["authorization_code", "implicit"].
+		 */
+		struct curl_slist *temp = actx->provider.grant_types_supported;
+
+		temp = curl_slist_append(temp, "authorization_code");
+		if (temp)
+		{
+			temp = curl_slist_append(temp, "implicit");
+		}
+
+		if (!temp)
+		{
+			actx_error(actx, "out of memory");
+			return false;
+		}
+
+		actx->provider.grant_types_supported = temp;
+	}
+
+	return true;
+}
+
+/*
+ * Ensure that the discovery document is provided by the expected issuer.
+ * Currently, issuers are statically configured in the connection string.
+ */
+static bool
+check_issuer(struct async_ctx *actx, PGconn *conn)
+{
+	const struct provider *provider = &actx->provider;
+
+	Assert(conn->oauth_issuer_id);	/* ensured by setup_oauth_parameters() */
+	Assert(provider->issuer);	/* ensured by parse_provider() */
+
+	/*---
+	 * We require strict equality for issuer identifiers -- no path or case
+	 * normalization, no substitution of default ports and schemes, etc. This
+	 * is done to match the rules in OIDC Discovery Sec. 4.3 for config
+	 * validation:
+	 *
+	 *    The issuer value returned MUST be identical to the Issuer URL that
+	 *    was used as the prefix to /.well-known/openid-configuration to
+	 *    retrieve the configuration information.
+	 *
+	 * as well as the rules set out in RFC 9207 for avoiding mix-up attacks:
+	 *
+	 *    Clients MUST then [...] compare the result to the issuer identifier
+	 *    of the authorization server where the authorization request was
+	 *    sent to. This comparison MUST use simple string comparison as defined
+	 *    in Section 6.2.1 of [RFC3986].
+	 */
+	if (strcmp(conn->oauth_issuer_id, provider->issuer) != 0)
+	{
+		actx_error(actx,
+				   "the issuer identifier (%s) does not match oauth_issuer (%s)",
+				   provider->issuer, conn->oauth_issuer_id);
+		return false;
+	}
+
+	return true;
+}
+
+#define HTTPS_SCHEME "https://"
+#define OAUTH_GRANT_TYPE_DEVICE_CODE "urn:ietf:params:oauth:grant-type:device_code"
+
+/*
+ * Ensure that the provider supports the Device Authorization flow (i.e. it
+ * provides an authorization endpoint, and both the token and authorization
+ * endpoint URLs seem reasonable).
+ */
+static bool
+check_for_device_flow(struct async_ctx *actx)
+{
+	const struct provider *provider = &actx->provider;
+
+	Assert(provider->issuer);	/* ensured by parse_provider() */
+	Assert(provider->token_endpoint);	/* ensured by parse_provider() */
+
+	if (!provider->device_authorization_endpoint)
+	{
+		actx_error(actx,
+				   "issuer \"%s\" does not provide a device authorization endpoint",
+				   provider->issuer);
+		return false;
+	}
+
+	/*
+	 * The original implementation checked that OAUTH_GRANT_TYPE_DEVICE_CODE
+	 * was present in the discovery document's grant_types_supported list. MS
+	 * Entra does not advertise this grant type, though, and since it doesn't
+	 * make sense to stand up a device_authorization_endpoint without also
+	 * accepting device codes at the token_endpoint, that's the only thing we
+	 * currently require.
+	 */
+
+	/*
+	 * Although libcurl will fail later if the URL contains an unsupported
+	 * scheme, that error message is going to be a bit opaque. This is a
+	 * decent time to bail out if we're not using HTTPS for the endpoints
+	 * we'll use for the flow.
+	 */
+	if (!actx->debugging)
+	{
+		if (pg_strncasecmp(provider->device_authorization_endpoint,
+						   HTTPS_SCHEME, strlen(HTTPS_SCHEME)) != 0)
+		{
+			actx_error(actx,
+					   "device authorization endpoint \"%s\" must use HTTPS",
+					   provider->device_authorization_endpoint);
+			return false;
+		}
+
+		if (pg_strncasecmp(provider->token_endpoint,
+						   HTTPS_SCHEME, strlen(HTTPS_SCHEME)) != 0)
+		{
+			actx_error(actx,
+					   "token endpoint \"%s\" must use HTTPS",
+					   provider->token_endpoint);
+			return false;
+		}
+	}
+
+	return true;
+}
+
+/*
+ * Adds the client ID (and secret, if provided) to the current request, using
+ * either HTTP headers or the request body.
+ */
+static bool
+add_client_identification(struct async_ctx *actx, PQExpBuffer reqbody, PGconn *conn)
+{
+	bool		success = false;
+	char	   *username = NULL;
+	char	   *password = NULL;
+
+	if (conn->oauth_client_secret)	/* Zero-length secrets are permitted! */
+	{
+		/*----
+		 * Use HTTP Basic auth to send the client_id and secret. Per RFC 6749,
+		 * Sec. 2.3.1,
+		 *
+		 *   Including the client credentials in the request-body using the
+		 *   two parameters is NOT RECOMMENDED and SHOULD be limited to
+		 *   clients unable to directly utilize the HTTP Basic authentication
+		 *   scheme (or other password-based HTTP authentication schemes).
+		 *
+		 * Additionally:
+		 *
+		 *   The client identifier is encoded using the
+		 *   "application/x-www-form-urlencoded" encoding algorithm per Appendix
+		 *   B, and the encoded value is used as the username; the client
+		 *   password is encoded using the same algorithm and used as the
+		 *   password.
+		 *
+		 * (Appendix B modifies application/x-www-form-urlencoded by requiring
+		 * an initial UTF-8 encoding step. Since the client ID and secret must
+		 * both be 7-bit ASCII -- RFC 6749 Appendix A -- we don't worry about
+		 * that in this function.)
+		 *
+		 * client_id is not added to the request body in this case. Not only
+		 * would it be redundant, but some providers in the wild (e.g. Okta)
+		 * refuse to accept it.
+		 */
+		username = urlencode(conn->oauth_client_id);
+		password = urlencode(conn->oauth_client_secret);
+
+		if (!username || !password)
+		{
+			actx_error(actx, "out of memory");
+			goto cleanup;
+		}
+
+		CHECK_SETOPT(actx, CURLOPT_HTTPAUTH, CURLAUTH_BASIC, goto cleanup);
+		CHECK_SETOPT(actx, CURLOPT_USERNAME, username, goto cleanup);
+		CHECK_SETOPT(actx, CURLOPT_PASSWORD, password, goto cleanup);
+
+		actx->used_basic_auth = true;
+	}
+	else
+	{
+		/*
+		 * If we're not otherwise authenticating, client_id is REQUIRED in the
+		 * request body.
+		 */
+		build_urlencoded(reqbody, "client_id", conn->oauth_client_id);
+
+		CHECK_SETOPT(actx, CURLOPT_HTTPAUTH, CURLAUTH_NONE, goto cleanup);
+		actx->used_basic_auth = false;
+	}
+
+	success = true;
+
+cleanup:
+	free(username);
+	free(password);
+
+	return success;
+}
+
+/*
+ * Queue a Device Authorization Request:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc8628#section-3.1
+ *
+ * This is the second step. We ask the provider to verify the end user out of
+ * band and authorize us to act on their behalf; it will give us the required
+ * nonces for us to later poll the request status, which we'll grab in
+ * finish_device_authz().
+ */
+static bool
+start_device_authz(struct async_ctx *actx, PGconn *conn)
+{
+	const char *device_authz_uri = actx->provider.device_authorization_endpoint;
+	PQExpBuffer work_buffer = &actx->work_data;
+
+	Assert(conn->oauth_client_id);	/* ensured by setup_oauth_parameters() */
+	Assert(device_authz_uri);	/* ensured by check_for_device_flow() */
+
+	/* Construct our request body. */
+	resetPQExpBuffer(work_buffer);
+	if (conn->oauth_scope && conn->oauth_scope[0])
+		build_urlencoded(work_buffer, "scope", conn->oauth_scope);
+
+	if (!add_client_identification(actx, work_buffer, conn))
+		return false;
+
+	if (PQExpBufferBroken(work_buffer))
+	{
+		actx_error(actx, "out of memory");
+		return false;
+	}
+
+	/* Make our request. */
+	CHECK_SETOPT(actx, CURLOPT_URL, device_authz_uri, return false);
+	CHECK_SETOPT(actx, CURLOPT_COPYPOSTFIELDS, work_buffer->data, return false);
+
+	return start_request(actx);
+}
+
+static bool
+finish_device_authz(struct async_ctx *actx)
+{
+	long		response_code;
+
+	CHECK_GETINFO(actx, CURLINFO_RESPONSE_CODE, &response_code, return false);
+
+	/*
+	 * Per RFC 8628, Section 3, a successful device authorization response
+	 * uses 200 OK.
+	 */
+	if (response_code == 200)
+	{
+		actx->errctx = "failed to parse device authorization";
+		if (!parse_device_authz(actx, &actx->authz))
+			return false;		/* error message already set */
+
+		return true;
+	}
+
+	/*
+	 * The device authorization endpoint uses the same error response as the
+	 * token endpoint, so the error handling roughly follows
+	 * finish_token_request(). The key difference is that an error here is
+	 * immediately fatal.
+	 */
+	if (response_code == 400 || response_code == 401)
+	{
+		struct token_error err = {0};
+
+		if (!parse_token_error(actx, &err))
+		{
+			free_token_error(&err);
+			return false;
+		}
+
+		record_token_error(actx, &err);
+
+		free_token_error(&err);
+		return false;
+	}
+
+	/* Any other response codes are considered invalid */
+	actx_error(actx, "unexpected response code %ld", response_code);
+	return false;
+}
+
+/*
+ * Queue an Access Token Request:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc6749#section-4.1.3
+ *
+ * This is the final step. We continually poll the token endpoint to see if the
+ * user has authorized us yet. finish_token_request() will pull either the token
+ * or a (ideally temporary) error status from the provider.
+ */
+static bool
+start_token_request(struct async_ctx *actx, PGconn *conn)
+{
+	const char *token_uri = actx->provider.token_endpoint;
+	const char *device_code = actx->authz.device_code;
+	PQExpBuffer work_buffer = &actx->work_data;
+
+	Assert(conn->oauth_client_id);	/* ensured by setup_oauth_parameters() */
+	Assert(token_uri);			/* ensured by parse_provider() */
+	Assert(device_code);		/* ensured by parse_device_authz() */
+
+	/* Construct our request body. */
+	resetPQExpBuffer(work_buffer);
+	build_urlencoded(work_buffer, "device_code", device_code);
+	build_urlencoded(work_buffer, "grant_type", OAUTH_GRANT_TYPE_DEVICE_CODE);
+
+	if (!add_client_identification(actx, work_buffer, conn))
+		return false;
+
+	if (PQExpBufferBroken(work_buffer))
+	{
+		actx_error(actx, "out of memory");
+		return false;
+	}
+
+	/* Make our request. */
+	CHECK_SETOPT(actx, CURLOPT_URL, token_uri, return false);
+	CHECK_SETOPT(actx, CURLOPT_COPYPOSTFIELDS, work_buffer->data, return false);
+
+	return start_request(actx);
+}
+
+static bool
+finish_token_request(struct async_ctx *actx, struct token *tok)
+{
+	long		response_code;
+
+	CHECK_GETINFO(actx, CURLINFO_RESPONSE_CODE, &response_code, return false);
+
+	/*
+	 * Per RFC 6749, Section 5, a successful response uses 200 OK.
+	 */
+	if (response_code == 200)
+	{
+		actx->errctx = "failed to parse access token response";
+		if (!parse_access_token(actx, tok))
+			return false;		/* error message already set */
+
+		return true;
+	}
+
+	/*
+	 * An error response uses either 400 Bad Request or 401 Unauthorized.
+	 * There are references online to implementations using 403 for error
+	 * return which would violate the specification. For now we stick to the
+	 * specification but we might have to revisit this.
+	 */
+	if (response_code == 400 || response_code == 401)
+	{
+		if (!parse_token_error(actx, &tok->err))
+			return false;
+
+		return true;
+	}
+
+	/* Any other response codes are considered invalid */
+	actx_error(actx, "unexpected response code %ld", response_code);
+	return false;
+}
+
+/*
+ * Finishes the token request and examines the response. If the flow has
+ * completed, a valid token will be returned via the parameter list. Otherwise,
+ * the token parameter remains unchanged, and the caller needs to wait for
+ * another interval (which will have been increased in response to a slow_down
+ * message from the server) before starting a new token request.
+ *
+ * False is returned only for permanent error conditions.
+ */
+static bool
+handle_token_response(struct async_ctx *actx, char **token)
+{
+	bool		success = false;
+	struct token tok = {0};
+	const struct token_error *err;
+
+	if (!finish_token_request(actx, &tok))
+		goto token_cleanup;
+
+	/* A successful token request gives either a token or an in-band error. */
+	Assert(tok.access_token || tok.err.error);
+
+	if (tok.access_token)
+	{
+		*token = tok.access_token;
+		tok.access_token = NULL;
+
+		success = true;
+		goto token_cleanup;
+	}
+
+	/*
+	 * authorization_pending and slow_down are the only acceptable errors;
+	 * anything else and we bail. These are defined in RFC 8628, Sec. 3.5.
+	 */
+	err = &tok.err;
+	if (strcmp(err->error, "authorization_pending") != 0 &&
+		strcmp(err->error, "slow_down") != 0)
+	{
+		record_token_error(actx, err);
+		goto token_cleanup;
+	}
+
+	/*
+	 * A slow_down error requires us to permanently increase our retry
+	 * interval by five seconds.
+	 */
+	if (strcmp(err->error, "slow_down") == 0)
+	{
+		int			prev_interval = actx->authz.interval;
+
+		actx->authz.interval += 5;
+		if (actx->authz.interval < prev_interval)
+		{
+			actx_error(actx, "slow_down interval overflow");
+			goto token_cleanup;
+		}
+	}
+
+	success = true;
+
+token_cleanup:
+	free_token(&tok);
+	return success;
+}
+
+/*
+ * Displays a device authorization prompt for action by the end user, either via
+ * the PQauthDataHook, or by a message on standard error if no hook is set.
+ */
+static bool
+prompt_user(struct async_ctx *actx, PGconn *conn)
+{
+	int			res;
+	PGpromptOAuthDevice prompt = {
+		.verification_uri = actx->authz.verification_uri,
+		.user_code = actx->authz.user_code,
+		.verification_uri_complete = actx->authz.verification_uri_complete,
+		.expires_in = actx->authz.expires_in,
+	};
+
+	res = PQauthDataHook(PQAUTHDATA_PROMPT_OAUTH_DEVICE, conn, &prompt);
+
+	if (!res)
+	{
+		/*
+		 * translator: The first %s is a URL for the user to visit in a
+		 * browser, and the second %s is a code to be copy-pasted there.
+		 */
+		fprintf(stderr, libpq_gettext("Visit %s and enter the code: %s\n"),
+				prompt.verification_uri, prompt.user_code);
+	}
+	else if (res < 0)
+	{
+		actx_error(actx, "device prompt failed");
+		return false;
+	}
+
+	return true;
+}
+
+/*
+ * Calls curl_global_init() in a thread-safe way.
+ *
+ * libcurl has stringent requirements for the thread context in which you call
+ * curl_global_init(), because it's going to try initializing a bunch of other
+ * libraries (OpenSSL, Winsock, etc). Recent versions of libcurl have improved
+ * the thread-safety situation, but there's a chicken-and-egg problem at
+ * runtime: you can't check the thread safety until you've initialized libcurl,
+ * which you can't do from within a thread unless you know it's thread-safe...
+ *
+ * Returns true if initialization was successful. Successful or not, this
+ * function will not try to reinitialize Curl on successive calls.
+ */
+static bool
+initialize_curl(PGconn *conn)
+{
+	/*
+	 * Don't let the compiler play tricks with this variable. In the
+	 * HAVE_THREADSAFE_CURL_GLOBAL_INIT case, we don't care if two threads
+	 * enter simultaneously, but we do care if this gets set transiently to
+	 * PG_BOOL_YES/NO in cases where that's not the final answer.
+	 */
+	static volatile PGTernaryBool init_successful = PG_BOOL_UNKNOWN;
+#if HAVE_THREADSAFE_CURL_GLOBAL_INIT
+	curl_version_info_data *info;
+#endif
+
+#if !HAVE_THREADSAFE_CURL_GLOBAL_INIT
+
+	/*
+	 * Lock around the whole function. If a libpq client performs its own work
+	 * with libcurl, it must either ensure that Curl is initialized safely
+	 * before calling us (in which case our call will be a no-op), or else it
+	 * must guard its own calls to curl_global_init() with a registered
+	 * threadlock handler. See PQregisterThreadLock().
+	 */
+	pglock_thread();
+#endif
+
+	/*
+	 * Skip initialization if we've already done it. (Curl tracks the number
+	 * of calls; there's no point in incrementing the counter every time we
+	 * connect.)
+	 */
+	if (init_successful == PG_BOOL_YES)
+		goto done;
+	else if (init_successful == PG_BOOL_NO)
+	{
+		libpq_append_conn_error(conn,
+								"curl_global_init previously failed during OAuth setup");
+		goto done;
+	}
+
+	/*
+	 * We know we've already initialized Winsock by this point (see
+	 * pqMakeEmptyPGconn()), so we should be able to safely skip that bit. But
+	 * we have to tell libcurl to initialize everything else, because other
+	 * pieces of our client executable may already be using libcurl for their
+	 * own purposes. If we initialize libcurl with only a subset of its
+	 * features, we could break those other clients nondeterministically, and
+	 * that would probably be a nightmare to debug.
+	 *
+	 * If some other part of the program has already called this, it's a
+	 * no-op.
+	 */
+	if (curl_global_init(CURL_GLOBAL_ALL & ~CURL_GLOBAL_WIN32) != CURLE_OK)
+	{
+		libpq_append_conn_error(conn,
+								"curl_global_init failed during OAuth setup");
+		init_successful = PG_BOOL_NO;
+		goto done;
+	}
+
+#if HAVE_THREADSAFE_CURL_GLOBAL_INIT
+
+	/*
+	 * If we determined at configure time that the Curl installation is
+	 * threadsafe, our job here is much easier. We simply initialize above
+	 * without any locking (concurrent or duplicated calls are fine in that
+	 * situation), then double-check to make sure the runtime setting agrees,
+	 * to try to catch silent downgrades.
+	 */
+	info = curl_version_info(CURLVERSION_NOW);
+	if (!(info->features & CURL_VERSION_THREADSAFE))
+	{
+		/*
+		 * 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 threadsafe\n"
+								"\tCurl initialization was reported threadsafe 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;
+	}
+#endif
+
+	init_successful = PG_BOOL_YES;
+
+done:
+#if !HAVE_THREADSAFE_CURL_GLOBAL_INIT
+	pgunlock_thread();
+#endif
+	return (init_successful == PG_BOOL_YES);
+}
+
+/*
+ * The core nonblocking libcurl implementation. This will be called several
+ * times to pump the async engine.
+ *
+ * The architecture is based on PQconnectPoll(). The first half drives the
+ * connection state forward as necessary, returning if we're not ready to
+ * proceed to the next step yet. The second half performs the actual transition
+ * between states.
+ *
+ * You can trace the overall OAuth flow through the second half. It's linear
+ * until we get to the end, where we flip back and forth between
+ * OAUTH_STEP_TOKEN_REQUEST and OAUTH_STEP_WAIT_INTERVAL to regularly ping the
+ * provider.
+ */
+static PostgresPollingStatusType
+pg_fe_run_oauth_flow_impl(PGconn *conn)
+{
+	fe_oauth_state *state = conn->sasl_state;
+	struct async_ctx *actx;
+
+	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. */
+		conn->altsock = actx->mux;
+
+		switch (actx->step)
+		{
+			case OAUTH_STEP_INIT:
+				break;
+
+			case OAUTH_STEP_DISCOVERY:
+			case OAUTH_STEP_DEVICE_AUTHORIZATION:
+			case OAUTH_STEP_TOKEN_REQUEST:
+				{
+					PostgresPollingStatusType status;
+
+					status = drive_request(actx);
+
+					if (status == PGRES_POLLING_FAILED)
+						goto error_return;
+					else if (status != PGRES_POLLING_OK)
+					{
+						/* not done yet */
+						return status;
+					}
+
+					break;
+				}
+
+			case OAUTH_STEP_WAIT_INTERVAL:
+
+				/*
+				 * The client application is supposed to wait until our timer
+				 * expires before calling PQconnectPoll() again, but that
+				 * might not happen. To avoid sending a token request early,
+				 * check the timer before continuing.
+				 */
+				if (!timer_expired(actx))
+				{
+					conn->altsock = actx->timerfd;
+					return PGRES_POLLING_READING;
+				}
+
+				/* Disable the expired timer. */
+				if (!set_timer(actx, -1))
+					goto error_return;
+
+				break;
+		}
+
+		/*
+		 * Each case here must ensure that actx->running is set while we're
+		 * waiting on some asynchronous work. Most cases rely on
+		 * start_request() to do that for them.
+		 */
+		switch (actx->step)
+		{
+			case OAUTH_STEP_INIT:
+				actx->errctx = "failed to fetch OpenID discovery document";
+				if (!start_discovery(actx, conn->oauth_discovery_uri))
+					goto error_return;
+
+				actx->step = OAUTH_STEP_DISCOVERY;
+				break;
+
+			case OAUTH_STEP_DISCOVERY:
+				if (!finish_discovery(actx))
+					goto error_return;
+
+				if (!check_issuer(actx, conn))
+					goto error_return;
+
+				actx->errctx = "cannot run OAuth device authorization";
+				if (!check_for_device_flow(actx))
+					goto error_return;
+
+				actx->errctx = "failed to obtain device authorization";
+				if (!start_device_authz(actx, conn))
+					goto error_return;
+
+				actx->step = OAUTH_STEP_DEVICE_AUTHORIZATION;
+				break;
+
+			case OAUTH_STEP_DEVICE_AUTHORIZATION:
+				if (!finish_device_authz(actx))
+					goto error_return;
+
+				actx->errctx = "failed to obtain access token";
+				if (!start_token_request(actx, conn))
+					goto error_return;
+
+				actx->step = OAUTH_STEP_TOKEN_REQUEST;
+				break;
+
+			case OAUTH_STEP_TOKEN_REQUEST:
+				if (!handle_token_response(actx, &conn->oauth_token))
+					goto error_return;
+
+				if (!actx->user_prompted)
+				{
+					/*
+					 * Now that we know the token endpoint isn't broken, give
+					 * the user the login instructions.
+					 */
+					if (!prompt_user(actx, conn))
+						goto error_return;
+
+					actx->user_prompted = true;
+				}
+
+				if (conn->oauth_token)
+					break;		/* done! */
+
+				/*
+				 * Wait for the required interval before issuing the next
+				 * request.
+				 */
+				if (!set_timer(actx, actx->authz.interval * 1000))
+					goto error_return;
+
+				/*
+				 * No Curl requests are running, so we can simplify by having
+				 * the client wait directly on the timerfd rather than the
+				 * multiplexer.
+				 */
+				conn->altsock = actx->timerfd;
+
+				actx->step = OAUTH_STEP_WAIT_INTERVAL;
+				actx->running = 1;
+				break;
+
+			case OAUTH_STEP_WAIT_INTERVAL:
+				actx->errctx = "failed to obtain access token";
+				if (!start_token_request(actx, conn))
+					goto error_return;
+
+				actx->step = OAUTH_STEP_TOKEN_REQUEST;
+				break;
+		}
+
+		/*
+		 * The vast majority of the time, if we don't have a token at this
+		 * point, actx->running will be set. But there are some corner cases
+		 * where we can immediately loop back around; see start_request().
+		 */
+	} while (!conn->oauth_token && !actx->running);
+
+	/* If we've stored a token, we're done. Otherwise come back later. */
+	return conn->oauth_token ? PGRES_POLLING_OK : PGRES_POLLING_READING;
+
+error_return:
+
+	/*
+	 * Assemble the three parts of our error: context, body, and detail. See
+	 * also the documentation for struct async_ctx.
+	 */
+	if (actx->errctx)
+	{
+		appendPQExpBufferStr(&conn->errorMessage,
+							 libpq_gettext(actx->errctx));
+		appendPQExpBufferStr(&conn->errorMessage, ": ");
+	}
+
+	if (PQExpBufferDataBroken(actx->errbuf))
+		appendPQExpBufferStr(&conn->errorMessage,
+							 libpq_gettext("out of memory"));
+	else
+		appendPQExpBufferStr(&conn->errorMessage, actx->errbuf.data);
+
+	if (actx->curl_err[0])
+	{
+		size_t		len;
+
+		appendPQExpBuffer(&conn->errorMessage, " (%s)", actx->curl_err);
+
+		/* Sometimes libcurl adds a newline to the error buffer. :( */
+		len = conn->errorMessage.len;
+		if (len >= 2 && conn->errorMessage.data[len - 2] == '\n')
+		{
+			conn->errorMessage.data[len - 2] = ')';
+			conn->errorMessage.data[len - 1] = '\0';
+			conn->errorMessage.len--;
+		}
+	}
+
+	appendPQExpBufferStr(&conn->errorMessage, "\n");
+
+	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.
+ */
+PostgresPollingStatusType
+pg_fe_run_oauth_flow(PGconn *conn)
+{
+	PostgresPollingStatusType result;
+#ifndef WIN32
+	sigset_t	osigset;
+	bool		sigpipe_pending;
+	bool		masked;
+
+	/*---
+	 * Ignore SIGPIPE on this thread during all Curl processing.
+	 *
+	 * Because we support multiple threads, we have to set up libcurl with
+	 * CURLOPT_NOSIGNAL, which disables its default global handling of
+	 * SIGPIPE. From the Curl docs:
+	 *
+	 *     libcurl makes an effort to never cause such SIGPIPE signals to
+	 *     trigger, but some operating systems have no way to avoid them and
+	 *     even on those that have there are some corner cases when they may
+	 *     still happen, contrary to our desire.
+	 *
+	 * Note that libcurl is also at the mercy of its DNS resolution and SSL
+	 * libraries; if any of them forget a MSG_NOSIGNAL then we're in trouble.
+	 * Modern platforms and libraries seem to get it right, so this is a
+	 * difficult corner case to exercise in practice, and unfortunately it's
+	 * not really clear whether it's necessary in all cases.
+	 */
+	masked = (pq_block_sigpipe(&osigset, &sigpipe_pending) == 0);
+#endif
+
+	result = pg_fe_run_oauth_flow_impl(conn);
+
+#ifndef WIN32
+	if (masked)
+	{
+		/*
+		 * Undo the SIGPIPE mask. Assume we may have gotten EPIPE (we have no
+		 * way of knowing at this level).
+		 */
+		pq_reset_sigpipe(&osigset, sigpipe_pending, true /* EPIPE, maybe */ );
+	}
+#endif
+
+	return result;
+}
diff --git a/src/interfaces/libpq/fe-auth-oauth.c b/src/interfaces/libpq/fe-auth-oauth.c
new file mode 100644
index 00000000000..8beae9604c7
--- /dev/null
+++ b/src/interfaces/libpq/fe-auth-oauth.c
@@ -0,0 +1,1153 @@
+/*-------------------------------------------------------------------------
+ *
+ * fe-auth-oauth.c
+ *	   The front-end (client) implementation of OAuth/OIDC authentication
+ *	   using the SASL OAUTHBEARER mechanism.
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/interfaces/libpq/fe-auth-oauth.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres_fe.h"
+
+#include "common/base64.h"
+#include "common/hmac.h"
+#include "common/jsonapi.h"
+#include "common/oauth-common.h"
+#include "fe-auth.h"
+#include "fe-auth-oauth.h"
+#include "mb/pg_wchar.h"
+
+/* The exported OAuth callback mechanism. */
+static void *oauth_init(PGconn *conn, const char *password,
+						const char *sasl_mechanism);
+static SASLStatus oauth_exchange(void *opaq, bool final,
+								 char *input, int inputlen,
+								 char **output, int *outputlen);
+static bool oauth_channel_bound(void *opaq);
+static void oauth_free(void *opaq);
+
+const pg_fe_sasl_mech pg_oauth_mech = {
+	oauth_init,
+	oauth_exchange,
+	oauth_channel_bound,
+	oauth_free,
+};
+
+/*
+ * Initializes mechanism state for OAUTHBEARER.
+ *
+ * For a full description of the API, see libpq/fe-auth-sasl.h.
+ */
+static void *
+oauth_init(PGconn *conn, const char *password,
+		   const char *sasl_mechanism)
+{
+	fe_oauth_state *state;
+
+	/*
+	 * We only support one SASL mechanism here; anything else is programmer
+	 * error.
+	 */
+	Assert(sasl_mechanism != NULL);
+	Assert(strcmp(sasl_mechanism, OAUTHBEARER_NAME) == 0);
+
+	state = calloc(1, sizeof(*state));
+	if (!state)
+		return NULL;
+
+	state->step = FE_OAUTH_INIT;
+	state->conn = conn;
+
+	return state;
+}
+
+/*
+ * Frees the state allocated by oauth_init().
+ *
+ * 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().
+ */
+static void
+oauth_free(void *opaq)
+{
+	fe_oauth_state *state = opaq;
+
+	/* Any async authentication state should have been cleaned up already. */
+	Assert(!state->async_ctx);
+
+	free(state);
+}
+
+#define kvsep "\x01"
+
+/*
+ * Constructs an OAUTHBEARER client initial response (RFC 7628, Sec. 3.1).
+ *
+ * If discover is true, the initial response will contain a request for the
+ * server's required OAuth parameters (Sec. 4.3). Otherwise, conn->token must
+ * be set; it will be sent as the connection's bearer token.
+ *
+ * Returns the response as a null-terminated string, or NULL on error.
+ */
+static char *
+client_initial_response(PGconn *conn, bool discover)
+{
+	static const char *const resp_format = "n,," kvsep "auth=%s%s" kvsep kvsep;
+
+	PQExpBufferData buf;
+	const char *authn_scheme;
+	char	   *response = NULL;
+	const char *token = conn->oauth_token;
+
+	if (discover)
+	{
+		/* Parameter discovery uses a completely empty auth value. */
+		authn_scheme = token = "";
+	}
+	else
+	{
+		/*
+		 * Use a Bearer authentication scheme (RFC 6750, Sec. 2.1). A trailing
+		 * space is used as a separator.
+		 */
+		authn_scheme = "Bearer ";
+
+		/* conn->token must have been set in this case. */
+		if (!token)
+		{
+			Assert(false);
+			libpq_append_conn_error(conn,
+									"internal error: no OAuth token was set for the connection");
+			return NULL;
+		}
+	}
+
+	initPQExpBuffer(&buf);
+	appendPQExpBuffer(&buf, resp_format, authn_scheme, token);
+
+	if (!PQExpBufferDataBroken(buf))
+		response = strdup(buf.data);
+	termPQExpBuffer(&buf);
+
+	if (!response)
+		libpq_append_conn_error(conn, "out of memory");
+
+	return response;
+}
+
+/*
+ * JSON Parser (for the OAUTHBEARER error result)
+ */
+
+/* Relevant JSON fields in the error result object. */
+#define ERROR_STATUS_FIELD "status"
+#define ERROR_SCOPE_FIELD "scope"
+#define ERROR_OPENID_CONFIGURATION_FIELD "openid-configuration"
+
+struct json_ctx
+{
+	char	   *errmsg;			/* any non-NULL value stops all processing */
+	PQExpBufferData errbuf;		/* backing memory for errmsg */
+	int			nested;			/* nesting level (zero is the top) */
+
+	const char *target_field_name;	/* points to a static allocation */
+	char	  **target_field;	/* see below */
+
+	/* target_field, if set, points to one of the following: */
+	char	   *status;
+	char	   *scope;
+	char	   *discovery_uri;
+};
+
+#define oauth_json_has_error(ctx) \
+	(PQExpBufferDataBroken((ctx)->errbuf) || (ctx)->errmsg)
+
+#define oauth_json_set_error(ctx, ...) \
+	do { \
+		appendPQExpBuffer(&(ctx)->errbuf, __VA_ARGS__); \
+		(ctx)->errmsg = (ctx)->errbuf.data; \
+	} while (0)
+
+static JsonParseErrorType
+oauth_json_object_start(void *state)
+{
+	struct json_ctx *ctx = state;
+
+	if (ctx->target_field)
+	{
+		Assert(ctx->nested == 1);
+
+		oauth_json_set_error(ctx,
+							 libpq_gettext("field \"%s\" must be a string"),
+							 ctx->target_field_name);
+	}
+
+	++ctx->nested;
+	return oauth_json_has_error(ctx) ? JSON_SEM_ACTION_FAILED : JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_object_end(void *state)
+{
+	struct json_ctx *ctx = state;
+
+	--ctx->nested;
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_object_field_start(void *state, char *name, bool isnull)
+{
+	struct json_ctx *ctx = state;
+
+	/* Only top-level keys are considered. */
+	if (ctx->nested == 1)
+	{
+		if (strcmp(name, ERROR_STATUS_FIELD) == 0)
+		{
+			ctx->target_field_name = ERROR_STATUS_FIELD;
+			ctx->target_field = &ctx->status;
+		}
+		else if (strcmp(name, ERROR_SCOPE_FIELD) == 0)
+		{
+			ctx->target_field_name = ERROR_SCOPE_FIELD;
+			ctx->target_field = &ctx->scope;
+		}
+		else if (strcmp(name, ERROR_OPENID_CONFIGURATION_FIELD) == 0)
+		{
+			ctx->target_field_name = ERROR_OPENID_CONFIGURATION_FIELD;
+			ctx->target_field = &ctx->discovery_uri;
+		}
+	}
+
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_array_start(void *state)
+{
+	struct json_ctx *ctx = state;
+
+	if (!ctx->nested)
+	{
+		ctx->errmsg = libpq_gettext("top-level element must be an object");
+	}
+	else if (ctx->target_field)
+	{
+		Assert(ctx->nested == 1);
+
+		oauth_json_set_error(ctx,
+							 libpq_gettext("field \"%s\" must be a string"),
+							 ctx->target_field_name);
+	}
+
+	return oauth_json_has_error(ctx) ? JSON_SEM_ACTION_FAILED : JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_scalar(void *state, char *token, JsonTokenType type)
+{
+	struct json_ctx *ctx = state;
+
+	if (!ctx->nested)
+	{
+		ctx->errmsg = libpq_gettext("top-level element must be an object");
+		return JSON_SEM_ACTION_FAILED;
+	}
+
+	if (ctx->target_field)
+	{
+		if (ctx->nested != 1)
+		{
+			/*
+			 * ctx->target_field should not have been set for nested keys.
+			 * Assert and don't continue any further for production builds.
+			 */
+			Assert(false);
+			oauth_json_set_error(ctx,	/* don't bother translating */
+								 "internal error: target scalar found at nesting level %d during OAUTHBEARER parsing",
+								 ctx->nested);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		/*
+		 * We don't allow duplicate field names; error out if the target has
+		 * already been set.
+		 */
+		if (*ctx->target_field)
+		{
+			oauth_json_set_error(ctx,
+								 libpq_gettext("field \"%s\" is duplicated"),
+								 ctx->target_field_name);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		/* The only fields we support are strings. */
+		if (type != JSON_TOKEN_STRING)
+		{
+			oauth_json_set_error(ctx,
+								 libpq_gettext("field \"%s\" must be a string"),
+								 ctx->target_field_name);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		*ctx->target_field = strdup(token);
+		if (!*ctx->target_field)
+			return JSON_OUT_OF_MEMORY;
+
+		ctx->target_field = NULL;
+		ctx->target_field_name = NULL;
+	}
+	else
+	{
+		/* otherwise we just ignore it */
+	}
+
+	return JSON_SUCCESS;
+}
+
+#define HTTPS_SCHEME "https://"
+#define HTTP_SCHEME "http://"
+
+/* We support both well-known suffixes defined by RFC 8414. */
+#define WK_PREFIX "/.well-known/"
+#define OPENID_WK_SUFFIX "openid-configuration"
+#define OAUTH_WK_SUFFIX "oauth-authorization-server"
+
+/*
+ * Derives an issuer identifier from one of our recognized .well-known URIs,
+ * using the rules in RFC 8414.
+ */
+static char *
+issuer_from_well_known_uri(PGconn *conn, const char *wkuri)
+{
+	const char *authority_start = NULL;
+	const char *wk_start;
+	const char *wk_end;
+	char	   *issuer;
+	ptrdiff_t	start_offset,
+				end_offset;
+	size_t		end_len;
+
+	/*
+	 * https:// is required for issuer identifiers (RFC 8414, Sec. 2; OIDC
+	 * Discovery 1.0, Sec. 3). This is a case-insensitive comparison at this
+	 * level (but issuer identifier comparison at the level above this is
+	 * case-sensitive, so in practice it's probably moot).
+	 */
+	if (pg_strncasecmp(wkuri, HTTPS_SCHEME, strlen(HTTPS_SCHEME)) == 0)
+		authority_start = wkuri + strlen(HTTPS_SCHEME);
+
+	if (!authority_start
+		&& oauth_unsafe_debugging_enabled()
+		&& pg_strncasecmp(wkuri, HTTP_SCHEME, strlen(HTTP_SCHEME)) == 0)
+	{
+		/* Allow http:// for testing only. */
+		authority_start = wkuri + strlen(HTTP_SCHEME);
+	}
+
+	if (!authority_start)
+	{
+		libpq_append_conn_error(conn,
+								"OAuth discovery URI \"%s\" must use HTTPS",
+								wkuri);
+		return NULL;
+	}
+
+	/*
+	 * Well-known URIs in general may support queries and fragments, but the
+	 * two types we support here do not. (They must be constructed from the
+	 * components of issuer identifiers, which themselves may not contain any
+	 * queries or fragments.)
+	 *
+	 * It's important to check this first, to avoid getting tricked later by a
+	 * prefix buried inside a query or fragment.
+	 */
+	if (strpbrk(authority_start, "?#") != NULL)
+	{
+		libpq_append_conn_error(conn,
+								"OAuth discovery URI \"%s\" must not contain query or fragment components",
+								wkuri);
+		return NULL;
+	}
+
+	/*
+	 * Find the start of the .well-known prefix. IETF rules (RFC 8615) state
+	 * this must be at the beginning of the path component, but OIDC defined
+	 * it at the end instead (OIDC Discovery 1.0, Sec. 4), so we have to
+	 * search for it anywhere.
+	 */
+	wk_start = strstr(authority_start, WK_PREFIX);
+	if (!wk_start)
+	{
+		libpq_append_conn_error(conn,
+								"OAuth discovery URI \"%s\" is not a .well-known URI",
+								wkuri);
+		return NULL;
+	}
+
+	/*
+	 * Now find the suffix type. We only support the two defined in OIDC
+	 * Discovery 1.0 and RFC 8414.
+	 */
+	wk_end = wk_start + strlen(WK_PREFIX);
+
+	if (strncmp(wk_end, OPENID_WK_SUFFIX, strlen(OPENID_WK_SUFFIX)) == 0)
+		wk_end += strlen(OPENID_WK_SUFFIX);
+	else if (strncmp(wk_end, OAUTH_WK_SUFFIX, strlen(OAUTH_WK_SUFFIX)) == 0)
+		wk_end += strlen(OAUTH_WK_SUFFIX);
+	else
+		wk_end = NULL;
+
+	/*
+	 * Even if there's a match, we still need to check to make sure the suffix
+	 * takes up the entire path segment, to weed out constructions like
+	 * "/.well-known/openid-configuration-bad".
+	 */
+	if (!wk_end || (*wk_end != '/' && *wk_end != '\0'))
+	{
+		libpq_append_conn_error(conn,
+								"OAuth discovery URI \"%s\" uses an unsupported .well-known suffix",
+								wkuri);
+		return NULL;
+	}
+
+	/*
+	 * Finally, make sure the .well-known components are provided either as a
+	 * prefix (IETF style) or as a postfix (OIDC style). In other words,
+	 * "https://localhost/a/.well-known/openid-configuration/b" is not allowed
+	 * to claim association with "https://localhost/a/b".
+	 */
+	if (*wk_end != '\0')
+	{
+		/*
+		 * It's not at the end, so it's required to be at the beginning at the
+		 * path. Find the starting slash.
+		 */
+		const char *path_start;
+
+		path_start = strchr(authority_start, '/');
+		Assert(path_start);		/* otherwise we wouldn't have found WK_PREFIX */
+
+		if (wk_start != path_start)
+		{
+			libpq_append_conn_error(conn,
+									"OAuth discovery URI \"%s\" uses an invalid format",
+									wkuri);
+			return NULL;
+		}
+	}
+
+	/* Checks passed! Now build the issuer. */
+	issuer = strdup(wkuri);
+	if (!issuer)
+	{
+		libpq_append_conn_error(conn, "out of memory");
+		return NULL;
+	}
+
+	/*
+	 * The .well-known components are from [wk_start, wk_end). Remove those to
+	 * form the issuer ID, by shifting the path suffix (which may be empty)
+	 * leftwards.
+	 */
+	start_offset = wk_start - wkuri;
+	end_offset = wk_end - wkuri;
+	end_len = strlen(wk_end) + 1;	/* move the NULL terminator too */
+
+	memmove(issuer + start_offset, issuer + end_offset, end_len);
+
+	return issuer;
+}
+
+/*
+ * Parses the server error result (RFC 7628, Sec. 3.2.2) contained in msg and
+ * stores any discovered openid_configuration and scope settings for the
+ * connection.
+ */
+static bool
+handle_oauth_sasl_error(PGconn *conn, const char *msg, int msglen)
+{
+	JsonLexContext lex = {0};
+	JsonSemAction sem = {0};
+	JsonParseErrorType err;
+	struct json_ctx ctx = {0};
+	char	   *errmsg = NULL;
+	bool		success = false;
+
+	Assert(conn->oauth_issuer_id);	/* ensured by setup_oauth_parameters() */
+
+	/* Sanity check. */
+	if (strlen(msg) != msglen)
+	{
+		libpq_append_conn_error(conn,
+								"server's error message contained an embedded NULL, and was discarded");
+		return false;
+	}
+
+	/*
+	 * pg_parse_json doesn't validate the incoming UTF-8, so we have to check
+	 * that up front.
+	 */
+	if (pg_encoding_verifymbstr(PG_UTF8, msg, msglen) != msglen)
+	{
+		libpq_append_conn_error(conn,
+								"server's error response is not valid UTF-8");
+		return false;
+	}
+
+	makeJsonLexContextCstringLen(&lex, msg, msglen, PG_UTF8, true);
+	setJsonLexContextOwnsTokens(&lex, true);	/* must not leak on error */
+
+	initPQExpBuffer(&ctx.errbuf);
+	sem.semstate = &ctx;
+
+	sem.object_start = oauth_json_object_start;
+	sem.object_end = oauth_json_object_end;
+	sem.object_field_start = oauth_json_object_field_start;
+	sem.array_start = oauth_json_array_start;
+	sem.scalar = oauth_json_scalar;
+
+	err = pg_parse_json(&lex, &sem);
+
+	if (err == JSON_SEM_ACTION_FAILED)
+	{
+		if (PQExpBufferDataBroken(ctx.errbuf))
+			errmsg = libpq_gettext("out of memory");
+		else if (ctx.errmsg)
+			errmsg = ctx.errmsg;
+		else
+		{
+			/*
+			 * Developer error: one of the action callbacks didn't call
+			 * oauth_json_set_error() before erroring out.
+			 */
+			Assert(oauth_json_has_error(&ctx));
+			errmsg = "<unexpected empty error>";
+		}
+	}
+	else if (err != JSON_SUCCESS)
+		errmsg = json_errdetail(err, &lex);
+
+	if (errmsg)
+		libpq_append_conn_error(conn,
+								"failed to parse server's error response: %s",
+								errmsg);
+
+	/* Don't need the error buffer or the JSON lexer anymore. */
+	termPQExpBuffer(&ctx.errbuf);
+	freeJsonLexContext(&lex);
+
+	if (errmsg)
+		goto cleanup;
+
+	if (ctx.discovery_uri)
+	{
+		char	   *discovery_issuer;
+
+		/*
+		 * The URI MUST correspond to our existing issuer, to avoid mix-ups.
+		 *
+		 * Issuer comparison is done byte-wise, rather than performing any URL
+		 * normalization; this follows the suggestions for issuer comparison
+		 * in RFC 9207 Sec. 2.4 (which requires simple string comparison) and
+		 * vastly simplifies things. Since this is the key protection against
+		 * a rogue server sending the client to an untrustworthy location,
+		 * simpler is better.
+		 */
+		discovery_issuer = issuer_from_well_known_uri(conn, ctx.discovery_uri);
+		if (!discovery_issuer)
+			goto cleanup;		/* error message already set */
+
+		if (strcmp(conn->oauth_issuer_id, discovery_issuer) != 0)
+		{
+			libpq_append_conn_error(conn,
+									"server's discovery document at %s (issuer \"%s\") is incompatible with oauth_issuer (%s)",
+									ctx.discovery_uri, discovery_issuer,
+									conn->oauth_issuer_id);
+
+			free(discovery_issuer);
+			goto cleanup;
+		}
+
+		free(discovery_issuer);
+
+		if (!conn->oauth_discovery_uri)
+		{
+			conn->oauth_discovery_uri = ctx.discovery_uri;
+			ctx.discovery_uri = NULL;
+		}
+		else
+		{
+			/* This must match the URI we'd previously determined. */
+			if (strcmp(conn->oauth_discovery_uri, ctx.discovery_uri) != 0)
+			{
+				libpq_append_conn_error(conn,
+										"server's discovery document has moved to %s (previous location was %s)",
+										ctx.discovery_uri,
+										conn->oauth_discovery_uri);
+				goto cleanup;
+			}
+		}
+	}
+
+	if (ctx.scope)
+	{
+		/* Servers may not override a previously set oauth_scope. */
+		if (!conn->oauth_scope)
+		{
+			conn->oauth_scope = ctx.scope;
+			ctx.scope = NULL;
+		}
+	}
+
+	if (!ctx.status)
+	{
+		libpq_append_conn_error(conn,
+								"server sent error response without a status");
+		goto cleanup;
+	}
+
+	if (strcmp(ctx.status, "invalid_token") != 0)
+	{
+		/*
+		 * invalid_token is the only error code we'll automatically retry for;
+		 * otherwise, just bail out now.
+		 */
+		libpq_append_conn_error(conn,
+								"server rejected OAuth bearer token: %s",
+								ctx.status);
+		goto cleanup;
+	}
+
+	success = true;
+
+cleanup:
+	free(ctx.status);
+	free(ctx.scope);
+	free(ctx.discovery_uri);
+
+	return success;
+}
+
+/*
+ * Callback implementation of conn->async_auth() for a user-defined OAuth flow.
+ * Delegates the retrieval of the token to the application's 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_*
+ * statuses for use by PQconnectPoll().
+ */
+static PostgresPollingStatusType
+run_user_oauth_flow(PGconn *conn)
+{
+	fe_oauth_state *state = conn->sasl_state;
+	PGoauthBearerRequest *request = state->async_ctx;
+	PostgresPollingStatusType status;
+
+	if (!request->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);
+	if (status == PGRES_POLLING_FAILED)
+	{
+		libpq_append_conn_error(conn, "user-defined OAuth flow failed");
+		return status;
+	}
+	else if (status == PGRES_POLLING_OK)
+	{
+		/*
+		 * 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.)
+		 */
+		if (!request->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);
+		if (!conn->oauth_token)
+		{
+			libpq_append_conn_error(conn, "out of memory");
+			return PGRES_POLLING_FAILED;
+		}
+
+		return PGRES_POLLING_OK;
+	}
+
+	/* The hook wants the client to poll the altsock. Make sure it set one. */
+	if (conn->altsock == PGINVALID_SOCKET)
+	{
+		libpq_append_conn_error(conn,
+								"user-defined OAuth flow did not provide a socket for polling");
+		return PGRES_POLLING_FAILED;
+	}
+
+	return status;
+}
+
+/*
+ * Cleanup callback for the async user flow. Delegates most of its job to the
+ * user-provided cleanup implementation, then disconnects the altsock.
+ */
+static void
+cleanup_user_oauth_flow(PGconn *conn)
+{
+	fe_oauth_state *state = conn->sasl_state;
+	PGoauthBearerRequest *request = state->async_ctx;
+
+	Assert(request);
+
+	if (request->cleanup)
+		request->cleanup(conn, request);
+	conn->altsock = PGINVALID_SOCKET;
+
+	free(request);
+	state->async_ctx = NULL;
+}
+
+/*
+ * Chooses an OAuth client flow for the connection, which will retrieve a Bearer
+ * 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
+ * asynchronous callbacks which will be managed by run_user_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.)
+ *
+ * If neither a custom handler nor the builtin flow is available, the connection
+ * fails here.
+ */
+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,
+	};
+
+	Assert(request.openid_configuration);
+
+	/* The client may have overridden the OAuth flow. */
+	res = PQauthDataHook(PQAUTHDATA_OAUTH_BEARER_TOKEN, conn, &request);
+	if (res > 0)
+	{
+		PGoauthBearerRequest *request_copy;
+
+		if (request.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);
+			if (!conn->oauth_token)
+			{
+				libpq_append_conn_error(conn, "out of memory");
+				goto fail;
+			}
+
+			/* short-circuit */
+			if (request.cleanup)
+				request.cleanup(conn, &request);
+			return true;
+		}
+
+		request_copy = malloc(sizeof(*request_copy));
+		if (!request_copy)
+		{
+			libpq_append_conn_error(conn, "out of memory");
+			goto fail;
+		}
+
+		memcpy(request_copy, &request, sizeof(request));
+
+		conn->async_auth = run_user_oauth_flow;
+		conn->cleanup_async_auth = cleanup_user_oauth_flow;
+		state->async_ctx = request_copy;
+	}
+	else if (res < 0)
+	{
+		libpq_append_conn_error(conn, "user-defined OAuth flow failed");
+		goto fail;
+	}
+	else
+	{
+#if USE_LIBCURL
+		/* Hand off to our built-in OAuth flow. */
+		conn->async_auth = pg_fe_run_oauth_flow;
+		conn->cleanup_async_auth = pg_fe_cleanup_oauth_flow;
+
+#else
+		libpq_append_conn_error(conn, "no custom OAuth flows are available, and libpq was not built with libcurl support");
+		goto fail;
+
+#endif
+	}
+
+	return true;
+
+fail:
+	if (request.cleanup)
+		request.cleanup(conn, &request);
+	return false;
+}
+
+/*
+ * Fill in our issuer identifier and discovery URI, if possible, using the
+ * connection parameters. If conn->oauth_discovery_uri can't be populated in
+ * this function, it will be requested from the server.
+ */
+static bool
+setup_oauth_parameters(PGconn *conn)
+{
+	/*---
+	 * To talk to a server, we require the user to provide issuer and client
+	 * identifiers.
+	 *
+	 * While it's possible for an OAuth client to support multiple issuers, it
+	 * requires additional effort to make sure the flows in use are safe -- to
+	 * quote RFC 9207,
+	 *
+	 *     OAuth clients that interact with only one authorization server are
+	 *     not vulnerable to mix-up attacks. However, when such clients decide
+	 *     to add support for a second authorization server in the future, they
+	 *     become vulnerable and need to apply countermeasures to mix-up
+	 *     attacks.
+	 *
+	 * For now, we allow only one.
+	 */
+	if (!conn->oauth_issuer || !conn->oauth_client_id)
+	{
+		libpq_append_conn_error(conn,
+								"server requires OAuth authentication, but oauth_issuer and oauth_client_id are not both set");
+		return false;
+	}
+
+	/*
+	 * oauth_issuer is interpreted differently if it's a well-known discovery
+	 * URI rather than just an issuer identifier.
+	 */
+	if (strstr(conn->oauth_issuer, WK_PREFIX) != NULL)
+	{
+		/*
+		 * Convert the URI back to an issuer identifier. (This also performs
+		 * validation of the URI format.)
+		 */
+		conn->oauth_issuer_id = issuer_from_well_known_uri(conn,
+														   conn->oauth_issuer);
+		if (!conn->oauth_issuer_id)
+			return false;		/* error message already set */
+
+		conn->oauth_discovery_uri = strdup(conn->oauth_issuer);
+		if (!conn->oauth_discovery_uri)
+		{
+			libpq_append_conn_error(conn, "out of memory");
+			return false;
+		}
+	}
+	else
+	{
+		/*
+		 * Treat oauth_issuer as an issuer identifier. We'll ask the server
+		 * for the discovery URI.
+		 */
+		conn->oauth_issuer_id = strdup(conn->oauth_issuer);
+		if (!conn->oauth_issuer_id)
+		{
+			libpq_append_conn_error(conn, "out of memory");
+			return false;
+		}
+	}
+
+	return true;
+}
+
+/*
+ * Implements the OAUTHBEARER SASL exchange (RFC 7628, Sec. 3.2).
+ *
+ * If the necessary OAuth parameters are set up on the connection, this will run
+ * the client flow asynchronously and present the resulting token to the server.
+ * Otherwise, an empty discovery response will be sent and any parameters sent
+ * back by the server will be stored for a second attempt.
+ *
+ * For a full description of the API, see libpq/sasl.h.
+ */
+static SASLStatus
+oauth_exchange(void *opaq, bool final,
+			   char *input, int inputlen,
+			   char **output, int *outputlen)
+{
+	fe_oauth_state *state = opaq;
+	PGconn	   *conn = state->conn;
+	bool		discover = false;
+
+	*output = NULL;
+	*outputlen = 0;
+
+	switch (state->step)
+	{
+		case FE_OAUTH_INIT:
+			/* We begin in the initial response phase. */
+			Assert(inputlen == -1);
+
+			if (!setup_oauth_parameters(conn))
+				return SASL_FAILED;
+
+			if (conn->oauth_token)
+			{
+				/*
+				 * A previous connection already fetched the token; we'll use
+				 * it below.
+				 */
+			}
+			else if (conn->oauth_discovery_uri)
+			{
+				/*
+				 * We don't have a token, but we have a discovery URI already
+				 * stored. Decide whether we're using a user-provided OAuth
+				 * flow or the one we have built in.
+				 */
+				if (!setup_token_request(conn, state))
+					return SASL_FAILED;
+
+				if (conn->oauth_token)
+				{
+					/*
+					 * A really smart user implementation may have already
+					 * given us the token (e.g. if there was an unexpired copy
+					 * already cached), and we can use it immediately.
+					 */
+				}
+				else
+				{
+					/*
+					 * Otherwise, we'll have to hand the connection over to
+					 * our OAuth implementation.
+					 *
+					 * This could take a while, since it generally involves a
+					 * user in the loop. To avoid consuming the server's
+					 * authentication timeout, we'll continue this handshake
+					 * to the end, so that the server can close its side of
+					 * the connection. We'll open a second connection later
+					 * once we've retrieved a token.
+					 */
+					discover = true;
+				}
+			}
+			else
+			{
+				/*
+				 * If we don't have a token, and we don't have a discovery URI
+				 * to be able to request a token, we ask the server for one
+				 * explicitly.
+				 */
+				discover = true;
+			}
+
+			/*
+			 * Generate an initial response. This either contains a token, if
+			 * we have one, or an empty discovery response which is doomed to
+			 * fail.
+			 */
+			*output = client_initial_response(conn, discover);
+			if (!*output)
+				return SASL_FAILED;
+
+			*outputlen = strlen(*output);
+			state->step = FE_OAUTH_BEARER_SENT;
+
+			if (conn->oauth_token)
+			{
+				/*
+				 * For the purposes of require_auth, our side of
+				 * authentication is done at this point; the server will
+				 * either accept the connection or send an error. Unlike
+				 * SCRAM, there is no additional server data to check upon
+				 * success.
+				 */
+				conn->client_finished_auth = true;
+			}
+
+			return SASL_CONTINUE;
+
+		case FE_OAUTH_BEARER_SENT:
+			if (final)
+			{
+				/*
+				 * OAUTHBEARER does not make use of additional data with a
+				 * successful SASL exchange, so we shouldn't get an
+				 * AuthenticationSASLFinal message.
+				 */
+				libpq_append_conn_error(conn,
+										"server sent unexpected additional OAuth data");
+				return SASL_FAILED;
+			}
+
+			/*
+			 * An error message was sent by the server. Respond with the
+			 * required dummy message (RFC 7628, sec. 3.2.3).
+			 */
+			*output = strdup(kvsep);
+			if (unlikely(!*output))
+			{
+				libpq_append_conn_error(conn, "out of memory");
+				return SASL_FAILED;
+			}
+			*outputlen = strlen(*output);	/* == 1 */
+
+			/* Grab the settings from discovery. */
+			if (!handle_oauth_sasl_error(conn, input, inputlen))
+				return SASL_FAILED;
+
+			if (conn->oauth_token)
+			{
+				/*
+				 * The server rejected our token. Continue onwards towards the
+				 * expected FATAL message, but mark our state to catch any
+				 * unexpected "success" from the server.
+				 */
+				state->step = FE_OAUTH_SERVER_ERROR;
+				return SASL_CONTINUE;
+			}
+
+			if (!conn->async_auth)
+			{
+				/*
+				 * No OAuth flow is set up yet. Did we get enough information
+				 * from the server to create one?
+				 */
+				if (!conn->oauth_discovery_uri)
+				{
+					libpq_append_conn_error(conn,
+											"server requires OAuth authentication, but no discovery metadata was provided");
+					return SASL_FAILED;
+				}
+
+				/* Yes. Set up the flow now. */
+				if (!setup_token_request(conn, state))
+					return SASL_FAILED;
+
+				if (conn->oauth_token)
+				{
+					/*
+					 * A token was available in a custom flow's cache. Skip
+					 * the asynchronous processing.
+					 */
+					goto reconnect;
+				}
+			}
+
+			/*
+			 * Time to retrieve a token. This involves a number of HTTP
+			 * connections and timed waits, so we escape the synchronous auth
+			 * processing and tell PQconnectPoll to transfer control to our
+			 * async implementation.
+			 */
+			Assert(conn->async_auth);	/* should have been set already */
+			state->step = FE_OAUTH_REQUESTING_TOKEN;
+			return SASL_ASYNC;
+
+		case FE_OAUTH_REQUESTING_TOKEN:
+
+			/*
+			 * We've returned successfully from token retrieval. Double-check
+			 * that we have what we need for the next connection.
+			 */
+			if (!conn->oauth_token)
+			{
+				Assert(false);	/* should have failed before this point! */
+				libpq_append_conn_error(conn,
+										"internal error: OAuth flow did not set a token");
+				return SASL_FAILED;
+			}
+
+			goto reconnect;
+
+		case FE_OAUTH_SERVER_ERROR:
+
+			/*
+			 * After an error, the server should send an error response to
+			 * fail the SASL handshake, which is handled in higher layers.
+			 *
+			 * If we get here, the server either sent *another* challenge
+			 * which isn't defined in the RFC, or completed the handshake
+			 * successfully after telling us it was going to fail. Neither is
+			 * acceptable.
+			 */
+			libpq_append_conn_error(conn,
+									"server sent additional OAuth data after error");
+			return SASL_FAILED;
+
+		default:
+			libpq_append_conn_error(conn, "invalid OAuth exchange state");
+			break;
+	}
+
+	Assert(false);				/* should never get here */
+	return SASL_FAILED;
+
+reconnect:
+
+	/*
+	 * Despite being a failure from the point of view of SASL, we have enough
+	 * information to restart with a new connection.
+	 */
+	libpq_append_conn_error(conn, "retrying connection with new bearer token");
+	conn->oauth_want_retry = true;
+	return SASL_FAILED;
+}
+
+static bool
+oauth_channel_bound(void *opaq)
+{
+	/* This mechanism does not support channel binding. */
+	return false;
+}
+
+/*
+ * Fully clears out any stored OAuth token. This is done proactively upon
+ * successful connection as well as during pqClosePGconn().
+ */
+void
+pqClearOAuthToken(PGconn *conn)
+{
+	if (!conn->oauth_token)
+		return;
+
+	explicit_bzero(conn->oauth_token, strlen(conn->oauth_token));
+	free(conn->oauth_token);
+	conn->oauth_token = NULL;
+}
+
+/*
+ * Returns true if the PGOAUTHDEBUG=UNSAFE flag is set in the environment.
+ */
+bool
+oauth_unsafe_debugging_enabled(void)
+{
+	const char *env = getenv("PGOAUTHDEBUG");
+
+	return (env && strcmp(env, "UNSAFE") == 0);
+}
diff --git a/src/interfaces/libpq/fe-auth-oauth.h b/src/interfaces/libpq/fe-auth-oauth.h
new file mode 100644
index 00000000000..32598721686
--- /dev/null
+++ b/src/interfaces/libpq/fe-auth-oauth.h
@@ -0,0 +1,45 @@
+/*-------------------------------------------------------------------------
+ *
+ * fe-auth-oauth.h
+ *
+ *	  Definitions for OAuth authentication implementations
+ *
+ * Portions Copyright (c) 2024, PostgreSQL Global Development Group
+ *
+ * src/interfaces/libpq/fe-auth-oauth.h
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#ifndef FE_AUTH_OAUTH_H
+#define FE_AUTH_OAUTH_H
+
+#include "libpq-fe.h"
+#include "libpq-int.h"
+
+
+enum fe_oauth_step
+{
+	FE_OAUTH_INIT,
+	FE_OAUTH_BEARER_SENT,
+	FE_OAUTH_REQUESTING_TOKEN,
+	FE_OAUTH_SERVER_ERROR,
+};
+
+typedef struct
+{
+	enum fe_oauth_step step;
+
+	PGconn	   *conn;
+	void	   *async_ctx;
+} fe_oauth_state;
+
+extern PostgresPollingStatusType pg_fe_run_oauth_flow(PGconn *conn);
+extern void pg_fe_cleanup_oauth_flow(PGconn *conn);
+extern void pqClearOAuthToken(PGconn *conn);
+extern bool oauth_unsafe_debugging_enabled(void);
+
+/* Mechanisms in fe-auth-oauth.c */
+extern const pg_fe_sasl_mech pg_oauth_mech;
+
+#endif							/* FE_AUTH_OAUTH_H */
diff --git a/src/interfaces/libpq/fe-auth.c b/src/interfaces/libpq/fe-auth.c
index 761ee8f88f7..ec7a9236044 100644
--- a/src/interfaces/libpq/fe-auth.c
+++ b/src/interfaces/libpq/fe-auth.c
@@ -40,9 +40,11 @@
 #endif
 
 #include "common/md5.h"
+#include "common/oauth-common.h"
 #include "common/scram-common.h"
 #include "fe-auth.h"
 #include "fe-auth-sasl.h"
+#include "fe-auth-oauth.h"
 #include "libpq-fe.h"
 
 #ifdef ENABLE_GSS
@@ -535,6 +537,13 @@ pg_SASL_init(PGconn *conn, int payloadlen, bool *async)
 			conn->sasl = &pg_scram_mech;
 			conn->password_needed = true;
 		}
+		else if (strcmp(mechanism_buf.data, OAUTHBEARER_NAME) == 0 &&
+				 !selected_mechanism)
+		{
+			selected_mechanism = OAUTHBEARER_NAME;
+			conn->sasl = &pg_oauth_mech;
+			conn->password_needed = false;
+		}
 	}
 
 	if (!selected_mechanism)
@@ -559,13 +568,6 @@ pg_SASL_init(PGconn *conn, int payloadlen, bool *async)
 
 		if (!allowed)
 		{
-			/*
-			 * TODO: this is dead code until a second SASL mechanism is added;
-			 * the connection can't have proceeded past check_expected_areq()
-			 * if no SASL methods are allowed.
-			 */
-			Assert(false);
-
 			libpq_append_conn_error(conn, "authentication method requirement \"%s\" failed: server requested %s authentication",
 									conn->require_auth, selected_mechanism);
 			goto error;
@@ -1580,3 +1582,23 @@ PQchangePassword(PGconn *conn, const char *user, const char *passwd)
 		}
 	}
 }
+
+PQauthDataHook_type PQauthDataHook = PQdefaultAuthDataHook;
+
+PQauthDataHook_type
+PQgetAuthDataHook(void)
+{
+	return PQauthDataHook;
+}
+
+void
+PQsetAuthDataHook(PQauthDataHook_type hook)
+{
+	PQauthDataHook = hook ? hook : PQdefaultAuthDataHook;
+}
+
+int
+PQdefaultAuthDataHook(PGauthData type, PGconn *conn, void *data)
+{
+	return 0;					/* handle nothing */
+}
diff --git a/src/interfaces/libpq/fe-auth.h b/src/interfaces/libpq/fe-auth.h
index 1d4991f8996..de98e0d20c4 100644
--- a/src/interfaces/libpq/fe-auth.h
+++ b/src/interfaces/libpq/fe-auth.h
@@ -18,6 +18,9 @@
 #include "libpq-int.h"
 
 
+extern PQauthDataHook_type PQauthDataHook;
+
+
 /* Prototypes for functions in fe-auth.c */
 extern int	pg_fe_sendauth(AuthRequest areq, int payloadlen, PGconn *conn,
 						   bool *async);
diff --git a/src/interfaces/libpq/fe-connect.c b/src/interfaces/libpq/fe-connect.c
index 85d1ca2864f..d5051f5e820 100644
--- a/src/interfaces/libpq/fe-connect.c
+++ b/src/interfaces/libpq/fe-connect.c
@@ -28,6 +28,7 @@
 #include "common/scram-common.h"
 #include "common/string.h"
 #include "fe-auth.h"
+#include "fe-auth-oauth.h"
 #include "libpq-fe.h"
 #include "libpq-int.h"
 #include "mb/pg_wchar.h"
@@ -373,6 +374,23 @@ static const internalPQconninfoOption PQconninfoOptions[] = {
 	{"scram_server_key", NULL, NULL, NULL, "SCRAM-Server-Key", "D", SCRAM_MAX_KEY_LEN * 2,
 	offsetof(struct pg_conn, scram_server_key)},
 
+	/* OAuth v2 */
+	{"oauth_issuer", NULL, NULL, NULL,
+		"OAuth-Issuer", "", 40,
+	offsetof(struct pg_conn, oauth_issuer)},
+
+	{"oauth_client_id", NULL, NULL, NULL,
+		"OAuth-Client-ID", "", 40,
+	offsetof(struct pg_conn, oauth_client_id)},
+
+	{"oauth_client_secret", NULL, NULL, NULL,
+		"OAuth-Client-Secret", "", 40,
+	offsetof(struct pg_conn, oauth_client_secret)},
+
+	{"oauth_scope", NULL, NULL, NULL,
+		"OAuth-Scope", "", 15,
+	offsetof(struct pg_conn, oauth_scope)},
+
 	/* Terminating entry --- MUST BE LAST */
 	{NULL, NULL, NULL, NULL,
 	NULL, NULL, 0}
@@ -399,6 +417,7 @@ static const PQEnvironmentOption EnvironmentOptions[] =
 static const pg_fe_sasl_mech *supported_sasl_mechs[] =
 {
 	&pg_scram_mech,
+	&pg_oauth_mech,
 };
 #define SASL_MECHANISM_COUNT lengthof(supported_sasl_mechs)
 
@@ -655,6 +674,7 @@ pqDropServerData(PGconn *conn)
 	conn->write_failed = false;
 	free(conn->write_err_msg);
 	conn->write_err_msg = NULL;
+	conn->oauth_want_retry = false;
 
 	/*
 	 * Cancel connections need to retain their be_pid and be_key across
@@ -1144,7 +1164,7 @@ static inline void
 fill_allowed_sasl_mechs(PGconn *conn)
 {
 	/*---
-	 * We only support one mechanism at the moment, so rather than deal with a
+	 * We only support two mechanisms at the moment, so rather than deal with a
 	 * linked list, conn->allowed_sasl_mechs is an array of static length. We
 	 * rely on the compile-time assertion here to keep us honest.
 	 *
@@ -1519,6 +1539,10 @@ pqConnectOptions2(PGconn *conn)
 			{
 				mech = &pg_scram_mech;
 			}
+			else if (strcmp(method, "oauth") == 0)
+			{
+				mech = &pg_oauth_mech;
+			}
 
 			/*
 			 * Final group: meta-options.
@@ -4111,7 +4135,19 @@ keep_going:						/* We will come back to here until there is
 				conn->inStart = conn->inCursor;
 
 				if (res != STATUS_OK)
+				{
+					/*
+					 * OAuth connections may perform two-step discovery, where
+					 * the first connection is a dummy.
+					 */
+					if (conn->sasl == &pg_oauth_mech && conn->oauth_want_retry)
+					{
+						need_new_connection = true;
+						goto keep_going;
+					}
+
 					goto error_return;
+				}
 
 				/*
 				 * Just make sure that any data sent by pg_fe_sendauth is
@@ -4390,6 +4426,9 @@ keep_going:						/* We will come back to here until there is
 					}
 				}
 
+				/* Don't hold onto any OAuth tokens longer than necessary. */
+				pqClearOAuthToken(conn);
+
 				/*
 				 * For non cancel requests we can release the address list
 				 * now. For cancel requests we never actually resolve
@@ -5002,6 +5041,12 @@ freePGconn(PGconn *conn)
 	free(conn->load_balance_hosts);
 	free(conn->scram_client_key);
 	free(conn->scram_server_key);
+	free(conn->oauth_issuer);
+	free(conn->oauth_issuer_id);
+	free(conn->oauth_discovery_uri);
+	free(conn->oauth_client_id);
+	free(conn->oauth_client_secret);
+	free(conn->oauth_scope);
 	termPQExpBuffer(&conn->errorMessage);
 	termPQExpBuffer(&conn->workBuffer);
 
@@ -5155,6 +5200,7 @@ pqClosePGconn(PGconn *conn)
 	conn->asyncStatus = PGASYNC_IDLE;
 	conn->xactStatus = PQTRANS_IDLE;
 	conn->pipelineStatus = PQ_PIPELINE_OFF;
+	pqClearOAuthToken(conn);
 	pqClearAsyncResult(conn);	/* deallocate result */
 	pqClearConnErrorState(conn);
 
diff --git a/src/interfaces/libpq/libpq-fe.h b/src/interfaces/libpq/libpq-fe.h
index a3491faf0c3..b7399dee58e 100644
--- a/src/interfaces/libpq/libpq-fe.h
+++ b/src/interfaces/libpq/libpq-fe.h
@@ -59,6 +59,8 @@ extern "C"
 /* Features added in PostgreSQL v18: */
 /* Indicates presence of PQfullProtocolVersion */
 #define LIBPQ_HAS_FULL_PROTOCOL_VERSION 1
+/* Indicates presence of the PQAUTHDATA_PROMPT_OAUTH_DEVICE authdata hook */
+#define LIBPQ_HAS_PROMPT_OAUTH_DEVICE 1
 
 /*
  * Option flags for PQcopyResult
@@ -186,6 +188,13 @@ typedef enum
 	PQ_PIPELINE_ABORTED
 } PGpipelineStatus;
 
+typedef enum
+{
+	PQAUTHDATA_PROMPT_OAUTH_DEVICE, /* user must visit a device-authorization
+									 * URL */
+	PQAUTHDATA_OAUTH_BEARER_TOKEN,	/* server requests an OAuth Bearer token */
+} PGauthData;
+
 /* PGconn encapsulates a connection to the backend.
  * The contents of this struct are not supposed to be known to applications.
  */
@@ -720,10 +729,86 @@ extern int	PQenv2encoding(void);
 
 /* === in fe-auth.c === */
 
+typedef struct _PGpromptOAuthDevice
+{
+	const char *verification_uri;	/* verification URI to visit */
+	const char *user_code;		/* user code to enter */
+	const char *verification_uri_complete;	/* optional combination of URI and
+											 * code, or NULL */
+	int			expires_in;		/* seconds until user code expires */
+} PGpromptOAuthDevice;
+
+/* for PGoauthBearerRequest.async() */
+#ifdef _WIN32
+#define SOCKTYPE uintptr_t		/* avoids depending on winsock2.h for SOCKET */
+#else
+#define SOCKTYPE int
+#endif
+
+typedef struct _PGoauthBearerRequest
+{
+	/* Hook inputs (constant across all calls) */
+	const char *const openid_configuration; /* OIDC discovery URI */
+	const char *const scope;	/* required scope(s), or NULL */
+
+	/* Hook outputs */
+
+	/*---------
+	 * Callback implementing a custom asynchronous OAuth flow.
+	 *
+	 * The callback may return
+	 * - PGRES_POLLING_READING/WRITING, to indicate that a socket descriptor
+	 *   has been stored in *altsock and libpq should wait until it is
+	 *   readable or writable before calling back;
+	 * - PGRES_POLLING_OK, to indicate that the flow is complete and
+	 *   request->token has been set; or
+	 * - PGRES_POLLING_FAILED, to indicate that token retrieval has failed.
+	 *
+	 * This callback is optional. If the token can be obtained without
+	 * 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.
+	 */
+	PostgresPollingStatusType (*async) (PGconn *conn,
+										struct _PGoauthBearerRequest *request,
+										SOCKTYPE * altsock);
+
+	/*
+	 * Callback to clean up custom allocations. A hook implementation may use
+	 * this to free request->token and any resources in request->user.
+	 *
+	 * This is technically optional, but highly recommended, because there is
+	 * no other indication as to when it is safe to free the token.
+	 */
+	void		(*cleanup) (PGconn *conn, struct _PGoauthBearerRequest *request);
+
+	/*
+	 * The hook should set this to the Bearer token contents for the
+	 * connection, once the flow is completed.  The token contents must remain
+	 * available to libpq until the hook's cleanup callback is called.
+	 */
+	char	   *token;
+
+	/*
+	 * Hook-defined data. libpq will not modify this pointer across calls to
+	 * the async callback, so it can be used to keep track of
+	 * application-specific state. Resources allocated here should be freed by
+	 * the cleanup callback.
+	 */
+	void	   *user;
+} PGoauthBearerRequest;
+
+#undef SOCKTYPE
+
 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);
 
+typedef int (*PQauthDataHook_type) (PGauthData type, PGconn *conn, void *data);
+extern void PQsetAuthDataHook(PQauthDataHook_type hook);
+extern PQauthDataHook_type PQgetAuthDataHook(void);
+extern int	PQdefaultAuthDataHook(PGauthData type, PGconn *conn, void *data);
+
 /* === in encnames.c === */
 
 extern int	pg_char_to_encoding(const char *name);
diff --git a/src/interfaces/libpq/libpq-int.h b/src/interfaces/libpq/libpq-int.h
index 2546f9f8a50..f36f7f19d58 100644
--- a/src/interfaces/libpq/libpq-int.h
+++ b/src/interfaces/libpq/libpq-int.h
@@ -437,6 +437,17 @@ struct pg_conn
 								 * cancel request, instead of being a normal
 								 * connection that's used for queries */
 
+	/* OAuth v2 */
+	char	   *oauth_issuer;	/* token issuer/URL */
+	char	   *oauth_issuer_id;	/* token issuer identifier */
+	char	   *oauth_discovery_uri;	/* URI of the issuer's discovery
+										 * document */
+	char	   *oauth_client_id;	/* client identifier */
+	char	   *oauth_client_secret;	/* client secret */
+	char	   *oauth_scope;	/* access token scope */
+	char	   *oauth_token;	/* access token */
+	bool		oauth_want_retry;	/* should we retry on failure? */
+
 	/* Optional file to write trace info to */
 	FILE	   *Pfdebug;
 	int			traceFlags;
@@ -505,7 +516,7 @@ struct pg_conn
 								 * the server? */
 	uint32		allowed_auth_methods;	/* bitmask of acceptable AuthRequest
 										 * codes */
-	const pg_fe_sasl_mech *allowed_sasl_mechs[1];	/* and acceptable SASL
+	const pg_fe_sasl_mech *allowed_sasl_mechs[2];	/* and acceptable SASL
 													 * mechanisms */
 	bool		client_finished_auth;	/* have we finished our half of the
 										 * authentication exchange? */
diff --git a/src/interfaces/libpq/meson.build b/src/interfaces/libpq/meson.build
index dd64d291b3e..19f4a52a97a 100644
--- a/src/interfaces/libpq/meson.build
+++ b/src/interfaces/libpq/meson.build
@@ -1,6 +1,7 @@
 # Copyright (c) 2022-2025, PostgreSQL Global Development Group
 
 libpq_sources = files(
+  'fe-auth-oauth.c',
   'fe-auth-scram.c',
   'fe-auth.c',
   'fe-cancel.c',
@@ -37,6 +38,10 @@ if gssapi.found()
   )
 endif
 
+if libcurl.found()
+  libpq_sources += files('fe-auth-oauth-curl.c')
+endif
+
 export_file = custom_target('libpq.exports',
   kwargs: gen_export_kwargs,
 )
diff --git a/src/makefiles/meson.build b/src/makefiles/meson.build
index d49b2079a44..60e13d50235 100644
--- a/src/makefiles/meson.build
+++ b/src/makefiles/meson.build
@@ -229,6 +229,7 @@ pgxs_deps = {
   'gssapi': gssapi,
   'icu': icu,
   'ldap': ldap,
+  'libcurl': libcurl,
   'libxml': libxml,
   'libxslt': libxslt,
   'llvm': llvm,
diff --git a/src/test/authentication/t/001_password.pl b/src/test/authentication/t/001_password.pl
index 1357f806b6f..4ce22ccbdf2 100644
--- a/src/test/authentication/t/001_password.pl
+++ b/src/test/authentication/t/001_password.pl
@@ -404,11 +404,11 @@ $node->connect_fails(
 $node->connect_fails(
 	"user=scram_role require_auth=!scram-sha-256",
 	"SCRAM authentication forbidden, fails with SCRAM auth",
-	expected_stderr => qr/server requested SASL authentication/);
+	expected_stderr => qr/server requested SCRAM-SHA-256 authentication/);
 $node->connect_fails(
 	"user=scram_role require_auth=!password,!md5,!scram-sha-256",
 	"multiple authentication types forbidden, fails with SCRAM auth",
-	expected_stderr => qr/server requested SASL authentication/);
+	expected_stderr => qr/server requested SCRAM-SHA-256 authentication/);
 
 # Test that bad passwords are rejected.
 $ENV{"PGPASSWORD"} = 'badpass';
@@ -465,13 +465,13 @@ $node->connect_fails(
 	"user=scram_role require_auth=!scram-sha-256",
 	"password authentication forbidden, fails with SCRAM auth",
 	expected_stderr =>
-	  qr/authentication method requirement "!scram-sha-256" failed: server requested SASL authentication/
+	  qr/authentication method requirement "!scram-sha-256" failed: server requested SCRAM-SHA-256 authentication/
 );
 $node->connect_fails(
 	"user=scram_role require_auth=!password,!md5,!scram-sha-256",
 	"multiple authentication types forbidden, fails with SCRAM auth",
 	expected_stderr =>
-	  qr/authentication method requirement "!password,!md5,!scram-sha-256" failed: server requested SASL authentication/
+	  qr/authentication method requirement "!password,!md5,!scram-sha-256" failed: server requested SCRAM-SHA-256 authentication/
 );
 
 # Test SYSTEM_USER <> NULL with parallel workers.
diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile
index 89e78b7d114..4e4be3fa511 100644
--- a/src/test/modules/Makefile
+++ b/src/test/modules/Makefile
@@ -11,6 +11,7 @@ SUBDIRS = \
 		  dummy_index_am \
 		  dummy_seclabel \
 		  libpq_pipeline \
+		  oauth_validator \
 		  plsample \
 		  spgist_name_ops \
 		  test_bloomfilter \
diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build
index a57077b682e..2b057451473 100644
--- a/src/test/modules/meson.build
+++ b/src/test/modules/meson.build
@@ -9,6 +9,7 @@ subdir('gin')
 subdir('injection_points')
 subdir('ldap_password_func')
 subdir('libpq_pipeline')
+subdir('oauth_validator')
 subdir('plsample')
 subdir('spgist_name_ops')
 subdir('ssl_passphrase_callback')
diff --git a/src/test/modules/oauth_validator/.gitignore b/src/test/modules/oauth_validator/.gitignore
new file mode 100644
index 00000000000..5dcb3ff9723
--- /dev/null
+++ b/src/test/modules/oauth_validator/.gitignore
@@ -0,0 +1,4 @@
+# Generated subdirectories
+/log/
+/results/
+/tmp_check/
diff --git a/src/test/modules/oauth_validator/Makefile b/src/test/modules/oauth_validator/Makefile
new file mode 100644
index 00000000000..f297ed5c968
--- /dev/null
+++ b/src/test/modules/oauth_validator/Makefile
@@ -0,0 +1,40 @@
+#-------------------------------------------------------------------------
+#
+# Makefile for src/test/modules/oauth_validator
+#
+# Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/test/modules/oauth_validator/Makefile
+#
+#-------------------------------------------------------------------------
+
+MODULES = validator fail_validator
+PGFILEDESC = "validator - test OAuth validator module"
+
+PROGRAM = oauth_hook_client
+PGAPPICON = win32
+OBJS = $(WIN32RES) oauth_hook_client.o
+
+PG_CPPFLAGS = -I$(libpq_srcdir)
+PG_LIBS_INTERNAL += $(libpq_pgport)
+
+NO_INSTALLCHECK = 1
+
+TAP_TESTS = 1
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = src/test/modules/oauth_validator
+top_builddir = ../../../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+
+export PYTHON
+export with_libcurl
+export with_python
+
+endif
diff --git a/src/test/modules/oauth_validator/README b/src/test/modules/oauth_validator/README
new file mode 100644
index 00000000000..138a8104622
--- /dev/null
+++ b/src/test/modules/oauth_validator/README
@@ -0,0 +1,13 @@
+Test programs and libraries for OAuth
+-------------------------------------
+
+This folder contains tests for the client- and server-side OAuth
+implementations. Most tests are run end-to-end to test both simultaneously. The
+tests in t/001_server use a mock OAuth authorization server, implemented jointly
+by t/OAuth/Server.pm and t/oauth_server.py, to run the libpq Device
+Authorization flow. The tests in t/002_client exercise custom OAuth flows and
+don't need an authorization server.
+
+Tests in this folder generally require 'oauth' to be present in PG_TEST_EXTRA,
+since localhost HTTP servers will be started. A Python installation is required
+to run the mock authorization server.
diff --git a/src/test/modules/oauth_validator/fail_validator.c b/src/test/modules/oauth_validator/fail_validator.c
new file mode 100644
index 00000000000..f77a3e115c6
--- /dev/null
+++ b/src/test/modules/oauth_validator/fail_validator.c
@@ -0,0 +1,42 @@
+/*-------------------------------------------------------------------------
+ *
+ * fail_validator.c
+ *	  Test module for serverside OAuth token validation callbacks, which always
+ *	  fails
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/test/modules/oauth_validator/fail_validator.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "fmgr.h"
+#include "libpq/oauth.h"
+
+PG_MODULE_MAGIC;
+
+static ValidatorModuleResult *fail_token(ValidatorModuleState *state,
+										 const char *token,
+										 const char *role);
+
+/* Callback implementations (we only need the main one) */
+static const OAuthValidatorCallbacks validator_callbacks = {
+	.validate_cb = fail_token,
+};
+
+const OAuthValidatorCallbacks *
+_PG_oauth_validator_module_init(void)
+{
+	return &validator_callbacks;
+}
+
+static ValidatorModuleResult *
+fail_token(ValidatorModuleState *state, const char *token, const char *role)
+{
+	elog(FATAL, "fail_validator: sentinel error");
+	pg_unreachable();
+}
diff --git a/src/test/modules/oauth_validator/meson.build b/src/test/modules/oauth_validator/meson.build
new file mode 100644
index 00000000000..4b78c90557c
--- /dev/null
+++ b/src/test/modules/oauth_validator/meson.build
@@ -0,0 +1,69 @@
+# Copyright (c) 2024, PostgreSQL Global Development Group
+
+validator_sources = files(
+  'validator.c',
+)
+
+if host_system == 'windows'
+  validator_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'validator',
+    '--FILEDESC', 'validator - test OAuth validator module',])
+endif
+
+validator = shared_module('validator',
+  validator_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += validator
+
+fail_validator_sources = files(
+  'fail_validator.c',
+)
+
+if host_system == 'windows'
+  fail_validator_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'fail_validator',
+    '--FILEDESC', 'fail_validator - failing OAuth validator module',])
+endif
+
+fail_validator = shared_module('fail_validator',
+  fail_validator_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += fail_validator
+
+oauth_hook_client_sources = files(
+  'oauth_hook_client.c',
+)
+
+if host_system == 'windows'
+  oauth_hook_client_sources += rc_bin_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'oauth_hook_client',
+    '--FILEDESC', 'oauth_hook_client - test program for libpq OAuth hooks',])
+endif
+
+oauth_hook_client = executable('oauth_hook_client',
+  oauth_hook_client_sources,
+  dependencies: [frontend_code, libpq],
+  kwargs: default_bin_args + {
+    'install': false,
+  },
+)
+testprep_targets += oauth_hook_client
+
+tests += {
+  'name': 'oauth_validator',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'tap': {
+    'tests': [
+      't/001_server.pl',
+      't/002_client.pl',
+    ],
+    'env': {
+      'PYTHON': python.path(),
+      'with_libcurl': libcurl.found() ? 'yes' : 'no',
+      'with_python': 'yes',
+    },
+  },
+}
diff --git a/src/test/modules/oauth_validator/oauth_hook_client.c b/src/test/modules/oauth_validator/oauth_hook_client.c
new file mode 100644
index 00000000000..fc003030ff8
--- /dev/null
+++ b/src/test/modules/oauth_validator/oauth_hook_client.c
@@ -0,0 +1,293 @@
+/*-------------------------------------------------------------------------
+ *
+ * oauth_hook_client.c
+ *		Test driver for t/002_client.pl, which verifies OAuth hook
+ *		functionality in libpq.
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ *
+ * IDENTIFICATION
+ *		src/test/modules/oauth_validator/oauth_hook_client.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres_fe.h"
+
+#include <sys/socket.h>
+
+#include "getopt_long.h"
+#include "libpq-fe.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(" --expected-scope SCOPE	fail if received scopes do not match SCOPE\n");
+	printf(" --expected-uri URI		fail if received configuration link does not match URI\n");
+	printf(" --misbehave=MODE		have the hook fail required postconditions\n"
+		   "						(MODEs: no-hook, fail-async, no-token, no-socket)\n");
+	printf(" --no-hook				don't install OAuth hooks\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(" --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_scope = NULL;
+static const char *misbehave_mode = NULL;
+static char *token = NULL;
+
+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},
+		{0}
+	};
+
+	const char *conninfo;
+	PGconn	   *conn;
+	int			c;
+
+	while ((c = getopt_long(argc, argv, "h", long_options, NULL)) != -1)
+	{
+		switch (c)
+		{
+			case 'h':
+				usage(argv);
+				return 0;
+
+			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;
+
+			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);
+
+	/* Connect. (All the actual work is in the hook.) */
+	if (stress_async)
+	{
+		/*
+		 * Perform an asynchronous connection, busy-looping on PQconnectPoll()
+		 * without actually waiting on socket events. This stresses code paths
+		 * that rely on asynchronous work to be done before continuing with
+		 * the next step in the flow.
+		 */
+		PostgresPollingStatusType res;
+
+		conn = PQconnectStart(conninfo);
+
+		do
+		{
+			res = PQconnectPoll(conn);
+		} while (res != PGRES_POLLING_FAILED && res != PGRES_POLLING_OK);
+	}
+	else
+	{
+		/* Perform a standard synchronous connection. */
+		conn = PQconnectdb(conninfo);
+	}
+
+	if (PQstatus(conn) != CONNECTION_OK)
+	{
+		fprintf(stderr, "connection to database failed: %s\n",
+				PQerrorMessage(conn));
+		PQfinish(conn);
+		return 1;
+	}
+
+	printf("connection succeeded\n");
+	PQfinish(conn);
+	return 0;
+}
+
+/*
+ * PQauthDataHook implementation. Replaces the default client flow by handling
+ * PQAUTHDATA_OAUTH_BEARER_TOKEN.
+ */
+static int
+handle_auth_data(PGauthData type, PGconn *conn, void *data)
+{
+	PGoauthBearerRequest *req = data;
+
+	if (no_hook || (type != PQAUTHDATA_OAUTH_BEARER_TOKEN))
+		return 0;
+
+	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;
+		}
+	}
+
+	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". */
+		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/001_server.pl b/src/test/modules/oauth_validator/t/001_server.pl
new file mode 100644
index 00000000000..f0b918390fd
--- /dev/null
+++ b/src/test/modules/oauth_validator/t/001_server.pl
@@ -0,0 +1,566 @@
+
+#
+# Tests the libpq builtin OAuth flow, as well as server-side HBA and validator
+# setup.
+#
+# Copyright (c) 2021-2024, PostgreSQL Global Development Group
+#
+
+use strict;
+use warnings FATAL => 'all';
+
+use JSON::PP     qw(encode_json);
+use MIME::Base64 qw(encode_base64);
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+use FindBin;
+use lib $FindBin::RealBin;
+
+use OAuth::Server;
+
+if (!$ENV{PG_TEST_EXTRA} || $ENV{PG_TEST_EXTRA} !~ /\boauth\b/)
+{
+	plan skip_all =>
+	  'Potentially unsafe test oauth not enabled in PG_TEST_EXTRA';
+}
+
+if ($ENV{with_libcurl} ne 'yes')
+{
+	plan skip_all => 'client-side OAuth not supported by this build';
+}
+
+if ($ENV{with_python} ne 'yes')
+{
+	plan skip_all => 'OAuth tests require --with-python to run';
+}
+
+my $node = PostgreSQL::Test::Cluster->new('primary');
+$node->init;
+$node->append_conf('postgresql.conf', "log_connections = on\n");
+$node->append_conf('postgresql.conf',
+	"oauth_validator_libraries = 'validator'\n");
+$node->start;
+
+$node->safe_psql('postgres', 'CREATE USER test;');
+$node->safe_psql('postgres', 'CREATE USER testalt;');
+$node->safe_psql('postgres', 'CREATE USER testparam;');
+
+# Save a background connection for later configuration changes.
+my $bgconn = $node->background_psql('postgres');
+
+my $webserver = OAuth::Server->new();
+$webserver->run();
+
+END
+{
+	my $exit_code = $?;
+
+	$webserver->stop() if defined $webserver;    # might have been SKIP'd
+
+	$? = $exit_code;
+}
+
+my $port = $webserver->port();
+my $issuer = "http://localhost:$port";
+
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test      oauth issuer="$issuer"       scope="openid postgres"
+local all testalt   oauth issuer="$issuer/.well-known/oauth-authorization-server/alternate" scope="openid postgres alt"
+local all testparam oauth issuer="$issuer/param" scope="openid postgres"
+});
+$node->reload;
+
+my $log_start = $node->wait_for_log(qr/reloading configuration files/);
+
+# Check pg_hba_file_rules() support.
+my $contents = $bgconn->query_safe(
+	qq(SELECT rule_number, auth_method, options
+		 FROM pg_hba_file_rules
+		 ORDER BY rule_number;));
+is( $contents,
+	qq{1|oauth|\{issuer=$issuer,"scope=openid postgres",validator=validator\}
+2|oauth|\{issuer=$issuer/.well-known/oauth-authorization-server/alternate,"scope=openid postgres alt",validator=validator\}
+3|oauth|\{issuer=$issuer/param,"scope=openid postgres",validator=validator\}},
+	"pg_hba_file_rules recreates OAuth HBA settings");
+
+# To test against HTTP rather than HTTPS, we need to enable PGOAUTHDEBUG. But
+# first, check to make sure the client refuses such connections by default.
+$node->connect_fails(
+	"user=test dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635",
+	"HTTPS is required without debug mode",
+	expected_stderr =>
+	  qr@OAuth discovery URI "\Q$issuer\E/.well-known/openid-configuration" must use HTTPS@
+);
+
+$ENV{PGOAUTHDEBUG} = "UNSAFE";
+
+my $user = "test";
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635",
+	"connect as test",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@,
+	log_like => [
+		qr/oauth_validator: token="9243959234", role="$user"/,
+		qr/oauth_validator: issuer="\Q$issuer\E", scope="openid postgres"/,
+		qr/connection authenticated: identity="test" method=oauth/,
+		qr/connection authorized/,
+	]);
+
+# The /alternate issuer uses slightly different parameters, along with an
+# OAuth-style discovery document.
+$user = "testalt";
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer/alternate oauth_client_id=f02c6361-0636",
+	"connect as testalt",
+	expected_stderr =>
+	  qr@Visit https://example\.org/ and enter the code: postgresuser@,
+	log_like => [
+		qr/oauth_validator: token="9243959234-alt", role="$user"/,
+		qr|oauth_validator: issuer="\Q$issuer/.well-known/oauth-authorization-server/alternate\E", scope="openid postgres alt"|,
+		qr/connection authenticated: identity="testalt" method=oauth/,
+		qr/connection authorized/,
+	]);
+
+# The issuer linked by the server must match the client's oauth_issuer setting.
+$node->connect_fails(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0636",
+	"oauth_issuer must match discovery",
+	expected_stderr =>
+	  qr@server's discovery document at \Q$issuer/.well-known/oauth-authorization-server/alternate\E \(issuer "\Q$issuer/alternate\E"\) is incompatible with oauth_issuer \(\Q$issuer\E\)@
+);
+
+# Test require_auth settings against OAUTHBEARER.
+my @cases = (
+	{ require_auth => "oauth" },
+	{ require_auth => "oauth,scram-sha-256" },
+	{ require_auth => "password,oauth" },
+	{ require_auth => "none,oauth" },
+	{ require_auth => "!scram-sha-256" },
+	{ require_auth => "!none" },
+
+	{
+		require_auth => "!oauth",
+		failure => qr/server requested OAUTHBEARER authentication/
+	},
+	{
+		require_auth => "scram-sha-256",
+		failure => qr/server requested OAUTHBEARER authentication/
+	},
+	{
+		require_auth => "!password,!oauth",
+		failure => qr/server requested OAUTHBEARER authentication/
+	},
+	{
+		require_auth => "none",
+		failure => qr/server requested SASL authentication/
+	},
+	{
+		require_auth => "!oauth,!scram-sha-256",
+		failure => qr/server requested SASL authentication/
+	});
+
+$user = "test";
+foreach my $c (@cases)
+{
+	my $connstr =
+	  "user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635 require_auth=$c->{'require_auth'}";
+
+	if (defined $c->{'failure'})
+	{
+		$node->connect_fails(
+			$connstr,
+			"require_auth=$c->{'require_auth'} fails",
+			expected_stderr => $c->{'failure'});
+	}
+	else
+	{
+		$node->connect_ok(
+			$connstr,
+			"require_auth=$c->{'require_auth'} succeeds",
+			expected_stderr =>
+			  qr@Visit https://example\.com/ and enter the code: postgresuser@
+		);
+	}
+}
+
+# Make sure the client_id and secret are correctly encoded. $vschars contains
+# every allowed character for a client_id/_secret (the "VSCHAR" class).
+# $vschars_esc is additionally backslash-escaped for inclusion in a
+# single-quoted connection string.
+my $vschars =
+  " !\"#\$%&'()*+,-./0123456789:;<=>?\@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~";
+my $vschars_esc =
+  " !\"#\$%&\\'()*+,-./0123456789:;<=>?\@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\\\]^_`abcdefghijklmnopqrstuvwxyz{|}~";
+
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id='$vschars_esc'",
+	"escapable characters: client_id",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id='$vschars_esc' oauth_client_secret='$vschars_esc'",
+	"escapable characters: client_id and secret",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+#
+# Further tests rely on support for specific behaviors in oauth_server.py. To
+# trigger these behaviors, we ask for the special issuer .../param (which is set
+# up in HBA for the testparam user) and encode magic instructions into the
+# oauth_client_id.
+#
+
+my $common_connstr =
+  "user=testparam dbname=postgres oauth_issuer=$issuer/param ";
+my $base_connstr = $common_connstr;
+
+sub connstr
+{
+	my (%params) = @_;
+
+	my $json = encode_json(\%params);
+	my $encoded = encode_base64($json, "");
+
+	return "$base_connstr oauth_client_id=$encoded";
+}
+
+# Make sure the param system works end-to-end first.
+$node->connect_ok(
+	connstr(),
+	"connect to /param",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$node->connect_ok(
+	connstr(stage => 'token', retries => 1),
+	"token retry",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(stage => 'token', retries => 2),
+	"token retry (twice)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(stage => 'all', retries => 1, interval => 2),
+	"token retry (two second interval)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(stage => 'all', retries => 1, interval => JSON::PP::null),
+	"token retry (default interval)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$node->connect_ok(
+	connstr(stage => 'all', content_type => 'application/json;charset=utf-8'),
+	"content type with charset",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(
+		stage => 'all',
+		content_type => "application/json \t;\t charset=utf-8"),
+	"content type with charset (whitespace)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(stage => 'device', uri_spelling => "verification_url"),
+	"alternative spelling of verification_uri",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$node->connect_fails(
+	connstr(stage => 'device', huge_response => JSON::PP::true),
+	"bad device authz response: overlarge JSON",
+	expected_stderr =>
+	  qr/failed to obtain device authorization: response is too large/);
+$node->connect_fails(
+	connstr(stage => 'token', huge_response => JSON::PP::true),
+	"bad token response: overlarge JSON",
+	expected_stderr =>
+	  qr/failed to obtain access token: response is too large/);
+
+$node->connect_fails(
+	connstr(stage => 'device', content_type => 'text/plain'),
+	"bad device authz response: wrong content type",
+	expected_stderr =>
+	  qr/failed to parse device authorization: unexpected content type/);
+$node->connect_fails(
+	connstr(stage => 'token', content_type => 'text/plain'),
+	"bad token response: wrong content type",
+	expected_stderr =>
+	  qr/failed to parse access token response: unexpected content type/);
+$node->connect_fails(
+	connstr(stage => 'token', content_type => 'application/jsonx'),
+	"bad token response: wrong content type (correct prefix)",
+	expected_stderr =>
+	  qr/failed to parse access token response: unexpected content type/);
+
+$node->connect_fails(
+	connstr(
+		stage => 'all',
+		interval => ~0,
+		retries => 1,
+		retry_code => "slow_down"),
+	"bad token response: server overflows the device authz interval",
+	expected_stderr =>
+	  qr/failed to obtain access token: slow_down interval overflow/);
+
+$node->connect_fails(
+	connstr(stage => 'token', error_code => "invalid_grant"),
+	"bad token response: invalid_grant, no description",
+	expected_stderr => qr/failed to obtain access token: \(invalid_grant\)/);
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_grant",
+		error_desc => "grant expired"),
+	"bad token response: expired grant",
+	expected_stderr =>
+	  qr/failed to obtain access token: grant expired \(invalid_grant\)/);
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_client",
+		error_status => 401),
+	"bad token response: client authentication failure, default description",
+	expected_stderr =>
+	  qr/failed to obtain access token: provider requires client authentication, and no oauth_client_secret is set \(invalid_client\)/
+);
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_client",
+		error_status => 401,
+		error_desc => "authn failure"),
+	"bad token response: client authentication failure, provided description",
+	expected_stderr =>
+	  qr/failed to obtain access token: authn failure \(invalid_client\)/);
+
+$node->connect_fails(
+	connstr(stage => 'token', token => ""),
+	"server rejects access: empty token",
+	expected_stderr => qr/bearer authentication failed/);
+$node->connect_fails(
+	connstr(stage => 'token', token => "****"),
+	"server rejects access: invalid token contents",
+	expected_stderr => qr/bearer authentication failed/);
+
+# Test behavior of the oauth_client_secret.
+$base_connstr = "$common_connstr oauth_client_secret=''";
+
+$node->connect_ok(
+	connstr(stage => 'all', expected_secret => ''),
+	"empty oauth_client_secret",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$base_connstr = "$common_connstr oauth_client_secret='$vschars_esc'";
+
+$node->connect_ok(
+	connstr(stage => 'all', expected_secret => $vschars),
+	"nonempty oauth_client_secret",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_client",
+		error_status => 401),
+	"bad token response: client authentication failure, default description with oauth_client_secret",
+	expected_stderr =>
+	  qr/failed to obtain access token: provider rejected the oauth_client_secret \(invalid_client\)/
+);
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_client",
+		error_status => 401,
+		error_desc => "mutual TLS required for client"),
+	"bad token response: client authentication failure, provided description with oauth_client_secret",
+	expected_stderr =>
+	  qr/failed to obtain access token: mutual TLS required for client \(invalid_client\)/
+);
+
+# Stress test: make sure our builtin flow operates correctly even if the client
+# application isn't respecting PGRES_POLLING_READING/WRITING signals returned
+# from PQconnectPoll().
+$base_connstr =
+  "$common_connstr port=" . $node->port . " host=" . $node->host;
+my @cmd = (
+	"oauth_hook_client", "--no-hook", "--stress-async",
+	connstr(stage => 'all', retries => 1, interval => 1));
+
+note "running '" . join("' '", @cmd) . "'";
+my ($stdout, $stderr) = run_command(\@cmd);
+
+like($stdout, qr/connection succeeded/, "stress-async: stdout matches");
+unlike($stderr, qr/connection to database failed/, "stress-async: stderr matches");
+
+#
+# This section of tests reconfigures the validator module itself, rather than
+# the OAuth server.
+#
+
+# Searching the logs is easier if OAuth parameter discovery isn't cluttering
+# things up; hardcode the discovery URI. (Scope is hardcoded to empty to cover
+# that case as well.)
+$common_connstr =
+  "dbname=postgres oauth_issuer=$issuer/.well-known/openid-configuration oauth_scope='' oauth_client_id=f02c6361-0635";
+
+# Misbehaving validators must fail shut.
+$bgconn->query_safe("ALTER SYSTEM SET oauth_validator.authn_id TO ''");
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+$node->connect_fails(
+	"$common_connstr user=test",
+	"validator must set authn_id",
+	expected_stderr => qr/OAuth bearer authentication failed/,
+	log_like => [
+		qr/connection authenticated: identity=""/,
+		qr/DETAIL:\s+Validator provided no identity/,
+		qr/FATAL:\s+OAuth bearer authentication failed/,
+	]);
+
+# Even if a validator authenticates the user, if the token isn't considered
+# valid, the connection fails.
+$bgconn->query_safe(
+	"ALTER SYSTEM SET oauth_validator.authn_id TO 'test\@example.org'");
+$bgconn->query_safe(
+	"ALTER SYSTEM SET oauth_validator.authorize_tokens TO false");
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+$node->connect_fails(
+	"$common_connstr user=test",
+	"validator must authorize token explicitly",
+	expected_stderr => qr/OAuth bearer authentication failed/,
+	log_like => [
+		qr/connection authenticated: identity="test\@example\.org"/,
+		qr/DETAIL:\s+Validator failed to authorize the provided token/,
+		qr/FATAL:\s+OAuth bearer authentication failed/,
+	]);
+
+#
+# Test user mapping.
+#
+
+# Allow "[email protected]" to log in under the test role.
+unlink($node->data_dir . '/pg_ident.conf');
+$node->append_conf(
+	'pg_ident.conf', qq{
+oauthmap	user\@example.com	test
+});
+
+# test and testalt use the map; testparam uses ident delegation.
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test      oauth issuer="$issuer" scope="" map=oauthmap
+local all testalt   oauth issuer="$issuer" scope="" map=oauthmap
+local all testparam oauth issuer="$issuer" scope="" delegate_ident_mapping=1
+});
+
+# To start, have the validator use the role names as authn IDs.
+$bgconn->query_safe("ALTER SYSTEM RESET oauth_validator.authn_id");
+$bgconn->query_safe("ALTER SYSTEM RESET oauth_validator.authorize_tokens");
+
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+# The test and testalt roles should no longer map correctly.
+$node->connect_fails(
+	"$common_connstr user=test",
+	"mismatched username map (test)",
+	expected_stderr => qr/OAuth bearer authentication failed/);
+$node->connect_fails(
+	"$common_connstr user=testalt",
+	"mismatched username map (testalt)",
+	expected_stderr => qr/OAuth bearer authentication failed/);
+
+# Have the validator identify the end user as [email protected].
+$bgconn->query_safe(
+	"ALTER SYSTEM SET oauth_validator.authn_id TO 'user\@example.com'");
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+# Now the test role can be logged into. (testalt still can't be mapped.)
+$node->connect_ok(
+	"$common_connstr user=test",
+	"matched username map (test)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_fails(
+	"$common_connstr user=testalt",
+	"mismatched username map (testalt)",
+	expected_stderr => qr/OAuth bearer authentication failed/);
+
+# testparam ignores the map entirely.
+$node->connect_ok(
+	"$common_connstr user=testparam",
+	"delegated ident (testparam)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$bgconn->query_safe("ALTER SYSTEM RESET oauth_validator.authn_id");
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+#
+# Test multiple validators.
+#
+
+$node->append_conf('postgresql.conf',
+	"oauth_validator_libraries = 'validator, fail_validator'\n");
+
+# With multiple validators, every HBA line must explicitly declare one.
+my $result = $node->restart(fail_ok => 1);
+is($result, 0,
+	'restart fails without explicit validators in oauth HBA entries');
+
+$log_start = $node->wait_for_log(
+	qr/authentication method "oauth" requires argument "validator" to be set/,
+	$log_start);
+
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test    oauth validator=validator      issuer="$issuer"           scope="openid postgres"
+local all testalt oauth validator=fail_validator issuer="$issuer/.well-known/oauth-authorization-server/alternate" scope="openid postgres alt"
+});
+$node->restart;
+
+$log_start = $node->wait_for_log(qr/ready to accept connections/, $log_start);
+
+# The test user should work as before.
+$user = "test";
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635",
+	"validator is used for $user",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@,
+	log_like => [qr/connection authorized/]);
+
+# testalt should be routed through the fail_validator.
+$user = "testalt";
+$node->connect_fails(
+	"user=$user dbname=postgres oauth_issuer=$issuer/.well-known/oauth-authorization-server/alternate oauth_client_id=f02c6361-0636",
+	"fail_validator is used for $user",
+	expected_stderr => qr/FATAL:\s+fail_validator: sentinel error/);
+
+$node->stop;
+
+done_testing();
diff --git a/src/test/modules/oauth_validator/t/002_client.pl b/src/test/modules/oauth_validator/t/002_client.pl
new file mode 100644
index 00000000000..95cccf90dd8
--- /dev/null
+++ b/src/test/modules/oauth_validator/t/002_client.pl
@@ -0,0 +1,154 @@
+#
+# Exercises the API for custom OAuth client flows, using the oauth_hook_client
+# test driver.
+#
+# Copyright (c) 2021-2024, PostgreSQL Global Development Group
+#
+
+use strict;
+use warnings FATAL => 'all';
+
+use JSON::PP     qw(encode_json);
+use MIME::Base64 qw(encode_base64);
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+if (!$ENV{PG_TEST_EXTRA} || $ENV{PG_TEST_EXTRA} !~ /\boauth\b/)
+{
+	plan skip_all =>
+	  'Potentially unsafe test oauth not enabled in PG_TEST_EXTRA';
+}
+
+#
+# Cluster Setup
+#
+
+my $node = PostgreSQL::Test::Cluster->new('primary');
+$node->init;
+$node->append_conf('postgresql.conf', "log_connections = on\n");
+$node->append_conf('postgresql.conf',
+	"oauth_validator_libraries = 'validator'\n");
+$node->start;
+
+$node->safe_psql('postgres', 'CREATE USER test;');
+
+# These tests don't use the builtin flow, and we don't have an authorization
+# server running, so the address used here shouldn't matter. Use an invalid IP
+# address, so if there's some cascade of errors that causes the client to
+# attempt a connection, we'll fail noisily.
+my $issuer = "https://256.256.256.256";
+my $scope = "openid postgres";
+
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test oauth issuer="$issuer" scope="$scope"
+});
+$node->reload;
+
+my ($log_start, $log_end);
+$log_start = $node->wait_for_log(qr/reloading configuration files/);
+
+$ENV{PGOAUTHDEBUG} = "UNSAFE";
+
+#
+# Tests
+#
+
+my $user = "test";
+my $base_connstr = $node->connstr() . " user=$user";
+my $common_connstr =
+  "$base_connstr oauth_issuer=$issuer oauth_client_id=myID";
+
+sub test
+{
+	my ($test_name, %params) = @_;
+
+	my $flags = [];
+	if (defined($params{flags}))
+	{
+		$flags = $params{flags};
+	}
+
+	my @cmd = ("oauth_hook_client", @{$flags}, $common_connstr);
+	note "running '" . join("' '", @cmd) . "'";
+
+	my ($stdout, $stderr) = run_command(\@cmd);
+
+	if (defined($params{expected_stdout}))
+	{
+		like($stdout, $params{expected_stdout}, "$test_name: stdout matches");
+	}
+
+	if (defined($params{expected_stderr}))
+	{
+		like($stderr, $params{expected_stderr}, "$test_name: stderr matches");
+	}
+	else
+	{
+		is($stderr, "", "$test_name: no stderr");
+	}
+}
+
+test(
+	"basic synchronous hook can provide a token",
+	flags => [
+		"--token", "my-token",
+		"--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"/, ]);
+
+if ($ENV{with_libcurl} ne 'yes')
+{
+	# libpq should help users out if no OAuth support is built in.
+	test(
+		"fails without custom hook installed",
+		flags => ["--no-hook"],
+		expected_stderr =>
+		  qr/no custom OAuth flows are available, and libpq was not built with libcurl support/
+	);
+}
+
+# connect_timeout should work if the flow doesn't respond.
+$common_connstr = "$common_connstr connect_timeout=1";
+test(
+	"connect_timeout interrupts hung client flow",
+	flags => ["--hang-forever"],
+	expected_stderr => qr/failed: timeout expired/);
+
+# Test various misbehaviors of the client hook.
+my @cases = (
+	{
+		flag => "--misbehave=no-hook",
+		expected_error =>
+		  qr/user-defined OAuth flow provided neither a token nor an async callback/,
+	},
+	{
+		flag => "--misbehave=fail-async",
+		expected_error => qr/user-defined OAuth flow failed/,
+	},
+	{
+		flag => "--misbehave=no-token",
+		expected_error => qr/user-defined OAuth flow did not provide a token/,
+	},
+	{
+		flag => "--misbehave=no-socket",
+		expected_error =>
+		  qr/user-defined OAuth flow did not provide a socket for polling/,
+	});
+
+foreach my $c (@cases)
+{
+	test(
+		"hook misbehavior: $c->{'flag'}",
+		flags => [ $c->{'flag'} ],
+		expected_stderr => $c->{'expected_error'});
+}
+
+done_testing();
diff --git a/src/test/modules/oauth_validator/t/OAuth/Server.pm b/src/test/modules/oauth_validator/t/OAuth/Server.pm
new file mode 100644
index 00000000000..f0f23d1d1a8
--- /dev/null
+++ b/src/test/modules/oauth_validator/t/OAuth/Server.pm
@@ -0,0 +1,140 @@
+
+# Copyright (c) 2024, PostgreSQL Global Development Group
+
+=pod
+
+=head1 NAME
+
+OAuth::Server - runs a mock OAuth authorization server for testing
+
+=head1 SYNOPSIS
+
+  use OAuth::Server;
+
+  my $server = OAuth::Server->new();
+  $server->run;
+
+  my $port = $server->port;
+  my $issuer = "http://localhost:$port";
+
+  # test against $issuer...
+
+  $server->stop;
+
+=head1 DESCRIPTION
+
+This is glue API between the Perl tests and the Python authorization server
+daemon implemented in t/oauth_server.py. (Python has a fairly usable HTTP server
+in its standard library, so the implementation was ported from Perl.)
+
+This authorization server does not use TLS (it implements a nonstandard, unsafe
+issuer at "http://localhost:<port>"), so libpq in particular will need to set
+PGOAUTHDEBUG=UNSAFE to be able to talk to it.
+
+=cut
+
+package OAuth::Server;
+
+use warnings;
+use strict;
+use Scalar::Util;
+use Test::More;
+
+=pod
+
+=head1 METHODS
+
+=over
+
+=item SSL::Server->new()
+
+Create a new OAuth Server object.
+
+=cut
+
+sub new
+{
+	my $class = shift;
+
+	my $self = {};
+	bless($self, $class);
+
+	return $self;
+}
+
+=pod
+
+=item $server->port()
+
+Returns the port in use by the server.
+
+=cut
+
+sub port
+{
+	my $self = shift;
+
+	return $self->{'port'};
+}
+
+=pod
+
+=item $server->run()
+
+Runs the authorization server daemon in t/oauth_server.py.
+
+=cut
+
+sub run
+{
+	my $self = shift;
+	my $port;
+
+	my $pid = open(my $read_fh, "-|", $ENV{PYTHON}, "t/oauth_server.py")
+	  or die "failed to start OAuth server: $!";
+
+	# Get the port number from the daemon. It closes stdout afterwards; that way
+	# we can slurp in the entire contents here rather than worrying about the
+	# number of bytes to read.
+	$port = do { local $/ = undef; <$read_fh> }
+	  // die "failed to read port number: $!";
+	chomp $port;
+	die "server did not advertise a valid port"
+	  unless Scalar::Util::looks_like_number($port);
+
+	$self->{'pid'} = $pid;
+	$self->{'port'} = $port;
+	$self->{'child'} = $read_fh;
+
+	note("OAuth provider (PID $pid) is listening on port $port\n");
+}
+
+=pod
+
+=item $server->stop()
+
+Sends SIGTERM to the authorization server and waits for it to exit.
+
+=cut
+
+sub stop
+{
+	my $self = shift;
+
+	note("Sending SIGTERM to OAuth provider PID: $self->{'pid'}\n");
+
+	kill(15, $self->{'pid'});
+	$self->{'pid'} = undef;
+
+	# Closing the popen() handle waits for the process to exit.
+	close($self->{'child'});
+	$self->{'child'} = undef;
+}
+
+=pod
+
+=back
+
+=cut
+
+1;
diff --git a/src/test/modules/oauth_validator/t/oauth_server.py b/src/test/modules/oauth_validator/t/oauth_server.py
new file mode 100755
index 00000000000..4faf3323d38
--- /dev/null
+++ b/src/test/modules/oauth_validator/t/oauth_server.py
@@ -0,0 +1,391 @@
+#! /usr/bin/env python3
+#
+# A mock OAuth authorization server, designed to be invoked from
+# OAuth/Server.pm. This listens on an ephemeral port number (printed to stdout
+# so that the Perl tests can contact it) and runs as a daemon until it is
+# signaled.
+#
+
+import base64
+import http.server
+import json
+import os
+import sys
+import time
+import urllib.parse
+from collections import defaultdict
+
+
+class OAuthHandler(http.server.BaseHTTPRequestHandler):
+    """
+    Core implementation of the authorization server. The API is
+    inheritance-based, with entry points at do_GET() and do_POST(). See the
+    documentation for BaseHTTPRequestHandler.
+    """
+
+    JsonObject = dict[str, object]  # TypeAlias is not available until 3.10
+
+    def _check_issuer(self):
+        """
+        Switches the behavior of the provider depending on the issuer URI.
+        """
+        self._alt_issuer = (
+            self.path.startswith("/alternate/")
+            or self.path == "/.well-known/oauth-authorization-server/alternate"
+        )
+        self._parameterized = self.path.startswith("/param/")
+
+        if self._alt_issuer:
+            # The /alternate issuer uses IETF-style .well-known URIs.
+            if self.path.startswith("/.well-known/"):
+                self.path = self.path.removesuffix("/alternate")
+            else:
+                self.path = self.path.removeprefix("/alternate")
+        elif self._parameterized:
+            self.path = self.path.removeprefix("/param")
+
+    def _check_authn(self):
+        """
+        Checks the expected value of the Authorization header, if any.
+        """
+        secret = self._get_param("expected_secret", None)
+        if secret is None:
+            return
+
+        assert "Authorization" in self.headers
+        method, creds = self.headers["Authorization"].split()
+
+        if method != "Basic":
+            raise RuntimeError(f"client used {method} auth; expected Basic")
+
+        username = urllib.parse.quote_plus(self.client_id)
+        password = urllib.parse.quote_plus(secret)
+        expected_creds = f"{username}:{password}"
+
+        if creds.encode() != base64.b64encode(expected_creds.encode()):
+            raise RuntimeError(
+                f"client sent '{creds}'; expected b64encode('{expected_creds}')"
+            )
+
+    def do_GET(self):
+        self._response_code = 200
+        self._check_issuer()
+
+        config_path = "/.well-known/openid-configuration"
+        if self._alt_issuer:
+            config_path = "/.well-known/oauth-authorization-server"
+
+        if self.path == config_path:
+            resp = self.config()
+        else:
+            self.send_error(404, "Not Found")
+            return
+
+        self._send_json(resp)
+
+    def _parse_params(self) -> dict[str, str]:
+        """
+        Parses apart the form-urlencoded request body and returns the resulting
+        dict. For use by do_POST().
+        """
+        size = int(self.headers["Content-Length"])
+        form = self.rfile.read(size)
+
+        assert self.headers["Content-Type"] == "application/x-www-form-urlencoded"
+        return urllib.parse.parse_qs(
+            form.decode("utf-8"),
+            strict_parsing=True,
+            keep_blank_values=True,
+            encoding="utf-8",
+            errors="strict",
+        )
+
+    @property
+    def client_id(self) -> str:
+        """
+        Returns the client_id sent in the POST body or the Authorization header.
+        self._parse_params() must have been called first.
+        """
+        if "client_id" in self._params:
+            return self._params["client_id"][0]
+
+        if "Authorization" not in self.headers:
+            raise RuntimeError("client did not send any client_id")
+
+        _, creds = self.headers["Authorization"].split()
+
+        decoded = base64.b64decode(creds).decode("utf-8")
+        username, _ = decoded.split(":", 1)
+
+        return urllib.parse.unquote_plus(username)
+
+    def do_POST(self):
+        self._response_code = 200
+        self._check_issuer()
+
+        self._params = self._parse_params()
+        if self._parameterized:
+            # Pull encoded test parameters out of the peer's client_id field.
+            # This is expected to be Base64-encoded JSON.
+            js = base64.b64decode(self.client_id)
+            self._test_params = json.loads(js)
+
+        self._check_authn()
+
+        if self.path == "/authorize":
+            resp = self.authorization()
+        elif self.path == "/token":
+            resp = self.token()
+        else:
+            self.send_error(404)
+            return
+
+        self._send_json(resp)
+
+    def _should_modify(self) -> bool:
+        """
+        Returns True if the client has requested a modification to this stage of
+        the exchange.
+        """
+        if not hasattr(self, "_test_params"):
+            return False
+
+        stage = self._test_params.get("stage")
+
+        return (
+            stage == "all"
+            or (
+                stage == "discovery"
+                and self.path == "/.well-known/openid-configuration"
+            )
+            or (stage == "device" and self.path == "/authorize")
+            or (stage == "token" and self.path == "/token")
+        )
+
+    def _get_param(self, name, default):
+        """
+        If the client has requested a modification to this stage (see
+        _should_modify()), this method searches the provided test parameters for
+        a key of the given name, and returns it if found. Otherwise the provided
+        default is returned.
+        """
+        if self._should_modify() and name in self._test_params:
+            return self._test_params[name]
+
+        return default
+
+    @property
+    def _content_type(self) -> str:
+        """
+        Returns "application/json" unless the test has requested something
+        different.
+        """
+        return self._get_param("content_type", "application/json")
+
+    @property
+    def _interval(self) -> int:
+        """
+        Returns 0 unless the test has requested something different.
+        """
+        return self._get_param("interval", 0)
+
+    @property
+    def _retry_code(self) -> str:
+        """
+        Returns "authorization_pending" unless the test has requested something
+        different.
+        """
+        return self._get_param("retry_code", "authorization_pending")
+
+    @property
+    def _uri_spelling(self) -> str:
+        """
+        Returns "verification_uri" unless the test has requested something
+        different.
+        """
+        return self._get_param("uri_spelling", "verification_uri")
+
+    @property
+    def _response_padding(self):
+        """
+        If the huge_response test parameter is set to True, returns a dict
+        containing a gigantic string value, which can then be folded into a JSON
+        response.
+        """
+        if not self._get_param("huge_response", False):
+            return dict()
+
+        return {"_pad_": "x" * 1024 * 1024}
+
+    @property
+    def _access_token(self):
+        """
+        The actual Bearer token sent back to the client on success. Tests may
+        override this with the "token" test parameter.
+        """
+        token = self._get_param("token", None)
+        if token is not None:
+            return token
+
+        token = "9243959234"
+        if self._alt_issuer:
+            token += "-alt"
+
+        return token
+
+    def _send_json(self, js: JsonObject) -> None:
+        """
+        Sends the provided JSON dict as an application/json response.
+        self._response_code can be modified to send JSON error responses.
+        """
+        resp = json.dumps(js).encode("ascii")
+        self.log_message("sending JSON response: %s", resp)
+
+        self.send_response(self._response_code)
+        self.send_header("Content-Type", self._content_type)
+        self.send_header("Content-Length", str(len(resp)))
+        self.end_headers()
+
+        self.wfile.write(resp)
+
+    def config(self) -> JsonObject:
+        port = self.server.socket.getsockname()[1]
+
+        issuer = f"http://localhost:{port}"
+        if self._alt_issuer:
+            issuer += "/alternate"
+        elif self._parameterized:
+            issuer += "/param"
+
+        return {
+            "issuer": issuer,
+            "token_endpoint": issuer + "/token",
+            "device_authorization_endpoint": issuer + "/authorize",
+            "response_types_supported": ["token"],
+            "subject_types_supported": ["public"],
+            "id_token_signing_alg_values_supported": ["RS256"],
+            "grant_types_supported": [
+                "authorization_code",
+                "urn:ietf:params:oauth:grant-type:device_code",
+            ],
+        }
+
+    @property
+    def _token_state(self):
+        """
+        A cached _TokenState object for the connected client (as determined by
+        the request's client_id), or a new one if it doesn't already exist.
+
+        This relies on the existence of a defaultdict attached to the server;
+        see main() below.
+        """
+        return self.server.token_state[self.client_id]
+
+    def _remove_token_state(self):
+        """
+        Removes any cached _TokenState for the current client_id. Call this
+        after the token exchange ends to get rid of unnecessary state.
+        """
+        if self.client_id in self.server.token_state:
+            del self.server.token_state[self.client_id]
+
+    def authorization(self) -> JsonObject:
+        uri = "https://example.com/"
+        if self._alt_issuer:
+            uri = "https://example.org/"
+
+        resp = {
+            "device_code": "postgres",
+            "user_code": "postgresuser",
+            self._uri_spelling: uri,
+            "expires_in": 5,
+            **self._response_padding,
+        }
+
+        interval = self._interval
+        if interval is not None:
+            resp["interval"] = interval
+            self._token_state.min_delay = interval
+        else:
+            self._token_state.min_delay = 5  # default
+
+        # Check the scope.
+        if "scope" in self._params:
+            assert self._params["scope"][0], "empty scopes should be omitted"
+
+        return resp
+
+    def token(self) -> JsonObject:
+        if err := self._get_param("error_code", None):
+            self._response_code = self._get_param("error_status", 400)
+
+            resp = {"error": err}
+            if desc := self._get_param("error_desc", ""):
+                resp["error_description"] = desc
+
+            return resp
+
+        if self._should_modify() and "retries" in self._test_params:
+            retries = self._test_params["retries"]
+
+            # Check to make sure the token interval is being respected.
+            now = time.monotonic()
+            if self._token_state.last_try is not None:
+                delay = now - self._token_state.last_try
+                assert (
+                    delay > self._token_state.min_delay
+                ), f"client waited only {delay} seconds between token requests (expected {self._token_state.min_delay})"
+
+            self._token_state.last_try = now
+
+            # If we haven't reached the required number of retries yet, return a
+            # "pending" response.
+            if self._token_state.retries < retries:
+                self._token_state.retries += 1
+
+                self._response_code = 400
+                return {"error": self._retry_code}
+
+        # Clean up any retry tracking state now that the exchange is ending.
+        self._remove_token_state()
+
+        return {
+            "access_token": self._access_token,
+            "token_type": "bearer",
+            **self._response_padding,
+        }
+
+
+def main():
+    """
+    Starts the authorization server on localhost. The ephemeral port in use will
+    be printed to stdout.
+    """
+
+    s = http.server.HTTPServer(("127.0.0.1", 0), OAuthHandler)
+
+    # Attach a "cache" dictionary to the server to allow the OAuthHandlers to
+    # track state across token requests. The use of defaultdict ensures that new
+    # entries will be created automatically.
+    class _TokenState:
+        retries = 0
+        min_delay = None
+        last_try = None
+
+    s.token_state = defaultdict(_TokenState)
+
+    # Give the parent the port number to contact (this is also the signal that
+    # we're ready to receive requests).
+    port = s.socket.getsockname()[1]
+    print(port)
+
+    # stdout is closed to allow the parent to just "read to the end".
+    stdout = sys.stdout.fileno()
+    sys.stdout.close()
+    os.close(stdout)
+
+    s.serve_forever()  # we expect our parent to send a termination signal
+
+
+if __name__ == "__main__":
+    main()
diff --git a/src/test/modules/oauth_validator/validator.c b/src/test/modules/oauth_validator/validator.c
new file mode 100644
index 00000000000..ef9bbb2866f
--- /dev/null
+++ b/src/test/modules/oauth_validator/validator.c
@@ -0,0 +1,135 @@
+/*-------------------------------------------------------------------------
+ *
+ * validator.c
+ *	  Test module for serverside OAuth token validation callbacks
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/test/modules/oauth_validator/validator.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "fmgr.h"
+#include "libpq/oauth.h"
+#include "miscadmin.h"
+#include "utils/guc.h"
+#include "utils/memutils.h"
+
+PG_MODULE_MAGIC;
+
+static void validator_startup(ValidatorModuleState *state);
+static void validator_shutdown(ValidatorModuleState *state);
+static ValidatorModuleResult *validate_token(ValidatorModuleState *state,
+											 const char *token,
+											 const char *role);
+
+/* Callback implementations (exercise all three) */
+static const OAuthValidatorCallbacks validator_callbacks = {
+	.startup_cb = validator_startup,
+	.shutdown_cb = validator_shutdown,
+	.validate_cb = validate_token
+};
+
+/* GUCs */
+static char *authn_id = NULL;
+static bool authorize_tokens = true;
+
+/*---
+ * Extension entry point. Sets up GUCs for use by tests:
+ *
+ * - oauth_validator.authn_id	Sets the user identifier to return during token
+ *								validation. Defaults to the username in the
+ *								startup packet.
+ *
+ * - oauth_validator.authorize_tokens
+ *								Sets whether to successfully validate incoming
+ *								tokens. Defaults to true.
+ */
+void
+_PG_init(void)
+{
+	DefineCustomStringVariable("oauth_validator.authn_id",
+							   "Authenticated identity to use for future connections",
+							   NULL,
+							   &authn_id,
+							   NULL,
+							   PGC_SIGHUP,
+							   0,
+							   NULL, NULL, NULL);
+	DefineCustomBoolVariable("oauth_validator.authorize_tokens",
+							 "Should tokens be marked valid?",
+							 NULL,
+							 &authorize_tokens,
+							 true,
+							 PGC_SIGHUP,
+							 0,
+							 NULL, NULL, NULL);
+
+	MarkGUCPrefixReserved("oauth_validator");
+}
+
+/*
+ * Validator module entry point.
+ */
+const OAuthValidatorCallbacks *
+_PG_oauth_validator_module_init(void)
+{
+	return &validator_callbacks;
+}
+
+#define PRIVATE_COOKIE ((void *) 13579)
+
+/*
+ * Startup callback, to set up private data for the validator.
+ */
+static void
+validator_startup(ValidatorModuleState *state)
+{
+	state->private_data = PRIVATE_COOKIE;
+}
+
+/*
+ * Shutdown callback, to tear down the validator.
+ */
+static void
+validator_shutdown(ValidatorModuleState *state)
+{
+	/* Check to make sure our private state still exists. */
+	if (state->private_data != PRIVATE_COOKIE)
+		elog(PANIC, "oauth_validator: private state cookie changed to %p in shutdown",
+			 state->private_data);
+}
+
+/*
+ * Validator implementation. Logs the incoming data and authorizes the token by
+ * default; the behavior can be modified via the module's GUC settings.
+ */
+static ValidatorModuleResult *
+validate_token(ValidatorModuleState *state, const char *token, const char *role)
+{
+	ValidatorModuleResult *res;
+
+	/* Check to make sure our private state still exists. */
+	if (state->private_data != PRIVATE_COOKIE)
+		elog(ERROR, "oauth_validator: private state cookie changed to %p in validate",
+			 state->private_data);
+
+	res = palloc(sizeof(ValidatorModuleResult));
+
+	elog(LOG, "oauth_validator: token=\"%s\", role=\"%s\"", token, role);
+	elog(LOG, "oauth_validator: issuer=\"%s\", scope=\"%s\"",
+		 MyProcPort->hba->oauth_issuer,
+		 MyProcPort->hba->oauth_scope);
+
+	res->authorized = authorize_tokens;
+	if (authn_id)
+		res->authn_id = pstrdup(authn_id);
+	else
+		res->authn_id = pstrdup(role);
+
+	return res;
+}
diff --git a/src/test/perl/PostgreSQL/Test/Cluster.pm b/src/test/perl/PostgreSQL/Test/Cluster.pm
index f521ad0b12f..ab7d7452ede 100644
--- a/src/test/perl/PostgreSQL/Test/Cluster.pm
+++ b/src/test/perl/PostgreSQL/Test/Cluster.pm
@@ -2515,6 +2515,11 @@ instead of the default.
 
 If this regular expression is set, matches it with the output generated.
 
+=item expected_stderr => B<value>
+
+If this regular expression is set, matches it against the standard error
+stream; otherwise the stderr must be empty.
+
 =item log_like => [ qr/required message/ ]
 
 =item log_unlike => [ qr/prohibited message/ ]
@@ -2558,7 +2563,20 @@ sub connect_ok
 		like($stdout, $params{expected_stdout}, "$test_name: stdout matches");
 	}
 
-	is($stderr, "", "$test_name: no stderr");
+	if (defined($params{expected_stderr}))
+	{
+		if (like($stderr, $params{expected_stderr}, "$test_name: stderr matches")
+			&& ($ret != 0))
+		{
+			# In this case (failing test but matching stderr) we'll have
+			# swallowed the output needed to debug. Put it back into the logs.
+			diag("$test_name: full stderr:\n" . $stderr);
+		}
+	}
+	else
+	{
+		is($stderr, "", "$test_name: no stderr");
+	}
 
 	$self->log_check($test_name, $log_location, %params);
 }
diff --git a/src/tools/pgindent/pgindent b/src/tools/pgindent/pgindent
index d8acce7e929..7dccf4614aa 100755
--- a/src/tools/pgindent/pgindent
+++ b/src/tools/pgindent/pgindent
@@ -242,6 +242,14 @@ sub pre_indent
 	# Protect wrapping in CATALOG()
 	$source =~ s!^(CATALOG\(.*)$!/*$1*/!gm;
 
+	# Treat a CURL_IGNORE_DEPRECATION() as braces for the purposes of
+	# indentation. (The recursive regex comes from the perlre documentation; it
+	# matches balanced parentheses as group $1 and the contents as group $2.)
+	my $curlopen = '{ /* CURL_IGNORE_DEPRECATION */';
+	my $curlclose = '} /* CURL_IGNORE_DEPRECATION */';
+	$source =~
+	  s!^[ \t]+CURL_IGNORE_DEPRECATION(\(((?:(?>[^()]+)|(?1))*)\))!$curlopen$2$curlclose!gms;
+
 	return $source;
 }
 
@@ -256,6 +264,12 @@ sub post_indent
 	$source =~ s!^/\* Open extern "C" \*/$!{!gm;
 	$source =~ s!^/\* Close extern "C" \*/$!}!gm;
 
+	# Restore the CURL_IGNORE_DEPRECATION() macro, keeping in mind that our
+	# markers may have been re-indented.
+	$source =~
+	  s!{[ \t]+/\* CURL_IGNORE_DEPRECATION \*/!CURL_IGNORE_DEPRECATION(!gm;
+	$source =~ s!}[ \t]+/\* CURL_IGNORE_DEPRECATION \*/!)!gm;
+
 	## Comments
 
 	# Undo change of dash-protected block comments
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index b6c170ac249..ed8ef8ddc89 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -371,6 +371,9 @@ CState
 CTECycleClause
 CTEMaterialize
 CTESearchClause
+CURL
+CURLM
+CURLoption
 CV
 CachedExpression
 CachedPlan
@@ -1724,6 +1727,7 @@ NumericDigit
 NumericSortSupport
 NumericSumAccum
 NumericVar
+OAuthValidatorCallbacks
 OM_uint32
 OP
 OSAPerGroupState
@@ -1832,6 +1836,7 @@ PGVerbosity
 PG_Locale_Strategy
 PG_Lock_Status
 PG_init_t
+PGauthData
 PGcancel
 PGcancelConn
 PGcmdQueueEntry
@@ -1839,7 +1844,9 @@ PGconn
 PGdataValue
 PGlobjfuncs
 PGnotify
+PGoauthBearerRequest
 PGpipelineStatus
+PGpromptOAuthDevice
 PGresAttDesc
 PGresAttValue
 PGresParamDesc
@@ -1952,6 +1959,7 @@ PQArgBlock
 PQEnvironmentOption
 PQExpBuffer
 PQExpBufferData
+PQauthDataHook_type
 PQcommMethods
 PQconninfoOption
 PQnoticeProcessor
@@ -3091,6 +3099,8 @@ VacuumRelation
 VacuumStmt
 ValidIOData
 ValidateIndexState
+ValidatorModuleState
+ValidatorModuleResult
 ValuesScan
 ValuesScanState
 Var
@@ -3488,6 +3498,7 @@ explain_get_index_name_hook_type
 f_smgr
 fasthash_state
 fd_set
+fe_oauth_state
 fe_scram_state
 fe_scram_state_enum
 fetch_range_request
-- 
2.39.3 (Apple Git-146)



  [application/octet-stream] v49-0002-v48-fixup-patches-Add-OAUTHBEARER-SASL-mechanism.patch (17.6K, ../../[email protected]/3-v49-0002-v48-fixup-patches-Add-OAUTHBEARER-SASL-mechanism.patch)
  download | inline diff:
From e723c2040405237155eeca58cab675866763b876 Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Fri, 7 Feb 2025 14:23:40 -0800
Subject: [PATCH v49 2/4] v48 fixup patches! Add OAUTHBEARER SASL mechanism

---
 doc/src/sgml/libpq.sgml                       | 40 +++++++++++++++-
 doc/src/sgml/oauth-validators.sgml            | 31 ++++++++----
 src/backend/libpq/auth-oauth.c                | 20 ++++++--
 src/include/libpq/oauth.h                     | 48 ++++++++++++++++++-
 src/interfaces/libpq/fe-auth-oauth-curl.c     | 14 +++++-
 .../modules/oauth_validator/fail_validator.c  | 15 ++++--
 .../modules/oauth_validator/t/001_server.pl   | 11 ++++-
 src/test/modules/oauth_validator/validator.c  | 28 +++++++----
 8 files changed, 175 insertions(+), 32 deletions(-)

diff --git a/doc/src/sgml/libpq.sgml b/doc/src/sgml/libpq.sgml
index a51355e238f..b2abae8deee 100644
--- a/doc/src/sgml/libpq.sgml
+++ b/doc/src/sgml/libpq.sgml
@@ -10133,8 +10133,46 @@ void PQinitSSL(int do_ssl);
   <title>OAuth Support</title>
 
   <para>
-   TODO
+   libpq implements support for the OAuth v2 Device Authorization client flow,
+   documented in
+   <ulink url="https://datatracker.ietf.org/doc/html/rfc8628">RFC 8628</ulink>,
+   which it will attempt to use by default if the server
+   <link linkend="auth-oauth">requests a bearer token</link> during
+   authentication. This flow can be utilized even if the system running the
+   client application does not have a usable web browser, for example when
+   running a client via SSH. Client applications may implement their own flows
+   instead; see <xref linkend="libpq-oauth-authdata-hooks"/>.
   </para>
+  <para>
+   The builtin flow will, by default, print a URL to visit and a user code to
+   enter there:
+<programlisting>
+$ psql 'dbname=postgres oauth_issuer=https://example.com oauth_client_id=...'
+Visit https://example.com/device and enter the code: ABCD-EFGH
+</programlisting>
+   (This prompt may be
+   <link linkend="libpq-oauth-authdata-prompt-oauth-device">customized</link>.)
+   You will then log into your OAuth provider, which will ask whether you want
+   to allow libpq and the server to perform actions on your behalf. It is always
+   a good idea to carefully review the URL and permissions displayed, to ensure
+   they match your expectations, before continuing. Do not give permissions to
+   untrusted third parties.
+  </para>
+  <para>
+   For an OAuth client flow to be usable, the connection string must at minimum
+   contain <xref linkend="libpq-connect-oauth-issuer"/> and
+   <xref linkend="libpq-connect-oauth-client-id"/>. (These settings are
+   determined by your organization's OAuth provider.) The builtin flow
+   additionally requires the OAuth authorization server to publish a device
+   authorization endpoint.
+  </para>
+
+  <note>
+   <para>
+    The builtin Device Authorization flow is not currently supported on Windows.
+    Custom client flows may still be implemented.
+   </para>
+  </note>
 
   <sect2 id="libpq-oauth-authdata-hooks">
    <title>Authdata Hooks</title>
diff --git a/doc/src/sgml/oauth-validators.sgml b/doc/src/sgml/oauth-validators.sgml
index d0bca9196d9..eb8c4431c2d 100644
--- a/doc/src/sgml/oauth-validators.sgml
+++ b/doc/src/sgml/oauth-validators.sgml
@@ -41,7 +41,9 @@
   <sect2 id="oauth-validator-design-responsibilities">
    <title>Validator Responsibilities</title>
    <para>
-    TODO
+    Although different modules may take very different approaches to token
+    validation, implementations generally need to perform three separate
+    actions:
    </para>
    <variablelist>
     <varlistentry>
@@ -121,6 +123,11 @@
        </footnote>
        if users are not prompted for additional scopes.
       </para>
+      <para>
+       Even if authorization fails, a module may choose to continue to pull
+       authentication information from the token for use in auditing and
+       debugging.
+      </para>
      </listitem>
     </varlistentry>
     <varlistentry>
@@ -290,13 +297,15 @@
    validator module a function named
    <function>_PG_oauth_validator_module_init</function> must be provided. The
    return value of the function must be a pointer to a struct of type
-   <structname>OAuthValidatorCallbacks</structname>, which contains pointers to
-   the module's token validation functions. The returned
+   <structname>OAuthValidatorCallbacks</structname>, which contains a magic
+   number and pointers to the module's token validation functions. The returned
    pointer must be of server lifetime, which is typically achieved by defining
    it as a <literal>static const</literal> variable in global scope.
 <programlisting>
 typedef struct OAuthValidatorCallbacks
 {
+    uint32        magic;            /* must be set to PG_OAUTH_VALIDATOR_MAGIC */
+
     ValidatorStartupCB startup_cb;
     ValidatorShutdownCB shutdown_cb;
     ValidatorValidateCB validate_cb;
@@ -341,14 +350,16 @@ typedef void (*ValidatorStartupCB) (ValidatorModuleState *state);
     previous calls will be available in <structfield>state->private_data</structfield>.
 
 <programlisting>
-typedef ValidatorModuleResult *(*ValidatorValidateCB) (ValidatorModuleState *state, const char *token, const char *role);
+typedef bool (*ValidatorValidateCB) (const ValidatorModuleState *state,
+                                     const char *token, const char *role,
+                                     ValidatorModuleResult *result);
 </programlisting>
 
     <replaceable>token</replaceable> will contain the bearer token to validate.
     The server has ensured that the token is well-formed syntactically, but no
     other validation has been performed.  <replaceable>role</replaceable> will
     contain the role the user has requested to log in as.  The callback must
-    return a palloc'd <literal>ValidatorModuleResult</literal> struct, which is
+    set output parameters in the <literal>result</literal> struct, which is
     defined as below:
 
 <programlisting>
@@ -368,17 +379,17 @@ typedef struct ValidatorModuleResult
     determined.
    </para>
    <para>
-    The caller assumes ownership of the returned memory allocation, the
-    validator module should not in any way access the memory after it has been
-    returned.  A validator may instead return NULL to signal an internal
-    error.
+    A validator may return <literal>false</literal> to signal an internal error,
+    in which case any result parameters are ignored and the connection fails.
+    Otherwise the validator should return <literal>true</literal> to indicate
+    that it has processed the token and made an authorization decision.
    </para>
    <para>
     The behavior after <function>validate_cb</function> returns depends on the
     specific HBA setup.  Normally, the <structfield>authn_id</structfield> user
     name must exactly match the role that the user is logging in as.  (This
     behavior may be modified with a usermap.)  But when authenticating against
-    an HBA rule with <literal>trust_validator_authz</literal> turned on, the
+    an HBA rule with <literal>delegate_ident_mapping</literal> turned on, the
     server will not perform any checks on the value of
     <structfield>authn_id</structfield> at all; in this case it is up to the
     validator to ensure that the token carries enough privileges for the user to
diff --git a/src/backend/libpq/auth-oauth.c b/src/backend/libpq/auth-oauth.c
index aa16977c643..e2b5d1ed913 100644
--- a/src/backend/libpq/auth-oauth.c
+++ b/src/backend/libpq/auth-oauth.c
@@ -656,9 +656,9 @@ validate(Port *port, const char *auth)
 				errmsg("validation of OAuth token requested without a validator loaded"));
 
 	/* Call the validation function from the validator module */
-	ret = ValidatorCallbacks->validate_cb(validator_module_state,
-										  token, port->user_name);
-	if (ret == NULL)
+	ret = palloc0(sizeof(ValidatorModuleResult));
+	if (!ValidatorCallbacks->validate_cb(validator_module_state, token,
+										 port->user_name, ret))
 	{
 		ereport(LOG, errmsg("internal error in OAuth validator module"));
 		return false;
@@ -756,8 +756,22 @@ load_validator_library(const char *libname)
 	ValidatorCallbacks = (*validator_init) ();
 	Assert(ValidatorCallbacks);
 
+	/*
+	 * Check the magic number, to protect against break-glass scenarios where
+	 * the ABI must change within a major version. load_external_function()
+	 * already checks for compatibility across major versions.
+	 */
+	if (ValidatorCallbacks->magic != PG_OAUTH_VALIDATOR_MAGIC)
+		ereport(ERROR,
+				errmsg("%s module \"%s\": magic number mismatch",
+					   "OAuth validator", libname),
+				errdetail("Server has magic number 0x%08X, module has 0x%08X.",
+						  PG_OAUTH_VALIDATOR_MAGIC, ValidatorCallbacks->magic));
+
 	/* Allocate memory for validator library private state data */
 	validator_module_state = (ValidatorModuleState *) palloc0(sizeof(ValidatorModuleState));
+	validator_module_state->sversion = PG_VERSION_NUM;
+
 	if (ValidatorCallbacks->startup_cb != NULL)
 		ValidatorCallbacks->startup_cb(validator_module_state);
 
diff --git a/src/include/libpq/oauth.h b/src/include/libpq/oauth.h
index 4fcdda74305..7e249613e10 100644
--- a/src/include/libpq/oauth.h
+++ b/src/include/libpq/oauth.h
@@ -20,26 +20,72 @@ extern PGDLLIMPORT char *oauth_validator_libraries_string;
 
 typedef struct ValidatorModuleState
 {
+	/* Holds the server's PG_VERSION_NUM. Reserved for future extensibility. */
+	int			sversion;
+
+	/*
+	 * Private data pointer for use by a validator module. This can be used to
+	 * store state for the module that will be passed to each of its
+	 * callbacks.
+	 */
 	void	   *private_data;
 } ValidatorModuleState;
 
 typedef struct ValidatorModuleResult
 {
+	/*
+	 * Should be set to true if the token carries sufficient permissions for
+	 * the bearer to connect.
+	 */
 	bool		authorized;
+
+	/*
+	 * If the token authenticates the user, this should be set to a palloc'd
+	 * string containing the SYSTEM_USER to use for HBA mapping. Consider
+	 * setting this even if result->authorized is false so that DBAs may use
+	 * the logs to match end users to token failures.
+	 *
+	 * This is required if the module is not configured for ident mapping
+	 * delegation. See the validator module documentation for details.
+	 */
 	char	   *authn_id;
 } ValidatorModuleResult;
 
+/*
+ * Validator module callbacks
+ *
+ * These callback functions should be defined by validator modules and returned
+ * via _PG_oauth_validator_module_init().  ValidatorValidateCB is the only
+ * required callback. For more information about the purpose of each callback,
+ * refer to the OAuth validator modules documentation.
+ */
 typedef void (*ValidatorStartupCB) (ValidatorModuleState *state);
 typedef void (*ValidatorShutdownCB) (ValidatorModuleState *state);
-typedef ValidatorModuleResult *(*ValidatorValidateCB) (ValidatorModuleState *state, const char *token, const char *role);
+typedef bool (*ValidatorValidateCB) (const ValidatorModuleState *state,
+									 const char *token, const char *role,
+									 ValidatorModuleResult *result);
+
+/*
+ * Identifies the compiled ABI version of the validator module. Since the server
+ * already enforces the PG_MODULE_MAGIC number for modules across major
+ * versions, this is reserved for emergency use within a stable release line.
+ * May it never need to change.
+ */
+#define PG_OAUTH_VALIDATOR_MAGIC 0x20250207
 
 typedef struct OAuthValidatorCallbacks
 {
+	uint32		magic;			/* must be set to PG_OAUTH_VALIDATOR_MAGIC */
+
 	ValidatorStartupCB startup_cb;
 	ValidatorShutdownCB shutdown_cb;
 	ValidatorValidateCB validate_cb;
 } OAuthValidatorCallbacks;
 
+/*
+ * Type of the shared library symbol _PG_oauth_validator_module_init that is
+ * looked up when loading a validator module.
+ */
 typedef const OAuthValidatorCallbacks *(*OAuthValidatorModuleInit) (void);
 extern PGDLLEXPORT const OAuthValidatorCallbacks *_PG_oauth_validator_module_init(void);
 
diff --git a/src/interfaces/libpq/fe-auth-oauth-curl.c b/src/interfaces/libpq/fe-auth-oauth-curl.c
index 2179bb89800..74323de309a 100644
--- a/src/interfaces/libpq/fe-auth-oauth-curl.c
+++ b/src/interfaces/libpq/fe-auth-oauth-curl.c
@@ -32,7 +32,19 @@
 #include "libpq-int.h"
 #include "mb/pg_wchar.h"
 
-#define MAX_OAUTH_RESPONSE_SIZE (1024 * 1024)
+/*
+ * It's generally prudent to set a maximum response size to buffer in memory,
+ * but it's less clear what size to choose. The biggest of our expected
+ * responses is the server metadata JSON, which will only continue to grow in
+ * size; the number of IANA-registered parameters in that document is up to 78
+ * as of February 2025.
+ *
+ * Even if every single parameter were to take up 2k on average (a previously
+ * common limit on the size of a URL), 256k gives us 128 parameter values before
+ * we give up. (That's almost certainly complete overkill in practice; 2-4k
+ * appears to be common among popular providers at the moment.)
+ */
+#define MAX_OAUTH_RESPONSE_SIZE (256 * 1024)
 
 /*
  * Parsed JSON Representations
diff --git a/src/test/modules/oauth_validator/fail_validator.c b/src/test/modules/oauth_validator/fail_validator.c
index f77a3e115c6..7b1e69518d9 100644
--- a/src/test/modules/oauth_validator/fail_validator.c
+++ b/src/test/modules/oauth_validator/fail_validator.c
@@ -19,12 +19,15 @@
 
 PG_MODULE_MAGIC;
 
-static ValidatorModuleResult *fail_token(ValidatorModuleState *state,
-										 const char *token,
-										 const char *role);
+static bool fail_token(const ValidatorModuleState *state,
+					   const char *token,
+					   const char *role,
+					   ValidatorModuleResult *result);
 
 /* Callback implementations (we only need the main one) */
 static const OAuthValidatorCallbacks validator_callbacks = {
+	PG_OAUTH_VALIDATOR_MAGIC,
+
 	.validate_cb = fail_token,
 };
 
@@ -34,8 +37,10 @@ _PG_oauth_validator_module_init(void)
 	return &validator_callbacks;
 }
 
-static ValidatorModuleResult *
-fail_token(ValidatorModuleState *state, const char *token, const char *role)
+static bool
+fail_token(const ValidatorModuleState *state,
+		   const char *token, const char *role,
+		   ValidatorModuleResult *res)
 {
 	elog(FATAL, "fail_validator: sentinel error");
 	pg_unreachable();
diff --git a/src/test/modules/oauth_validator/t/001_server.pl b/src/test/modules/oauth_validator/t/001_server.pl
index f0b918390fd..d2dda62a2d4 100644
--- a/src/test/modules/oauth_validator/t/001_server.pl
+++ b/src/test/modules/oauth_validator/t/001_server.pl
@@ -14,12 +14,18 @@ use MIME::Base64 qw(encode_base64);
 use PostgreSQL::Test::Cluster;
 use PostgreSQL::Test::Utils;
 use Test::More;
+use Config;
 
 use FindBin;
 use lib $FindBin::RealBin;
 
 use OAuth::Server;
 
+if ($Config{osname} eq 'MSWin32')
+{
+	plan skip_all => 'OAuth server-side tests are not supported on Windows';
+}
+
 if (!$ENV{PG_TEST_EXTRA} || $ENV{PG_TEST_EXTRA} !~ /\boauth\b/)
 {
 	plan skip_all =>
@@ -402,7 +408,10 @@ note "running '" . join("' '", @cmd) . "'";
 my ($stdout, $stderr) = run_command(\@cmd);
 
 like($stdout, qr/connection succeeded/, "stress-async: stdout matches");
-unlike($stderr, qr/connection to database failed/, "stress-async: stderr matches");
+unlike(
+	$stderr,
+	qr/connection to database failed/,
+	"stress-async: stderr matches");
 
 #
 # This section of tests reconfigures the validator module itself, rather than
diff --git a/src/test/modules/oauth_validator/validator.c b/src/test/modules/oauth_validator/validator.c
index ef9bbb2866f..e218f5c8902 100644
--- a/src/test/modules/oauth_validator/validator.c
+++ b/src/test/modules/oauth_validator/validator.c
@@ -23,12 +23,15 @@ PG_MODULE_MAGIC;
 
 static void validator_startup(ValidatorModuleState *state);
 static void validator_shutdown(ValidatorModuleState *state);
-static ValidatorModuleResult *validate_token(ValidatorModuleState *state,
-											 const char *token,
-											 const char *role);
+static bool validate_token(const ValidatorModuleState *state,
+						   const char *token,
+						   const char *role,
+						   ValidatorModuleResult *result);
 
 /* Callback implementations (exercise all three) */
 static const OAuthValidatorCallbacks validator_callbacks = {
+	PG_OAUTH_VALIDATOR_MAGIC,
+
 	.startup_cb = validator_startup,
 	.shutdown_cb = validator_shutdown,
 	.validate_cb = validate_token
@@ -89,6 +92,13 @@ _PG_oauth_validator_module_init(void)
 static void
 validator_startup(ValidatorModuleState *state)
 {
+	/*
+	 * Make sure the server is correctly setting sversion. (Real modules
+	 * should not do this; it would defeat upgrade compatibility.)
+	 */
+	if (state->sversion != PG_VERSION_NUM)
+		elog(ERROR, "oauth_validator: sversion set to %d", state->sversion);
+
 	state->private_data = PRIVATE_COOKIE;
 }
 
@@ -108,18 +118,16 @@ validator_shutdown(ValidatorModuleState *state)
  * Validator implementation. Logs the incoming data and authorizes the token by
  * default; the behavior can be modified via the module's GUC settings.
  */
-static ValidatorModuleResult *
-validate_token(ValidatorModuleState *state, const char *token, const char *role)
+static bool
+validate_token(const ValidatorModuleState *state,
+			   const char *token, const char *role,
+			   ValidatorModuleResult *res)
 {
-	ValidatorModuleResult *res;
-
 	/* Check to make sure our private state still exists. */
 	if (state->private_data != PRIVATE_COOKIE)
 		elog(ERROR, "oauth_validator: private state cookie changed to %p in validate",
 			 state->private_data);
 
-	res = palloc(sizeof(ValidatorModuleResult));
-
 	elog(LOG, "oauth_validator: token=\"%s\", role=\"%s\"", token, role);
 	elog(LOG, "oauth_validator: issuer=\"%s\", scope=\"%s\"",
 		 MyProcPort->hba->oauth_issuer,
@@ -131,5 +139,5 @@ validate_token(ValidatorModuleState *state, const char *token, const char *role)
 	else
 		res->authn_id = pstrdup(role);
 
-	return res;
+	return true;
 }
-- 
2.39.3 (Apple Git-146)



  [application/octet-stream] v49-0003-XXX-fix-libcurl-link-error.patch (1.2K, ../../[email protected]/4-v49-0003-XXX-fix-libcurl-link-error.patch)
  download | inline diff:
From 03742dd74dd3948c2d03b13009b08fbe09f928d8 Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Mon, 13 Jan 2025 12:31:59 -0800
Subject: [PATCH v49 3/4] XXX fix libcurl link error

The ftp/curl port appears to be missing a minimum version dependency on
libssh2, so the following starts showing up after upgrading to curl
8.11.1_1:

    libcurl.so.4: Undefined symbol "libssh2_session_callback_set2"

But 13.3 is EOL, so it's not clear if anyone would be interested in a
bug report, and a FreeBSD 14 Cirrus image is in progress. Hack past it
for now.
---
 .cirrus.tasks.yml | 1 +
 1 file changed, 1 insertion(+)

diff --git a/.cirrus.tasks.yml b/.cirrus.tasks.yml
index 2f5f5ef21a8..91b51142d2e 100644
--- a/.cirrus.tasks.yml
+++ b/.cirrus.tasks.yml
@@ -168,6 +168,7 @@ task:
     sysctl kern.corefile='/tmp/cores/%N.%P.core'
   setup_additional_packages_script: |
     pkg install -y curl
+    pkg upgrade -y libssh2 # XXX shouldn't be necessary. revisit w/ FreeBSD 14
 
   # NB: Intentionally build without -Dllvm. The freebsd image size is already
   # large enough to make VM startup slow, and even without llvm freebsd
-- 
2.39.3 (Apple Git-146)



  [application/octet-stream] v49-0004-v49-Fixups-proposed-by-Daniel.patch (37.9K, ../../[email protected]/5-v49-0004-v49-Fixups-proposed-by-Daniel.patch)
  download | inline diff:
From eaa6078e9fe70940c359cca0883227746456fc94 Mon Sep 17 00:00:00 2001
From: Daniel Gustafsson <[email protected]>
Date: Thu, 13 Feb 2025 23:45:50 +0100
Subject: [PATCH v49 4/4] v49 Fixups proposed by Daniel

---
 config/programs.m4                            |  4 +-
 doc/src/sgml/client-auth.sgml                 |  8 +-
 doc/src/sgml/installation.sgml                | 10 +--
 doc/src/sgml/libpq.sgml                       | 25 +++---
 doc/src/sgml/oauth-validators.sgml            | 27 ++++---
 meson.build                                   |  8 +-
 src/backend/libpq/auth-oauth.c                | 79 +++++++++++++------
 src/include/common/oauth-common.h             |  2 +-
 src/include/libpq/oauth.h                     |  7 +-
 src/include/pg_config.h.in                    |  2 +-
 src/interfaces/libpq/fe-auth-oauth-curl.c     | 18 +++--
 src/interfaces/libpq/fe-auth-oauth.c          |  4 +-
 src/test/modules/oauth_validator/Makefile     |  2 +-
 src/test/modules/oauth_validator/README       |  6 +-
 .../modules/oauth_validator/fail_validator.c  |  6 +-
 .../modules/oauth_validator/magic_validator.c | 48 +++++++++++
 src/test/modules/oauth_validator/meson.build  | 18 ++++-
 .../oauth_validator/oauth_hook_client.c       |  2 +-
 .../modules/oauth_validator/t/001_server.pl   | 31 ++++++--
 .../modules/oauth_validator/t/002_client.pl   |  2 +-
 .../modules/oauth_validator/t/OAuth/Server.pm |  4 +-
 src/test/modules/oauth_validator/validator.c  |  2 +-
 22 files changed, 220 insertions(+), 95 deletions(-)
 create mode 100644 src/test/modules/oauth_validator/magic_validator.c

diff --git a/config/programs.m4 b/config/programs.m4
index ead427046f5..061b13376ac 100644
--- a/config/programs.m4
+++ b/config/programs.m4
@@ -280,7 +280,7 @@ AC_DEFUN([PGAC_CHECK_STRIP],
 # PGAC_CHECK_LIBCURL
 # ------------------
 # Check for required libraries and headers, and test to see whether the current
-# installation of libcurl is threadsafe.
+# installation of libcurl is thread-safe.
 
 AC_DEFUN([PGAC_CHECK_LIBCURL],
 [
@@ -313,7 +313,7 @@ AC_DEFUN([PGAC_CHECK_LIBCURL],
   [pgac_cv__libcurl_threadsafe_init=unknown])])
   if test x"$pgac_cv__libcurl_threadsafe_init" = xyes ; then
     AC_DEFINE(HAVE_THREADSAFE_CURL_GLOBAL_INIT, 1,
-              [Define to 1 if curl_global_init() is guaranteed to be threadsafe.])
+              [Define to 1 if curl_global_init() is guaranteed to be thread-safe.])
   fi
 
   # Warn if a thread-friendly DNS resolver isn't built.
diff --git a/doc/src/sgml/client-auth.sgml b/doc/src/sgml/client-auth.sgml
index f84085dbac4..6fc0da57f1b 100644
--- a/doc/src/sgml/client-auth.sgml
+++ b/doc/src/sgml/client-auth.sgml
@@ -2397,7 +2397,7 @@ host ... radius radiusservers="server1,server2" radiussecrets="""secret one"",""
       <term>Resource Server</term>
       <listitem>
        <para>
-        The system which hosts the protected resources which are
+        The system hosting the protected resources which are
         accessed by the client. The <productname>PostgreSQL</productname>
         cluster being connected to is the resource server.
        </para>
@@ -2409,7 +2409,7 @@ host ... radius radiusservers="server1,server2" radiussecrets="""secret one"",""
       <listitem>
        <para>
         The organization, product vendor, or other entity which develops and/or
-        administers the OAuth servers and clients for a given application.
+        administers the OAuth resource servers and clients for a given application.
         Different providers typically choose different implementation details
         for their OAuth systems; a client of one provider is not generally
         guaranteed to have access to the servers of another.
@@ -2432,7 +2432,7 @@ host ... radius radiusservers="server1,server2" radiussecrets="""secret one"",""
         The system which receives requests from, and issues access tokens to,
         the client after the authenticated resource owner has given approval.
         <productname>PostgreSQL</productname> does not provide an authorization
-        server; it's obtained from the OAuth provider.
+        server; it is the responsibility of the OAuth provider.
        </para>
       </listitem>
      </varlistentry>
@@ -2500,7 +2500,7 @@ host ... radius radiusservers="server1,server2" radiussecrets="""secret one"",""
          exactly match the issuer identifier which is provided in the discovery
          document, which must in turn match the client's
          <xref linkend="libpq-connect-oauth-issuer"/> setting. No variations in
-         case or format are permitted.
+         case or formatting are permitted.
         </para>
        </warning>
       </listitem>
diff --git a/doc/src/sgml/installation.sgml b/doc/src/sgml/installation.sgml
index 96e433179b9..3c95c15a1e4 100644
--- a/doc/src/sgml/installation.sgml
+++ b/doc/src/sgml/installation.sgml
@@ -1148,8 +1148,8 @@ build-postgresql:
        <listitem>
         <para>
          Build with libcurl support for OAuth 2.0 client flows.
-         This requires the <productname>curl</productname> package to be
-         installed.  Building with this will check for the required header files
+         Libcurl version 7.61.0 or later is required for this feature.
+         Building with this will check for the required header files
          and libraries to make sure that your <productname>curl</productname>
          installation is sufficient before proceeding.
         </para>
@@ -2602,9 +2602,9 @@ ninja install
       <listitem>
        <para>
         Build with libcurl support for OAuth 2.0 client flows.
-        This requires the <productname>curl</productname> package to be
-        installed.  Building with this will check for the required header files
-        and libraries to make sure that your <productname>curl</productname>
+        Libcurl version 7.61.0 or later is required for this feature.
+        Building with this will check for the required header files
+        and libraries to make sure that your <productname>Curl</productname>
         installation is sufficient before proceeding. The default for this
         option is auto.
        </para>
diff --git a/doc/src/sgml/libpq.sgml b/doc/src/sgml/libpq.sgml
index b2abae8deee..ca84226755d 100644
--- a/doc/src/sgml/libpq.sgml
+++ b/doc/src/sgml/libpq.sgml
@@ -2390,7 +2390,7 @@ postgresql://%2Fvar%2Flib%2Fpostgresql/dbname
         The HTTPS URL of a trusted issuer to contact if the server requests an
         OAuth token for the connection. This parameter is required for all OAuth
         connections; it should exactly match the <literal>issuer</literal>
-        setting in <link linkend="auth-oauth">the server's HBA configuration.</link>
+        setting in <link linkend="auth-oauth">the server's HBA configuration</link>.
        </para>
        <para>
         As part of the standard authentication handshake, <application>libpq</application>
@@ -2399,8 +2399,9 @@ postgresql://%2Fvar%2Flib%2Fpostgresql/dbname
         provide a URL that is directly constructed from the components of the
         <literal>oauth_issuer</literal>, and this value must exactly match the
         issuer identifier that is declared in the discovery document itself, or
-        the connection will fail. This is required to prevent a class of "mix-up
-        attacks" on OAuth clients.
+        the connection will fail. This is required to prevent a class of
+        <ulink url="https://mailarchive.ietf.org/arch/msg/oauth/JIVxFBGsJBVtm7ljwJhPUm3Fr-w/">
+        "mix-up attacks"</ulink> on OAuth clients.
        </para>
        <para>
         You may also explicitly set <literal>oauth_issuer</literal> to the
@@ -10140,7 +10141,7 @@ void PQinitSSL(int do_ssl);
    <link linkend="auth-oauth">requests a bearer token</link> during
    authentication. This flow can be utilized even if the system running the
    client application does not have a usable web browser, for example when
-   running a client via SSH. Client applications may implement their own flows
+   running a client via <application>SSH</application>. Client applications may implement their own flows
    instead; see <xref linkend="libpq-oauth-authdata-hooks"/>.
   </para>
   <para>
@@ -10152,11 +10153,11 @@ Visit https://example.com/device and enter the code: ABCD-EFGH
 </programlisting>
    (This prompt may be
    <link linkend="libpq-oauth-authdata-prompt-oauth-device">customized</link>.)
-   You will then log into your OAuth provider, which will ask whether you want
-   to allow libpq and the server to perform actions on your behalf. It is always
+   The user will then log into their OAuth provider, which will ask whether
+   to allow libpq and the server to perform actions on their behalf. It is always
    a good idea to carefully review the URL and permissions displayed, to ensure
-   they match your expectations, before continuing. Do not give permissions to
-   untrusted third parties.
+   they match expectations, before continuing. Permissions should not be given
+   to untrusted third parties.
   </para>
   <para>
    For an OAuth client flow to be usable, the connection string must at minimum
@@ -10199,7 +10200,7 @@ void PQsetAuthDataHook(PQauthDataHook_type hook);
 <programlisting>
 int hook_fn(PGauthData type, PGconn *conn, void *data);
 </programlisting>
-        which <application>libpq</application> will call when when action is
+        which <application>libpq</application> will call when an action is
         required of the application. <replaceable>type</replaceable> describes
         the request being made, <replaceable>conn</replaceable> is the
         connection handle being authenticated, and <replaceable>data</replaceable>
@@ -10431,7 +10432,7 @@ typedef struct _PGoauthBearerRequest
      </listitem>
      <listitem>
       <para>
-       sprays HTTP traffic (containing several critical secrets) to standard
+       prints HTTP traffic (containing several critical secrets) to standard
        error during the OAuth flow
       </para>
      </listitem>
@@ -10526,13 +10527,13 @@ int PQisthreadsafe();
   </para>
 
   <para>
-   Similarly, if you are using Curl inside your application,
+   Similarly, if you are using <productname></productname>Curl</productname> inside your application,
    <emphasis>and</emphasis> you do not already
    <ulink url="https://curl.se/libcurl/c/curl_global_init.html">initialize
    libcurl globally</ulink> before starting new threads, you will need to
    cooperatively lock (again via <function>PQregisterThreadLock</function>)
    around any code that may initialize libcurl. This restriction is lifted for
-   more recent versions of Curl that are built to support threadsafe
+   more recent versions of <productname>Curl</productname> that are built to support thread-safe
    initialization; those builds can be identified by the advertisement of a
    <literal>threadsafe</literal> feature in their version metadata.
   </para>
diff --git a/doc/src/sgml/oauth-validators.sgml b/doc/src/sgml/oauth-validators.sgml
index eb8c4431c2d..e9d28d3daea 100644
--- a/doc/src/sgml/oauth-validators.sgml
+++ b/doc/src/sgml/oauth-validators.sgml
@@ -10,8 +10,8 @@
   custom modules to perform server-side validation of OAuth bearer tokens.
   Because OAuth implementations vary so wildly, and bearer token validation is
   heavily dependent on the issuing party, the server cannot check the token
-  itself; validator modules provide the glue between the server and the OAuth
-  provider in use.
+  itself; validator modules provide the integration layer between the server
+  and the OAuth provider in use.
  </para>
  <para>
   OAuth validator modules must at least consist of an initialization function
@@ -21,7 +21,7 @@
  <warning>
   <para>
    Since a misbehaving validator might let unauthorized users into the database,
-   correct implementation is critical. See
+   validating the correctness of the implementation is critical. See
    <xref linkend="oauth-validator-design"/> for design considerations.
   </para>
  </warning>
@@ -290,8 +290,9 @@
    <primary>_PG_oauth_validator_module_init</primary>
   </indexterm>
   <para>
-   An OAuth validator module is loaded by dynamically loading one of the shared
+   OAuth validator modules are dynamically loaded from the shared
    libraries listed in <xref linkend="guc-oauth-validator-libraries"/>.
+   Modules are loaded on demand when requested from a login in progress.
    The normal library search path is used to locate the library. To
    provide the validator callbacks and to indicate that the library is an OAuth
    validator module a function named
@@ -356,7 +357,7 @@ typedef bool (*ValidatorValidateCB) (const ValidatorModuleState *state,
 </programlisting>
 
     <replaceable>token</replaceable> will contain the bearer token to validate.
-    The server has ensured that the token is well-formed syntactically, but no
+    <application>libpq</application> has ensured that the token is well-formed syntactically, but no
     other validation has been performed.  <replaceable>role</replaceable> will
     contain the role the user has requested to log in as.  The callback must
     set output parameters in the <literal>result</literal> struct, which is
@@ -371,10 +372,10 @@ typedef struct ValidatorModuleResult
 </programlisting>
 
     The connection will only proceed if the module sets
-    <structfield>authorized</structfield> to <literal>true</literal>.  To
+    <structfield>result->authorized</structfield> to <literal>true</literal>.  To
     authenticate the user, the authenticated user name (as determined using the
-    token) shall be palloc'd and returned in the <structfield>authn_id</structfield>
-    field.  Alternatively, <structfield>authn_id</structfield> may be set to
+    token) shall be palloc'd and returned in the <structfield>result->authn_id</structfield>
+    field.  Alternatively, <structfield>result->authn_id</structfield> may be set to
     NULL if the token is valid but the associated user identity cannot be
     determined.
    </para>
@@ -386,12 +387,12 @@ typedef struct ValidatorModuleResult
    </para>
    <para>
     The behavior after <function>validate_cb</function> returns depends on the
-    specific HBA setup.  Normally, the <structfield>authn_id</structfield> user
+    specific HBA setup.  Normally, the <structfield>result->authn_id</structfield> user
     name must exactly match the role that the user is logging in as.  (This
     behavior may be modified with a usermap.)  But when authenticating against
-    an HBA rule with <literal>delegate_ident_mapping</literal> turned on, the
-    server will not perform any checks on the value of
-    <structfield>authn_id</structfield> at all; in this case it is up to the
+    an HBA rule with <literal>delegate_ident_mapping</literal> turned on,
+    <productname>PostgreSQL</productname> will not perform any checks on the value of
+    <structfield>result->authn_id</structfield> at all; in this case it is up to the
     validator to ensure that the token carries enough privileges for the user to
     log in under the indicated <replaceable>role</replaceable>.
    </para>
@@ -402,7 +403,7 @@ typedef struct ValidatorModuleResult
    <para>
     The <function>shutdown_cb</function> callback is executed when the backend
     process associated with the connection exits. If the validator module has
-    any state, this callback should free it to avoid resource leaks.
+    any allocated state, this callback should free it to avoid resource leaks.
 <programlisting>
 typedef void (*ValidatorShutdownCB) (ValidatorModuleState *state);
 </programlisting>
diff --git a/meson.build b/meson.build
index 5c35159b4f1..574f992ed49 100644
--- a/meson.build
+++ b/meson.build
@@ -867,7 +867,7 @@ if not libcurlopt.disabled()
   if libcurl.found()
     cdata.set('USE_LIBCURL', 1)
 
-    # Check to see whether the current platform supports threadsafe Curl
+    # Check to see whether the current platform supports thread-safe Curl
     # initialization.
     libcurl_threadsafe_init = false
 
@@ -897,11 +897,11 @@ if not libcurlopt.disabled()
       assert(r.compiled())
       if r.returncode() == 0
         libcurl_threadsafe_init = true
-        message('curl_global_init is threadsafe')
+        message('curl_global_init is thread-safe')
       elif r.returncode() == 1
-        message('curl_global_init is not threadsafe')
+        message('curl_global_init is not thread-safe')
       else
-        message('curl_global_init failed; assuming not threadsafe')
+        message('curl_global_init failed; assuming not thread-safe')
       endif
     endif
 
diff --git a/src/backend/libpq/auth-oauth.c b/src/backend/libpq/auth-oauth.c
index e2b5d1ed913..db56cd8c200 100644
--- a/src/backend/libpq/auth-oauth.c
+++ b/src/backend/libpq/auth-oauth.c
@@ -4,9 +4,9 @@
  *	  Server-side implementation of the SASL OAUTHBEARER mechanism.
  *
  * See the following RFC for more details:
- * - RFC 7628: https://tools.ietf.org/html/rfc7628
+ * - RFC 7628: https://datatracker.ietf.org/doc/html/rfc7628
  *
- * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
  * Portions Copyright (c) 1994, Regents of the University of California
  *
  * src/backend/libpq/auth-oauth.c
@@ -70,7 +70,7 @@ struct oauth_ctx
 	const char *scope;
 };
 
-static char *sanitize_char(char c);
+static void sanitize_char(char c, char *buf, size_t buflen);
 static char *parse_kvpairs_for_auth(char **input);
 static void generate_error_response(struct oauth_ctx *ctx, char **output, int *outputlen);
 static bool validate(Port *port, const char *auth);
@@ -139,6 +139,7 @@ oauth_exchange(void *opaq, const char *input, int inputlen,
 	char		cbind_flag;
 	char	   *auth;
 	int			status;
+	char		errmsgbuf[5];
 
 	struct oauth_ctx *ctx = opaq;
 
@@ -162,6 +163,7 @@ oauth_exchange(void *opaq, const char *input, int inputlen,
 
 	/*
 	 * Check that the input length agrees with the string length of the input.
+	 * Possible reasons for discrepancies include embedded nulls in the string.
 	 */
 	if (inputlen == 0)
 		ereport(ERROR,
@@ -223,22 +225,29 @@ oauth_exchange(void *opaq, const char *input, int inputlen,
 
 		case 'y':				/* fall through */
 		case 'n':
-			p++;
+			if (!*(++p))
+				goto endofmessage;
+
 			if (*p != ',')
+			{
+				sanitize_char(*p, errmsgbuf, sizeof(errmsgbuf));
 				ereport(ERROR,
 						errcode(ERRCODE_PROTOCOL_VIOLATION),
 						errmsg("malformed OAUTHBEARER message"),
 						errdetail("Comma expected, but found character \"%s\".",
-								  sanitize_char(*p)));
-			p++;
+								  errmsgbuf));
+			}
+			if (!*(++p))
+				goto endofmessage;
 			break;
 
 		default:
+			sanitize_char(*p, errmsgbuf, sizeof(errmsgbuf));
 			ereport(ERROR,
 					errcode(ERRCODE_PROTOCOL_VIOLATION),
 					errmsg("malformed OAUTHBEARER message"),
 					errdetail("Unexpected channel-binding flag \"%s\".",
-							  sanitize_char(cbind_flag)));
+							  errmsgbuf));
 	}
 
 	/*
@@ -249,21 +258,29 @@ oauth_exchange(void *opaq, const char *input, int inputlen,
 				errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				errmsg("client uses authorization identity, but it is not supported"));
 	if (*p != ',')
+	{
+		sanitize_char(*p, errmsgbuf, sizeof(errmsgbuf));
 		ereport(ERROR,
 				errcode(ERRCODE_PROTOCOL_VIOLATION),
 				errmsg("malformed OAUTHBEARER message"),
 				errdetail("Unexpected attribute \"%s\" in client-first-message.",
-						  sanitize_char(*p)));
-	p++;
+						  errmsgbuf));
+	}
+	if (!*(++p))
+		goto endofmessage;
 
 	/* All remaining fields are separated by the RFC's kvsep (\x01). */
 	if (*p != KVSEP)
+	{
+		sanitize_char(*p, errmsgbuf, sizeof(errmsgbuf));
 		ereport(ERROR,
 				errcode(ERRCODE_PROTOCOL_VIOLATION),
 				errmsg("malformed OAUTHBEARER message"),
 				errdetail("Key-value separator expected, but found character \"%s\".",
-						  sanitize_char(*p)));
-	p++;
+						  errmsgbuf));
+	}
+	if (!*(++p))
+		goto endofmessage;
 
 	auth = parse_kvpairs_for_auth(&p);
 	if (!auth)
@@ -296,6 +313,13 @@ oauth_exchange(void *opaq, const char *input, int inputlen,
 	explicit_bzero(input_copy, inputlen);
 
 	return status;
+
+endofmessage:
+	explicit_bzero(input_copy, inputlen);
+	ereport(ERROR,
+			errcode(ERRCODE_PROTOCOL_VIOLATION),
+			errmsg("malformed OAUTHBEARER message"));
+	pg_unreachable();
 }
 
 /*
@@ -303,19 +327,14 @@ oauth_exchange(void *opaq, const char *input, int inputlen,
  *
  * If it's a printable ASCII character, print it as a single character.
  * otherwise, print it in hex.
- *
- * The returned pointer points to a static buffer.
  */
-static char *
-sanitize_char(char c)
+static void
+sanitize_char(char c, char *buf, size_t buflen)
 {
-	static char buf[5];
-
 	if (c >= 0x21 && c <= 0x7E)
-		snprintf(buf, sizeof(buf), "'%c'", c);
+		snprintf(buf, buflen, "'%c'", c);
 	else
-		snprintf(buf, sizeof(buf), "0x%02x", (unsigned char) c);
-	return buf;
+		snprintf(buf, buflen, "0x%02x", (unsigned char) c);
 }
 
 /*
@@ -660,7 +679,9 @@ validate(Port *port, const char *auth)
 	if (!ValidatorCallbacks->validate_cb(validator_module_state, token,
 										 port->user_name, ret))
 	{
-		ereport(LOG, errmsg("internal error in OAuth validator module"));
+		ereport(WARNING,
+				errcode(ERRCODE_INTERNAL_ERROR),
+				errmsg("internal error in OAuth validator module"));
 		return false;
 	}
 
@@ -738,6 +759,11 @@ load_validator_library(const char *libname)
 	OAuthValidatorModuleInit validator_init;
 	MemoryContextCallback *mcb;
 
+	/*
+	 * Thre presence, and validity, of libname has already been established by
+	 * check_oauth_validator so we don't need to perform more than Assert level
+	 * checking here.
+	 */
 	Assert(libname && *libname);
 
 	validator_init = (OAuthValidatorModuleInit)
@@ -768,6 +794,15 @@ load_validator_library(const char *libname)
 				errdetail("Server has magic number 0x%08X, module has 0x%08X.",
 						  PG_OAUTH_VALIDATOR_MAGIC, ValidatorCallbacks->magic));
 
+	/*
+	 * Make sure all required callbacks are present in the ValidatorCallbacks
+	 * structure. Right now only the validation callback is required.
+	 */
+	if (ValidatorCallbacks->validate_cb == NULL)
+		ereport(ERROR,
+				errmsg("%s module \"%s\" must define the symbol %s",
+					   "OAuth validator", libname, "validate_cb"));
+
 	/* Allocate memory for validator library private state data */
 	validator_module_state = (ValidatorModuleState *) palloc0(sizeof(ValidatorModuleState));
 	validator_module_state->sversion = PG_VERSION_NUM;
@@ -804,7 +839,7 @@ bool
 check_oauth_validator(HbaLine *hbaline, int elevel, char **err_msg)
 {
 	int			line_num = hbaline->linenumber;
-	char	   *file_name = hbaline->sourcefile;
+	const char *file_name = hbaline->sourcefile;
 	char	   *rawstring;
 	List	   *elemlist = NIL;
 
diff --git a/src/include/common/oauth-common.h b/src/include/common/oauth-common.h
index 8fe56267780..5fb559d84b2 100644
--- a/src/include/common/oauth-common.h
+++ b/src/include/common/oauth-common.h
@@ -3,7 +3,7 @@
  * oauth-common.h
  *		Declarations for helper functions used for OAuth/OIDC authentication
  *
- * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
  * Portions Copyright (c) 1994, Regents of the University of California
  *
  * src/include/common/oauth-common.h
diff --git a/src/include/libpq/oauth.h b/src/include/libpq/oauth.h
index 7e249613e10..2f01b669633 100644
--- a/src/include/libpq/oauth.h
+++ b/src/include/libpq/oauth.h
@@ -3,7 +3,7 @@
  * oauth.h
  *	  Interface to libpq/auth-oauth.c
  *
- * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
  * Portions Copyright (c) 1994, Regents of the University of California
  *
  * src/include/libpq/oauth.h
@@ -83,8 +83,9 @@ typedef struct OAuthValidatorCallbacks
 } OAuthValidatorCallbacks;
 
 /*
- * Type of the shared library symbol _PG_oauth_validator_module_init that is
- * looked up when loading a validator module.
+ * Type of the shared library symbol _PG_oauth_validator_module_init which is
+ * required for all validator modules.  This function will be invoked during
+ * module loading.
  */
 typedef const OAuthValidatorCallbacks *(*OAuthValidatorModuleInit) (void);
 extern PGDLLEXPORT const OAuthValidatorCallbacks *_PG_oauth_validator_module_init(void);
diff --git a/src/include/pg_config.h.in b/src/include/pg_config.h.in
index c04ee38d086..db6454090d2 100644
--- a/src/include/pg_config.h.in
+++ b/src/include/pg_config.h.in
@@ -445,7 +445,7 @@
 /* Define to 1 if you have the <termios.h> header file. */
 #undef HAVE_TERMIOS_H
 
-/* Define to 1 if curl_global_init() is guaranteed to be threadsafe. */
+/* Define to 1 if curl_global_init() is guaranteed to be thread-safe. */
 #undef HAVE_THREADSAFE_CURL_GLOBAL_INIT
 
 /* Define to 1 if your compiler understands `typeof' or something similar. */
diff --git a/src/interfaces/libpq/fe-auth-oauth-curl.c b/src/interfaces/libpq/fe-auth-oauth-curl.c
index 74323de309a..c9aa51b1007 100644
--- a/src/interfaces/libpq/fe-auth-oauth-curl.c
+++ b/src/interfaces/libpq/fe-auth-oauth-curl.c
@@ -4,7 +4,7 @@
  *	   The libcurl implementation of OAuth/OIDC authentication, using the
  *	   OAuth Device Authorization Grant (RFC 8628).
  *
- * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
  * Portions Copyright (c) 1994, Regents of the University of California
  *
  * IDENTIFICATION
@@ -917,7 +917,7 @@ parse_interval(struct async_ctx *actx, const char *interval_str)
 	if (parsed < 1)
 		return actx->debugging ? 0 : 1;
 
-	else if (INT_MAX <= parsed)
+	else if (parsed >= INT_MAX)
 		return INT_MAX;
 
 	return parsed;
@@ -940,7 +940,7 @@ parse_expires_in(struct async_ctx *actx, const char *expires_in_str)
 	parsed = parse_json_number(expires_in_str);
 	parsed = round(parsed);
 
-	if (INT_MAX <= parsed)
+	if (parsed >= INT_MAX)
 		return INT_MAX;
 	else if (parsed <= INT_MIN)
 		return INT_MIN;
@@ -966,6 +966,10 @@ parse_device_authz(struct async_ctx *actx, struct device_authz *authz)
 		 */
 		{"verification_url", JSON_TOKEN_STRING, {&authz->verification_uri}, REQUIRED},
 
+		/*
+		 * There is no evidence of verification_uri_complete being spelled
+		 * with "url" instead with any service provider, so only support "uri".
+		 */
 		{"verification_uri_complete", JSON_TOKEN_STRING, {&authz->verification_uri_complete}, OPTIONAL},
 		{"interval", JSON_TOKEN_NUMBER, {&authz->interval_str}, OPTIONAL},
 
@@ -1870,6 +1874,7 @@ append_urlencoded(PQExpBuffer buf, const char *s)
 	char	   *haystack;
 	char	   *match;
 
+	/* The first parameter to curl_easy_escape is deprecated by Curl */
 	escaped = curl_easy_escape(NULL, s, 0);
 	if (!escaped)
 	{
@@ -2273,6 +2278,7 @@ finish_device_authz(struct async_ctx *actx)
 			return false;
 		}
 
+		/* Copy the token error into the context error buffer */
 		record_token_error(actx, &err);
 
 		free_token_error(&err);
@@ -2541,7 +2547,7 @@ initialize_curl(PGconn *conn)
 
 	/*
 	 * If we determined at configure time that the Curl installation is
-	 * threadsafe, our job here is much easier. We simply initialize above
+	 * thread-safe, our job here is much easier. We simply initialize above
 	 * without any locking (concurrent or duplicated calls are fine in that
 	 * situation), then double-check to make sure the runtime setting agrees,
 	 * to try to catch silent downgrades.
@@ -2553,8 +2559,8 @@ 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 threadsafe\n"
-								"\tCurl initialization was reported threadsafe when libpq\n"
+		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.");
diff --git a/src/interfaces/libpq/fe-auth-oauth.c b/src/interfaces/libpq/fe-auth-oauth.c
index 8beae9604c7..24448c3e209 100644
--- a/src/interfaces/libpq/fe-auth-oauth.c
+++ b/src/interfaces/libpq/fe-auth-oauth.c
@@ -4,7 +4,7 @@
  *	   The front-end (client) implementation of OAuth/OIDC authentication
  *	   using the SASL OAUTHBEARER mechanism.
  *
- * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
  * Portions Copyright (c) 1994, Regents of the University of California
  *
  * IDENTIFICATION
@@ -272,7 +272,7 @@ oauth_json_scalar(void *state, char *token, JsonTokenType type)
 			 * Assert and don't continue any further for production builds.
 			 */
 			Assert(false);
-			oauth_json_set_error(ctx,	/* don't bother translating */
+			oauth_json_set_error(ctx,
 								 "internal error: target scalar found at nesting level %d during OAUTHBEARER parsing",
 								 ctx->nested);
 			return JSON_SEM_ACTION_FAILED;
diff --git a/src/test/modules/oauth_validator/Makefile b/src/test/modules/oauth_validator/Makefile
index f297ed5c968..bbd2a98023b 100644
--- a/src/test/modules/oauth_validator/Makefile
+++ b/src/test/modules/oauth_validator/Makefile
@@ -2,7 +2,7 @@
 #
 # Makefile for src/test/modules/oauth_validator
 #
-# Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+# Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
 # Portions Copyright (c) 1994, Regents of the University of California
 #
 # src/test/modules/oauth_validator/Makefile
diff --git a/src/test/modules/oauth_validator/README b/src/test/modules/oauth_validator/README
index 138a8104622..54eac5b117e 100644
--- a/src/test/modules/oauth_validator/README
+++ b/src/test/modules/oauth_validator/README
@@ -8,6 +8,6 @@ by t/OAuth/Server.pm and t/oauth_server.py, to run the libpq Device
 Authorization flow. The tests in t/002_client exercise custom OAuth flows and
 don't need an authorization server.
 
-Tests in this folder generally require 'oauth' to be present in PG_TEST_EXTRA,
-since localhost HTTP servers will be started. A Python installation is required
-to run the mock authorization server.
+Tests in this folder require 'oauth' to be present in PG_TEST_EXTRA, since
+HTTPS servers listening on localhost with TCP/IP sockets will be started. A
+Python installation is required to run the mock authorization server.
diff --git a/src/test/modules/oauth_validator/fail_validator.c b/src/test/modules/oauth_validator/fail_validator.c
index 7b1e69518d9..a4c7a4451d3 100644
--- a/src/test/modules/oauth_validator/fail_validator.c
+++ b/src/test/modules/oauth_validator/fail_validator.c
@@ -1,10 +1,10 @@
 /*-------------------------------------------------------------------------
  *
  * fail_validator.c
- *	  Test module for serverside OAuth token validation callbacks, which always
- *	  fails
+ *	  Test module for serverside OAuth token validation callbacks, which is
+ *	  guaranteed to always fail in the validation callback
  *
- * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
  * Portions Copyright (c) 1994, Regents of the University of California
  *
  * src/test/modules/oauth_validator/fail_validator.c
diff --git a/src/test/modules/oauth_validator/magic_validator.c b/src/test/modules/oauth_validator/magic_validator.c
new file mode 100644
index 00000000000..5ce68cdf405
--- /dev/null
+++ b/src/test/modules/oauth_validator/magic_validator.c
@@ -0,0 +1,48 @@
+/*-------------------------------------------------------------------------
+ *
+ * magic_validator.c
+ *	  Test module for serverside OAuth token validation callbacks, which
+ *	  should fail due to using the wrong PG_OAUTH_VALIDATOR_MAGIC marker
+ *	  and thus the wrong ABI version
+ *
+ * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/test/modules/oauth_validator/fail_validator.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "fmgr.h"
+#include "libpq/oauth.h"
+
+PG_MODULE_MAGIC;
+
+static bool validate_token(const ValidatorModuleState *state,
+						   const char *token,
+						   const char *role,
+						   ValidatorModuleResult *result);
+
+/* Callback implementations (we only need the main one) */
+static const OAuthValidatorCallbacks validator_callbacks = {
+	0xdeadbeef,
+
+	.validate_cb = validate_token,
+};
+
+const OAuthValidatorCallbacks *
+_PG_oauth_validator_module_init(void)
+{
+	return &validator_callbacks;
+}
+
+static bool
+validate_token(const ValidatorModuleState *state,
+			   const char *token, const char *role,
+			   ValidatorModuleResult *res)
+{
+	elog(FATAL, "magic_validator: this should be unreachable");
+	pg_unreachable();
+}
diff --git a/src/test/modules/oauth_validator/meson.build b/src/test/modules/oauth_validator/meson.build
index 4b78c90557c..36d1b26369f 100644
--- a/src/test/modules/oauth_validator/meson.build
+++ b/src/test/modules/oauth_validator/meson.build
@@ -1,4 +1,4 @@
-# Copyright (c) 2024, PostgreSQL Global Development Group
+# Copyright (c) 2025, PostgreSQL Global Development Group
 
 validator_sources = files(
   'validator.c',
@@ -32,6 +32,22 @@ fail_validator = shared_module('fail_validator',
 )
 test_install_libs += fail_validator
 
+magic_validator_sources = files(
+  'magic_validator.c',
+)
+
+if host_system == 'windows'
+  magic_validator_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'magic_validator',
+    '--FILEDESC', 'magic_validator - ABI incompatible OAuth validator module',])
+endif
+
+magic_validator = shared_module('magic_validator',
+  magic_validator_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += magic_validator
+
 oauth_hook_client_sources = files(
   'oauth_hook_client.c',
 )
diff --git a/src/test/modules/oauth_validator/oauth_hook_client.c b/src/test/modules/oauth_validator/oauth_hook_client.c
index fc003030ff8..9f553792c05 100644
--- a/src/test/modules/oauth_validator/oauth_hook_client.c
+++ b/src/test/modules/oauth_validator/oauth_hook_client.c
@@ -4,7 +4,7 @@
  *		Test driver for t/002_client.pl, which verifies OAuth hook
  *		functionality in libpq.
  *
- * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
  * Portions Copyright (c) 1994, Regents of the University of California
  *
  *
diff --git a/src/test/modules/oauth_validator/t/001_server.pl b/src/test/modules/oauth_validator/t/001_server.pl
index d2dda62a2d4..dada89e95cc 100644
--- a/src/test/modules/oauth_validator/t/001_server.pl
+++ b/src/test/modules/oauth_validator/t/001_server.pl
@@ -3,7 +3,7 @@
 # Tests the libpq builtin OAuth flow, as well as server-side HBA and validator
 # setup.
 #
-# Copyright (c) 2021-2024, PostgreSQL Global Development Group
+# Copyright (c) 2021-2025, PostgreSQL Global Development Group
 #
 
 use strict;
@@ -14,24 +14,23 @@ use MIME::Base64 qw(encode_base64);
 use PostgreSQL::Test::Cluster;
 use PostgreSQL::Test::Utils;
 use Test::More;
-use Config;
 
 use FindBin;
 use lib $FindBin::RealBin;
 
 use OAuth::Server;
 
-if ($Config{osname} eq 'MSWin32')
-{
-	plan skip_all => 'OAuth server-side tests are not supported on Windows';
-}
-
 if (!$ENV{PG_TEST_EXTRA} || $ENV{PG_TEST_EXTRA} !~ /\boauth\b/)
 {
 	plan skip_all =>
 	  'Potentially unsafe test oauth not enabled in PG_TEST_EXTRA';
 }
 
+if ($windows_os)
+{
+	plan skip_all => 'OAuth server-side tests are not supported on Windows';
+}
+
 if ($ENV{with_libcurl} ne 'yes')
 {
 	plan skip_all => 'client-side OAuth not supported by this build';
@@ -570,6 +569,24 @@ $node->connect_fails(
 	"fail_validator is used for $user",
 	expected_stderr => qr/FATAL:\s+fail_validator: sentinel error/);
 
+#
+# Test ABI compatability magic marker
+#
+$node->append_conf('postgresql.conf',
+	"oauth_validator_libraries = 'magic_validator'\n");
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test    oauth validator=magic_validator      issuer="$issuer"           scope="openid postgres"
+});
+$node->restart;
+
+$log_start = $node->wait_for_log(qr/ready to accept connections/, $log_start);
+
+$node->connect_fails(
+	"user=test dbname=postgres oauth_issuer=$issuer/.well-known/oauth-authorization-server/alternate oauth_client_id=f02c6361-0636",
+	"magic_validator is used for $user",
+	expected_stderr => qr/FATAL:\s+OAuth validator module "magic_validator": magic number mismatch/);
 $node->stop;
 
 done_testing();
diff --git a/src/test/modules/oauth_validator/t/002_client.pl b/src/test/modules/oauth_validator/t/002_client.pl
index 95cccf90dd8..ab83258d736 100644
--- a/src/test/modules/oauth_validator/t/002_client.pl
+++ b/src/test/modules/oauth_validator/t/002_client.pl
@@ -2,7 +2,7 @@
 # Exercises the API for custom OAuth client flows, using the oauth_hook_client
 # test driver.
 #
-# Copyright (c) 2021-2024, PostgreSQL Global Development Group
+# Copyright (c) 2021-2025, PostgreSQL Global Development Group
 #
 
 use strict;
diff --git a/src/test/modules/oauth_validator/t/OAuth/Server.pm b/src/test/modules/oauth_validator/t/OAuth/Server.pm
index f0f23d1d1a8..655b2870b0b 100644
--- a/src/test/modules/oauth_validator/t/OAuth/Server.pm
+++ b/src/test/modules/oauth_validator/t/OAuth/Server.pm
@@ -1,5 +1,5 @@
 
-# Copyright (c) 2024, PostgreSQL Global Development Group
+# Copyright (c) 2025, PostgreSQL Global Development Group
 
 =pod
 
@@ -46,7 +46,7 @@ use Test::More;
 
 =over
 
-=item SSL::Server->new()
+=item OAuth::Server->new()
 
 Create a new OAuth Server object.
 
diff --git a/src/test/modules/oauth_validator/validator.c b/src/test/modules/oauth_validator/validator.c
index e218f5c8902..b2e5d182e1b 100644
--- a/src/test/modules/oauth_validator/validator.c
+++ b/src/test/modules/oauth_validator/validator.c
@@ -3,7 +3,7 @@
  * validator.c
  *	  Test module for serverside OAuth token validation callbacks
  *
- * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
  * Portions Copyright (c) 1994, Regents of the University of California
  *
  * src/test/modules/oauth_validator/validator.c
-- 
2.39.3 (Apple Git-146)



^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-02-13 23:01  Daniel Gustafsson <[email protected]>
  parent: Jacob Champion <[email protected]>
  0 siblings, 0 replies; 101+ messages in thread

From: Daniel Gustafsson @ 2025-02-13 23:01 UTC (permalink / raw)
  To: Jacob Champion <[email protected]>; +Cc: Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

> On 13 Feb 2025, at 22:23, Jacob Champion <[email protected]> wrote:
> 
> On Wed, Feb 12, 2025 at 6:55 AM Peter Eisentraut <[email protected]> wrote:
>> This just depends on how people have built their libcurl, right?
>> 
>> Do we have any information whether the async-dns-free build is a common
>> configuration?
> 
> I don't think the annual Curl survey covers that, unfortunately.

We should be able to get a decent idea by inspecting the packaging scripts for
the major distributions I think.

--
Daniel Gustafsson







^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-02-15 01:14  Jacob Champion <[email protected]>
  parent: Daniel Gustafsson <[email protected]>
  0 siblings, 1 reply; 101+ messages in thread

From: Jacob Champion @ 2025-02-15 01:14 UTC (permalink / raw)
  To: Daniel Gustafsson <[email protected]>; +Cc: Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

On Thu, Feb 13, 2025 at 2:56 PM Daniel Gustafsson <[email protected]> wrote:
> To make it easier for you to see what I mean I have implemented most of the
> comments and attached as a fixup patch, from which you can cherry-pick hunks
> you agree with.  Those I didn't implement should be marked as such below.
>
> As we discussed off-list I took the liberty of squashing the previous fixup
> patches into a single one, and squashed your fixes for my comments against v47
> into 0001.  All of my proposals are in 0004.

Great! I have attached v50; 0001 has almost all of v49 squashed in,
0002 has my changes on top (includes a pgindent/pgperltidy), and 0003
holds the only part I don't like (see below). 0004 contains the
FreeBSD hack that I suspect we'll need to merge in until Cirrus images
are updated.

> +        The organization, product vendor, or other entity which develops and/or
> +        administers the OAuth servers and clients for a given application.
> Since we define terminology here, shouldn't this be "OAuth resource servers"?

The resource server is Postgres for our purposes; I've changed it to
"authorization servers".

> +   Since a misbehaving validator might let unauthorized users into the database,
> +   correct implementation is critical. See
> "Don't make any bugs" isn't very helpful advice =) Expanded on it slightly.

Hmm, I think the overloading of "validate" in the replacement text
could be confusing. I guess my point is less "don't write bugs" and
more "a bug here has extreme impact"? I've taken another shot at it;
see what you think.

> +    The server has ensured that the token is well-formed syntactically, but no
> "server" is an overloaded nomenclature here, perhaps using libpq instead to
> clearly indicate that it's postgres and not an OAuth server.

I've replaced this with "PostgreSQL" to match up with Peter's earlier
feedback (we were using "libpq" to describe the backend and he wanted
to avoid that).

> +sanitize_char(char c)
> +{
> +       static char buf[5];
> With the multithreading work on the horizon we should probably avoid static
> variables like these to not create work for our future selves?  The code isn't
> as neat when passing in a buffer/length but it avoids the need for a static or
> threadlocal variable. Or am I overthinking this?

This is the only part of the feedback patch that I'm not a fan of,
mostly because it begins to diverge heavily from the SCRAM code it
copied from. I don't disagree with the goal of getting rid of the
static buffers, but I would like to see them modified at the same time
so that we can refactor easily if/when a third SASL mechanism shows
up. (Maybe with a psprintf() rather than buffers?)

> +                       p++;
> +                       if (*p != ',')
> If the SASL exchange, are we certain that a rogue client cannot inject a
> message which trips us past the end of string?  Should we be doublecheck when
> advancing p across the message?

The existing != checks will bail out if they get to the end of the
string. It relies on byte-at-a-time advancement for safety, as well as
the SASL code higher in the stack that ensures that the input buffer
is always null terminated. (SCRAM relies on that too.) If we ever
jumped farther than a byte, we'd need stronger checks, but at the
moment I don't think this change helps us.

> In load_validator_library we don't explicitly verify that the required callback
> is defined in the returned structure, which seems like a cheap enough belts and
> suspenders level check.

Yeah, there's a later check at time of use, but it's not as
user-friendly. I've adjusted the new error message to make it a bit
closer to the logical plugin wording.

> +       if (parsed < 1)
> +               return actx->debugging ? 0 : 1;
> Is 1 second a sane lower bound on interval for all situations?  I'm starting to
> wonder if we should be more conservative here, or even make it configurable in
> some way? The default if not set of 5 seconds is quite a lot higher than 1.

Mmm, maybe it should be made configurable, but one second seems like a
long time from a CPU perspective. Maybe it would be applicable to
embedded clients? But only if some provider out there actually starts
using smaller intervals than their clients can stand... Should we wait
to hear from someone who is interested in configuring it?

> +       parsed = parse_json_number(expires_in_str);
> +       parsed = round(parsed);
> Shouldn't we floor() the value here to ensure we never report an expiration
> time longer than the actual expiration?

Sounds reasonable. Done in 0002.

> register_socket() doesn't have an error catch for the case when neither epoll
> nor kqeue is supported.  Shouldn't it set actx_error() here as well?  (Not done
> in my review patch.)

Done.

> +       if (actx->curl_err[0])
> +       {
> +               size_t          len;
> +
> +               appendPQExpBuffer(&conn->errorMessage, " (%s)", actx->curl_err);
> Should this also qualify that the error comes from outside of postgres?
> Something like "(libcurl:%s)" to match?  I haven't changed this in the attached
> since I'm still on the fence, but I'm leanings that we probably should.
> Thoughts?

Done. More context is probably better than less here.

> -   * We only support one mechanism at the moment, so rather than deal with a
> +   * We only support two mechanisms at the moment, so rather than deal with a
> While there's nothing incorrect about this comment, I have a feeling we won't
> support more mechanisms than we can justify having a simple array for anytime
> soon =)

Yeah. My goal was mostly to justify the use of the (unusual)
static-length list to future readers.

Thank you so much for the reviews!

--Jacob


Attachments:

  [application/octet-stream] v50-0001-Add-OAUTHBEARER-SASL-mechanism.patch (323.1K, ../../CAOYmi+=zgJinCoqdQ70fvKmvyrLekd9-supkuj57Poa1FmX8bw@mail.gmail.com/2-v50-0001-Add-OAUTHBEARER-SASL-mechanism.patch)
  download | inline diff:
From 1e9e97d298352fe07c4d87c0aa72693286cadd1d Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Wed, 23 Oct 2024 09:37:33 -0700
Subject: [PATCH v50 1/4] Add OAUTHBEARER SASL mechanism

DO NOT USE THIS PROOF OF CONCEPT IN PRODUCTION.

Implement OAUTHBEARER (RFC 7628) and OAuth 2.0 Device Authorization
Grants (RFC 8628). This adds a new auth method, oauth, to pg_hba. When
speaking to a OAuth-enabled server, it looks a bit like this:

    $ psql 'host=example.org oauth_issuer=... oauth_client_id=...'
    Visit https://oauth.example.org/login and enter the code: FPQ2-M4BG

The OAuth issuer must support device authorization. No other OAuth flows
are currently implemented (but clients may provide their own flows).

The client implementation requires libcurl and its development headers.
Pass --with-libcurl/-Dlibcurl=enabled during configuration. The server
implementation does not require additional build-time dependencies, but
an external validator module must be supplied.

Thomas Munro wrote the kqueue() implementation for oauth-curl; thanks!

Several TODOs:
- perform several sanity checks on the OAuth issuer's responses
- improve error debuggability during the OAuth handshake
- fix libcurl initialization thread-safety
- harden the libcurl flow implementation
- fill in documentation stubs
- support protocol "variants" implemented by major providers
- implement more helpful handling of HBA misconfigurations
- use logdetail during auth failures
- ...and more.

Co-authored-by: Daniel Gustafsson <[email protected]>
---
 .cirrus.tasks.yml                             |   15 +-
 config/programs.m4                            |   65 +
 configure                                     |  332 ++
 configure.ac                                  |   41 +
 doc/src/sgml/client-auth.sgml                 |  252 ++
 doc/src/sgml/config.sgml                      |   26 +
 doc/src/sgml/filelist.sgml                    |    1 +
 doc/src/sgml/installation.sgml                |   27 +
 doc/src/sgml/libpq.sgml                       |  445 +++
 doc/src/sgml/oauth-validators.sgml            |  414 +++
 doc/src/sgml/postgres.sgml                    |    1 +
 doc/src/sgml/protocol.sgml                    |  133 +-
 doc/src/sgml/regress.sgml                     |   10 +
 meson.build                                   |  100 +
 meson_options.txt                             |    3 +
 src/Makefile.global.in                        |    1 +
 src/backend/libpq/Makefile                    |    1 +
 src/backend/libpq/auth-oauth.c                |  894 +++++
 src/backend/libpq/auth.c                      |   10 +-
 src/backend/libpq/hba.c                       |   64 +-
 src/backend/libpq/meson.build                 |    1 +
 src/backend/libpq/pg_hba.conf.sample          |    4 +-
 src/backend/utils/adt/hbafuncs.c              |   19 +
 src/backend/utils/misc/guc_tables.c           |   12 +
 src/backend/utils/misc/postgresql.conf.sample |    3 +
 src/include/common/oauth-common.h             |   19 +
 src/include/libpq/auth.h                      |    1 +
 src/include/libpq/hba.h                       |    7 +-
 src/include/libpq/oauth.h                     |  101 +
 src/include/pg_config.h.in                    |    9 +
 src/interfaces/libpq/Makefile                 |   11 +-
 src/interfaces/libpq/exports.txt              |    3 +
 src/interfaces/libpq/fe-auth-oauth-curl.c     | 2876 +++++++++++++++++
 src/interfaces/libpq/fe-auth-oauth.c          | 1153 +++++++
 src/interfaces/libpq/fe-auth-oauth.h          |   45 +
 src/interfaces/libpq/fe-auth.c                |   36 +-
 src/interfaces/libpq/fe-auth.h                |    3 +
 src/interfaces/libpq/fe-connect.c             |   48 +-
 src/interfaces/libpq/libpq-fe.h               |   85 +
 src/interfaces/libpq/libpq-int.h              |   13 +-
 src/interfaces/libpq/meson.build              |    5 +
 src/makefiles/meson.build                     |    1 +
 src/test/authentication/t/001_password.pl     |    8 +-
 src/test/modules/Makefile                     |    1 +
 src/test/modules/meson.build                  |    1 +
 src/test/modules/oauth_validator/.gitignore   |    4 +
 src/test/modules/oauth_validator/Makefile     |   40 +
 src/test/modules/oauth_validator/README       |   13 +
 .../modules/oauth_validator/fail_validator.c  |   47 +
 .../modules/oauth_validator/magic_validator.c |   48 +
 src/test/modules/oauth_validator/meson.build  |   85 +
 .../oauth_validator/oauth_hook_client.c       |  293 ++
 .../modules/oauth_validator/t/001_server.pl   |  592 ++++
 .../modules/oauth_validator/t/002_client.pl   |  154 +
 .../modules/oauth_validator/t/OAuth/Server.pm |  140 +
 .../modules/oauth_validator/t/oauth_server.py |  391 +++
 src/test/modules/oauth_validator/validator.c  |  143 +
 src/test/perl/PostgreSQL/Test/Cluster.pm      |   20 +-
 src/tools/pgindent/pgindent                   |   14 +
 src/tools/pgindent/typedefs.list              |   11 +
 60 files changed, 9256 insertions(+), 39 deletions(-)
 create mode 100644 doc/src/sgml/oauth-validators.sgml
 create mode 100644 src/backend/libpq/auth-oauth.c
 create mode 100644 src/include/common/oauth-common.h
 create mode 100644 src/include/libpq/oauth.h
 create mode 100644 src/interfaces/libpq/fe-auth-oauth-curl.c
 create mode 100644 src/interfaces/libpq/fe-auth-oauth.c
 create mode 100644 src/interfaces/libpq/fe-auth-oauth.h
 create mode 100644 src/test/modules/oauth_validator/.gitignore
 create mode 100644 src/test/modules/oauth_validator/Makefile
 create mode 100644 src/test/modules/oauth_validator/README
 create mode 100644 src/test/modules/oauth_validator/fail_validator.c
 create mode 100644 src/test/modules/oauth_validator/magic_validator.c
 create mode 100644 src/test/modules/oauth_validator/meson.build
 create mode 100644 src/test/modules/oauth_validator/oauth_hook_client.c
 create mode 100644 src/test/modules/oauth_validator/t/001_server.pl
 create mode 100644 src/test/modules/oauth_validator/t/002_client.pl
 create mode 100644 src/test/modules/oauth_validator/t/OAuth/Server.pm
 create mode 100755 src/test/modules/oauth_validator/t/oauth_server.py
 create mode 100644 src/test/modules/oauth_validator/validator.c

diff --git a/.cirrus.tasks.yml b/.cirrus.tasks.yml
index fffa438cec1..2f5f5ef21a8 100644
--- a/.cirrus.tasks.yml
+++ b/.cirrus.tasks.yml
@@ -23,7 +23,7 @@ env:
   MTEST_ARGS: --print-errorlogs --no-rebuild -C build
   PGCTLTIMEOUT: 120 # avoids spurious failures during parallel tests
   TEMP_CONFIG: ${CIRRUS_WORKING_DIR}/src/tools/ci/pg_ci_base.conf
-  PG_TEST_EXTRA: kerberos ldap ssl libpq_encryption load_balance
+  PG_TEST_EXTRA: kerberos ldap ssl libpq_encryption load_balance oauth
 
 
 # What files to preserve in case tests fail
@@ -167,7 +167,7 @@ task:
     chown root:postgres /tmp/cores
     sysctl kern.corefile='/tmp/cores/%N.%P.core'
   setup_additional_packages_script: |
-    #pkg install -y ...
+    pkg install -y curl
 
   # NB: Intentionally build without -Dllvm. The freebsd image size is already
   # large enough to make VM startup slow, and even without llvm freebsd
@@ -329,6 +329,7 @@ LINUX_CONFIGURE_FEATURES: &LINUX_CONFIGURE_FEATURES >-
   --with-gssapi
   --with-icu
   --with-ldap
+  --with-libcurl
   --with-libxml
   --with-libxslt
   --with-llvm
@@ -422,8 +423,10 @@ task:
     EOF
 
   setup_additional_packages_script: |
-    #apt-get update
-    #DEBIAN_FRONTEND=noninteractive apt-get -y install ...
+    apt-get update
+    DEBIAN_FRONTEND=noninteractive apt-get -y install \
+      libcurl4-openssl-dev \
+      libcurl4-openssl-dev:i386 \
 
   matrix:
     - name: Linux - Debian Bookworm - Autoconf
@@ -799,8 +802,8 @@ task:
     folder: $CCACHE_DIR
 
   setup_additional_packages_script: |
-    #apt-get update
-    #DEBIAN_FRONTEND=noninteractive apt-get -y install ...
+    apt-get update
+    DEBIAN_FRONTEND=noninteractive apt-get -y install libcurl4-openssl-dev
 
   ###
   # Test that code can be built with gcc/clang without warnings
diff --git a/config/programs.m4 b/config/programs.m4
index 7b55c2664a6..061b13376ac 100644
--- a/config/programs.m4
+++ b/config/programs.m4
@@ -274,3 +274,68 @@ AC_DEFUN([PGAC_CHECK_STRIP],
   AC_SUBST(STRIP_STATIC_LIB)
   AC_SUBST(STRIP_SHARED_LIB)
 ])# PGAC_CHECK_STRIP
+
+
+
+# PGAC_CHECK_LIBCURL
+# ------------------
+# Check for required libraries and headers, and test to see whether the current
+# installation of libcurl is thread-safe.
+
+AC_DEFUN([PGAC_CHECK_LIBCURL],
+[
+  AC_CHECK_HEADER(curl/curl.h, [],
+				  [AC_MSG_ERROR([header file <curl/curl.h> is required for --with-libcurl])])
+  AC_CHECK_LIB(curl, curl_multi_init, [],
+			   [AC_MSG_ERROR([library 'curl' does not provide curl_multi_init])])
+
+  # Check to see whether the current platform supports threadsafe Curl
+  # initialization.
+  AC_CACHE_CHECK([for curl_global_init thread safety], [pgac_cv__libcurl_threadsafe_init],
+  [AC_RUN_IFELSE([AC_LANG_PROGRAM([
+#include <curl/curl.h>
+],[
+    curl_version_info_data *info;
+
+    if (curl_global_init(CURL_GLOBAL_ALL))
+        return -1;
+
+    info = curl_version_info(CURLVERSION_NOW);
+#ifdef CURL_VERSION_THREADSAFE
+    if (info->features & CURL_VERSION_THREADSAFE)
+        return 0;
+#endif
+
+    return 1;
+])],
+  [pgac_cv__libcurl_threadsafe_init=yes],
+  [pgac_cv__libcurl_threadsafe_init=no],
+  [pgac_cv__libcurl_threadsafe_init=unknown])])
+  if test x"$pgac_cv__libcurl_threadsafe_init" = xyes ; then
+    AC_DEFINE(HAVE_THREADSAFE_CURL_GLOBAL_INIT, 1,
+              [Define to 1 if curl_global_init() is guaranteed to be thread-safe.])
+  fi
+
+  # Warn if a thread-friendly DNS resolver isn't built.
+  AC_CACHE_CHECK([for curl support for asynchronous DNS], [pgac_cv__libcurl_async_dns],
+  [AC_RUN_IFELSE([AC_LANG_PROGRAM([
+#include <curl/curl.h>
+],[
+    curl_version_info_data *info;
+
+    if (curl_global_init(CURL_GLOBAL_ALL))
+        return -1;
+
+    info = curl_version_info(CURLVERSION_NOW);
+    return (info->features & CURL_VERSION_ASYNCHDNS) ? 0 : 1;
+])],
+  [pgac_cv__libcurl_async_dns=yes],
+  [pgac_cv__libcurl_async_dns=no],
+  [pgac_cv__libcurl_async_dns=unknown])])
+  if test x"$pgac_cv__libcurl_async_dns" != xyes ; then
+    AC_MSG_WARN([
+*** The installed version of libcurl does not support asynchronous DNS
+*** lookups. Connection timeouts will not be honored during DNS resolution,
+*** which may lead to hangs in client programs.])
+  fi
+])# PGAC_CHECK_LIBCURL
diff --git a/configure b/configure
index 0ffcaeb4367..93fddd69981 100755
--- a/configure
+++ b/configure
@@ -708,6 +708,9 @@ XML2_LIBS
 XML2_CFLAGS
 XML2_CONFIG
 with_libxml
+LIBCURL_LIBS
+LIBCURL_CFLAGS
+with_libcurl
 with_uuid
 with_readline
 with_systemd
@@ -864,6 +867,7 @@ with_readline
 with_libedit_preferred
 with_uuid
 with_ossp_uuid
+with_libcurl
 with_libxml
 with_libxslt
 with_system_tzdata
@@ -894,6 +898,8 @@ PKG_CONFIG_PATH
 PKG_CONFIG_LIBDIR
 ICU_CFLAGS
 ICU_LIBS
+LIBCURL_CFLAGS
+LIBCURL_LIBS
 XML2_CONFIG
 XML2_CFLAGS
 XML2_LIBS
@@ -1574,6 +1580,7 @@ Optional Packages:
                           prefer BSD Libedit over GNU Readline
   --with-uuid=LIB         build contrib/uuid-ossp using LIB (bsd,e2fs,ossp)
   --with-ossp-uuid        obsolete spelling of --with-uuid=ossp
+  --with-libcurl          build with libcurl support
   --with-libxml           build with XML support
   --with-libxslt          use XSLT support when building contrib/xml2
   --with-system-tzdata=DIR
@@ -1607,6 +1614,10 @@ Some influential environment variables:
               path overriding pkg-config's built-in search path
   ICU_CFLAGS  C compiler flags for ICU, overriding pkg-config
   ICU_LIBS    linker flags for ICU, overriding pkg-config
+  LIBCURL_CFLAGS
+              C compiler flags for LIBCURL, overriding pkg-config
+  LIBCURL_LIBS
+              linker flags for LIBCURL, overriding pkg-config
   XML2_CONFIG path to xml2-config utility
   XML2_CFLAGS C compiler flags for XML2, overriding pkg-config
   XML2_LIBS   linker flags for XML2, overriding pkg-config
@@ -8762,6 +8773,157 @@ fi
 
 
 
+#
+# libcurl
+#
+{ $as_echo "$as_me:${as_lineno-$LINENO}: checking whether to build with libcurl support" >&5
+$as_echo_n "checking whether to build with libcurl support... " >&6; }
+
+
+
+# Check whether --with-libcurl was given.
+if test "${with_libcurl+set}" = set; then :
+  withval=$with_libcurl;
+  case $withval in
+    yes)
+
+$as_echo "#define USE_LIBCURL 1" >>confdefs.h
+
+      ;;
+    no)
+      :
+      ;;
+    *)
+      as_fn_error $? "no argument expected for --with-libcurl option" "$LINENO" 5
+      ;;
+  esac
+
+else
+  with_libcurl=no
+
+fi
+
+
+{ $as_echo "$as_me:${as_lineno-$LINENO}: result: $with_libcurl" >&5
+$as_echo "$with_libcurl" >&6; }
+
+
+if test "$with_libcurl" = yes ; then
+  # Check for libcurl 7.61.0 or higher (corresponding to RHEL8 and the ability
+  # to explicitly set TLS 1.3 ciphersuites).
+
+pkg_failed=no
+{ $as_echo "$as_me:${as_lineno-$LINENO}: checking for libcurl >= 7.61.0" >&5
+$as_echo_n "checking for libcurl >= 7.61.0... " >&6; }
+
+if test -n "$LIBCURL_CFLAGS"; then
+    pkg_cv_LIBCURL_CFLAGS="$LIBCURL_CFLAGS"
+ elif test -n "$PKG_CONFIG"; then
+    if test -n "$PKG_CONFIG" && \
+    { { $as_echo "$as_me:${as_lineno-$LINENO}: \$PKG_CONFIG --exists --print-errors \"libcurl >= 7.61.0\""; } >&5
+  ($PKG_CONFIG --exists --print-errors "libcurl >= 7.61.0") 2>&5
+  ac_status=$?
+  $as_echo "$as_me:${as_lineno-$LINENO}: \$? = $ac_status" >&5
+  test $ac_status = 0; }; then
+  pkg_cv_LIBCURL_CFLAGS=`$PKG_CONFIG --cflags "libcurl >= 7.61.0" 2>/dev/null`
+		      test "x$?" != "x0" && pkg_failed=yes
+else
+  pkg_failed=yes
+fi
+ else
+    pkg_failed=untried
+fi
+if test -n "$LIBCURL_LIBS"; then
+    pkg_cv_LIBCURL_LIBS="$LIBCURL_LIBS"
+ elif test -n "$PKG_CONFIG"; then
+    if test -n "$PKG_CONFIG" && \
+    { { $as_echo "$as_me:${as_lineno-$LINENO}: \$PKG_CONFIG --exists --print-errors \"libcurl >= 7.61.0\""; } >&5
+  ($PKG_CONFIG --exists --print-errors "libcurl >= 7.61.0") 2>&5
+  ac_status=$?
+  $as_echo "$as_me:${as_lineno-$LINENO}: \$? = $ac_status" >&5
+  test $ac_status = 0; }; then
+  pkg_cv_LIBCURL_LIBS=`$PKG_CONFIG --libs "libcurl >= 7.61.0" 2>/dev/null`
+		      test "x$?" != "x0" && pkg_failed=yes
+else
+  pkg_failed=yes
+fi
+ else
+    pkg_failed=untried
+fi
+
+
+
+if test $pkg_failed = yes; then
+        { $as_echo "$as_me:${as_lineno-$LINENO}: result: no" >&5
+$as_echo "no" >&6; }
+
+if $PKG_CONFIG --atleast-pkgconfig-version 0.20; then
+        _pkg_short_errors_supported=yes
+else
+        _pkg_short_errors_supported=no
+fi
+        if test $_pkg_short_errors_supported = yes; then
+	        LIBCURL_PKG_ERRORS=`$PKG_CONFIG --short-errors --print-errors --cflags --libs "libcurl >= 7.61.0" 2>&1`
+        else
+	        LIBCURL_PKG_ERRORS=`$PKG_CONFIG --print-errors --cflags --libs "libcurl >= 7.61.0" 2>&1`
+        fi
+	# Put the nasty error message in config.log where it belongs
+	echo "$LIBCURL_PKG_ERRORS" >&5
+
+	as_fn_error $? "Package requirements (libcurl >= 7.61.0) were not met:
+
+$LIBCURL_PKG_ERRORS
+
+Consider adjusting the PKG_CONFIG_PATH environment variable if you
+installed software in a non-standard prefix.
+
+Alternatively, you may set the environment variables LIBCURL_CFLAGS
+and LIBCURL_LIBS to avoid the need to call pkg-config.
+See the pkg-config man page for more details." "$LINENO" 5
+elif test $pkg_failed = untried; then
+        { $as_echo "$as_me:${as_lineno-$LINENO}: result: no" >&5
+$as_echo "no" >&6; }
+	{ { $as_echo "$as_me:${as_lineno-$LINENO}: error: in \`$ac_pwd':" >&5
+$as_echo "$as_me: error: in \`$ac_pwd':" >&2;}
+as_fn_error $? "The pkg-config script could not be found or is too old.  Make sure it
+is in your PATH or set the PKG_CONFIG environment variable to the full
+path to pkg-config.
+
+Alternatively, you may set the environment variables LIBCURL_CFLAGS
+and LIBCURL_LIBS to avoid the need to call pkg-config.
+See the pkg-config man page for more details.
+
+To get pkg-config, see <http://pkg-config.freedesktop.org/>.
+See \`config.log' for more details" "$LINENO" 5; }
+else
+	LIBCURL_CFLAGS=$pkg_cv_LIBCURL_CFLAGS
+	LIBCURL_LIBS=$pkg_cv_LIBCURL_LIBS
+        { $as_echo "$as_me:${as_lineno-$LINENO}: result: yes" >&5
+$as_echo "yes" >&6; }
+
+fi
+
+  # We only care about -I, -D, and -L switches;
+  # note that -lcurl will be added by PGAC_CHECK_LIBCURL below.
+  for pgac_option in $LIBCURL_CFLAGS; do
+    case $pgac_option in
+      -I*|-D*) CPPFLAGS="$CPPFLAGS $pgac_option";;
+    esac
+  done
+  for pgac_option in $LIBCURL_LIBS; do
+    case $pgac_option in
+      -L*) LDFLAGS="$LDFLAGS $pgac_option";;
+    esac
+  done
+
+  # OAuth requires python for testing
+  if test "$with_python" != yes; then
+    { $as_echo "$as_me:${as_lineno-$LINENO}: WARNING: *** OAuth support tests require --with-python to run" >&5
+$as_echo "$as_me: WARNING: *** OAuth support tests require --with-python to run" >&2;}
+  fi
+fi
+
+
 #
 # XML
 #
@@ -12216,6 +12378,176 @@ fi
 
 fi
 
+# XXX libcurl must link after libgssapi_krb5 on FreeBSD to avoid segfaults
+# during gss_acquire_cred(). This is possibly related to Curl's Heimdal
+# dependency on that platform?
+if test "$with_libcurl" = yes ; then
+
+  ac_fn_c_check_header_mongrel "$LINENO" "curl/curl.h" "ac_cv_header_curl_curl_h" "$ac_includes_default"
+if test "x$ac_cv_header_curl_curl_h" = xyes; then :
+
+else
+  as_fn_error $? "header file <curl/curl.h> is required for --with-libcurl" "$LINENO" 5
+fi
+
+
+  { $as_echo "$as_me:${as_lineno-$LINENO}: checking for curl_multi_init in -lcurl" >&5
+$as_echo_n "checking for curl_multi_init in -lcurl... " >&6; }
+if ${ac_cv_lib_curl_curl_multi_init+:} false; then :
+  $as_echo_n "(cached) " >&6
+else
+  ac_check_lib_save_LIBS=$LIBS
+LIBS="-lcurl  $LIBS"
+cat confdefs.h - <<_ACEOF >conftest.$ac_ext
+/* end confdefs.h.  */
+
+/* Override any GCC internal prototype to avoid an error.
+   Use char because int might match the return type of a GCC
+   builtin and then its argument prototype would still apply.  */
+#ifdef __cplusplus
+extern "C"
+#endif
+char curl_multi_init ();
+int
+main ()
+{
+return curl_multi_init ();
+  ;
+  return 0;
+}
+_ACEOF
+if ac_fn_c_try_link "$LINENO"; then :
+  ac_cv_lib_curl_curl_multi_init=yes
+else
+  ac_cv_lib_curl_curl_multi_init=no
+fi
+rm -f core conftest.err conftest.$ac_objext \
+    conftest$ac_exeext conftest.$ac_ext
+LIBS=$ac_check_lib_save_LIBS
+fi
+{ $as_echo "$as_me:${as_lineno-$LINENO}: result: $ac_cv_lib_curl_curl_multi_init" >&5
+$as_echo "$ac_cv_lib_curl_curl_multi_init" >&6; }
+if test "x$ac_cv_lib_curl_curl_multi_init" = xyes; then :
+  cat >>confdefs.h <<_ACEOF
+#define HAVE_LIBCURL 1
+_ACEOF
+
+  LIBS="-lcurl $LIBS"
+
+else
+  as_fn_error $? "library 'curl' does not provide curl_multi_init" "$LINENO" 5
+fi
+
+
+  # Check to see whether the current platform supports threadsafe Curl
+  # initialization.
+  { $as_echo "$as_me:${as_lineno-$LINENO}: checking for curl_global_init thread safety" >&5
+$as_echo_n "checking for curl_global_init thread safety... " >&6; }
+if ${pgac_cv__libcurl_threadsafe_init+:} false; then :
+  $as_echo_n "(cached) " >&6
+else
+  if test "$cross_compiling" = yes; then :
+  pgac_cv__libcurl_threadsafe_init=unknown
+else
+  cat confdefs.h - <<_ACEOF >conftest.$ac_ext
+/* end confdefs.h.  */
+
+#include <curl/curl.h>
+
+int
+main ()
+{
+
+    curl_version_info_data *info;
+
+    if (curl_global_init(CURL_GLOBAL_ALL))
+        return -1;
+
+    info = curl_version_info(CURLVERSION_NOW);
+#ifdef CURL_VERSION_THREADSAFE
+    if (info->features & CURL_VERSION_THREADSAFE)
+        return 0;
+#endif
+
+    return 1;
+
+  ;
+  return 0;
+}
+_ACEOF
+if ac_fn_c_try_run "$LINENO"; then :
+  pgac_cv__libcurl_threadsafe_init=yes
+else
+  pgac_cv__libcurl_threadsafe_init=no
+fi
+rm -f core *.core core.conftest.* gmon.out bb.out conftest$ac_exeext \
+  conftest.$ac_objext conftest.beam conftest.$ac_ext
+fi
+
+fi
+{ $as_echo "$as_me:${as_lineno-$LINENO}: result: $pgac_cv__libcurl_threadsafe_init" >&5
+$as_echo "$pgac_cv__libcurl_threadsafe_init" >&6; }
+  if test x"$pgac_cv__libcurl_threadsafe_init" = xyes ; then
+
+$as_echo "#define HAVE_THREADSAFE_CURL_GLOBAL_INIT 1" >>confdefs.h
+
+  fi
+
+  # Warn if a thread-friendly DNS resolver isn't built.
+  { $as_echo "$as_me:${as_lineno-$LINENO}: checking for curl support for asynchronous DNS" >&5
+$as_echo_n "checking for curl support for asynchronous DNS... " >&6; }
+if ${pgac_cv__libcurl_async_dns+:} false; then :
+  $as_echo_n "(cached) " >&6
+else
+  if test "$cross_compiling" = yes; then :
+  pgac_cv__libcurl_async_dns=unknown
+else
+  cat confdefs.h - <<_ACEOF >conftest.$ac_ext
+/* end confdefs.h.  */
+
+#include <curl/curl.h>
+
+int
+main ()
+{
+
+    curl_version_info_data *info;
+
+    if (curl_global_init(CURL_GLOBAL_ALL))
+        return -1;
+
+    info = curl_version_info(CURLVERSION_NOW);
+    return (info->features & CURL_VERSION_ASYNCHDNS) ? 0 : 1;
+
+  ;
+  return 0;
+}
+_ACEOF
+if ac_fn_c_try_run "$LINENO"; then :
+  pgac_cv__libcurl_async_dns=yes
+else
+  pgac_cv__libcurl_async_dns=no
+fi
+rm -f core *.core core.conftest.* gmon.out bb.out conftest$ac_exeext \
+  conftest.$ac_objext conftest.beam conftest.$ac_ext
+fi
+
+fi
+{ $as_echo "$as_me:${as_lineno-$LINENO}: result: $pgac_cv__libcurl_async_dns" >&5
+$as_echo "$pgac_cv__libcurl_async_dns" >&6; }
+  if test x"$pgac_cv__libcurl_async_dns" != xyes ; then
+    { $as_echo "$as_me:${as_lineno-$LINENO}: WARNING:
+*** The installed version of libcurl does not support asynchronous DNS
+*** lookups. Connection timeouts will not be honored during DNS resolution,
+*** which may lead to hangs in client programs." >&5
+$as_echo "$as_me: WARNING:
+*** The installed version of libcurl does not support asynchronous DNS
+*** lookups. Connection timeouts will not be honored during DNS resolution,
+*** which may lead to hangs in client programs." >&2;}
+  fi
+
+fi
+
 if test "$with_gssapi" = yes ; then
   if test "$PORTNAME" != "win32"; then
     { $as_echo "$as_me:${as_lineno-$LINENO}: checking for library containing gss_store_cred_into" >&5
diff --git a/configure.ac b/configure.ac
index f56681e0d91..b6d02f5ecc7 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1007,6 +1007,40 @@ fi
 AC_SUBST(with_uuid)
 
 
+#
+# libcurl
+#
+AC_MSG_CHECKING([whether to build with libcurl support])
+PGAC_ARG_BOOL(with, libcurl, no, [build with libcurl support],
+              [AC_DEFINE([USE_LIBCURL], 1, [Define to 1 to build with libcurl support. (--with-libcurl)])])
+AC_MSG_RESULT([$with_libcurl])
+AC_SUBST(with_libcurl)
+
+if test "$with_libcurl" = yes ; then
+  # Check for libcurl 7.61.0 or higher (corresponding to RHEL8 and the ability
+  # to explicitly set TLS 1.3 ciphersuites).
+  PKG_CHECK_MODULES(LIBCURL, [libcurl >= 7.61.0])
+
+  # We only care about -I, -D, and -L switches;
+  # note that -lcurl will be added by PGAC_CHECK_LIBCURL below.
+  for pgac_option in $LIBCURL_CFLAGS; do
+    case $pgac_option in
+      -I*|-D*) CPPFLAGS="$CPPFLAGS $pgac_option";;
+    esac
+  done
+  for pgac_option in $LIBCURL_LIBS; do
+    case $pgac_option in
+      -L*) LDFLAGS="$LDFLAGS $pgac_option";;
+    esac
+  done
+
+  # OAuth requires python for testing
+  if test "$with_python" != yes; then
+    AC_MSG_WARN([*** OAuth support tests require --with-python to run])
+  fi
+fi
+
+
 #
 # XML
 #
@@ -1294,6 +1328,13 @@ failure.  It is possible the compiler isn't looking in the proper directory.
 Use --without-zlib to disable zlib support.])])
 fi
 
+# XXX libcurl must link after libgssapi_krb5 on FreeBSD to avoid segfaults
+# during gss_acquire_cred(). This is possibly related to Curl's Heimdal
+# dependency on that platform?
+if test "$with_libcurl" = yes ; then
+  PGAC_CHECK_LIBCURL
+fi
+
 if test "$with_gssapi" = yes ; then
   if test "$PORTNAME" != "win32"; then
     AC_SEARCH_LIBS(gss_store_cred_into, [gssapi_krb5 gss 'gssapi -lkrb5 -lcrypto'], [],
diff --git a/doc/src/sgml/client-auth.sgml b/doc/src/sgml/client-auth.sgml
index 782b49c85ac..6fc0da57f1b 100644
--- a/doc/src/sgml/client-auth.sgml
+++ b/doc/src/sgml/client-auth.sgml
@@ -656,6 +656,16 @@ include_dir         <replaceable>directory</replaceable>
          </para>
         </listitem>
        </varlistentry>
+
+       <varlistentry>
+        <term><literal>oauth</literal></term>
+        <listitem>
+         <para>
+          Authorize and optionally authenticate using a third-party OAuth 2.0
+          identity provider. See <xref linkend="auth-oauth"/> for details.
+         </para>
+        </listitem>
+       </varlistentry>
       </variablelist>
 
       </para>
@@ -1143,6 +1153,12 @@ omicron         bryanh                  guest1
       only on OpenBSD).
      </para>
     </listitem>
+    <listitem>
+     <para>
+      <link linkend="auth-oauth">OAuth authorization/authentication</link>,
+      which relies on an external OAuth 2.0 identity provider.
+     </para>
+    </listitem>
    </itemizedlist>
   </para>
 
@@ -2329,6 +2345,242 @@ host ... radius radiusservers="server1,server2" radiussecrets="""secret one"",""
    </note>
   </sect1>
 
+  <sect1 id="auth-oauth">
+   <title>OAuth Authorization/Authentication</title>
+
+   <indexterm zone="auth-oauth">
+    <primary>OAuth Authorization/Authentication</primary>
+   </indexterm>
+
+   <para>
+    OAuth 2.0 is an industry-standard framework, defined in
+    <ulink url="https://datatracker.ietf.org/doc/html/rfc6749">RFC 6749</ulink>,
+    to enable third-party applications to obtain limited access to a protected
+    resource.
+
+    OAuth client support has to be enabled when <productname>PostgreSQL</productname>
+    is built, see <xref linkend="installation"/> for more information.
+   </para>
+
+   <para>
+    This documentation uses the following terminology when discussing the OAuth
+    ecosystem:
+
+    <variablelist>
+
+     <varlistentry>
+      <term>Resource Owner (or End User)</term>
+      <listitem>
+       <para>
+        The user or system who owns protected resources and can grant access to
+        them. This documentation also uses the term <emphasis>end user</emphasis>
+        when the resource owner is a person. When you use
+        <application>psql</application> to connect to the database using OAuth,
+        you are the resource owner/end user.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term>Client</term>
+      <listitem>
+       <para>
+        The system which accesses the protected resources using access
+        tokens. Applications using libpq, such as <application>psql</application>,
+        are the OAuth clients when connecting to a
+        <productname>PostgreSQL</productname> cluster.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term>Resource Server</term>
+      <listitem>
+       <para>
+        The system hosting the protected resources which are
+        accessed by the client. The <productname>PostgreSQL</productname>
+        cluster being connected to is the resource server.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term>Provider</term>
+      <listitem>
+       <para>
+        The organization, product vendor, or other entity which develops and/or
+        administers the OAuth resource servers and clients for a given application.
+        Different providers typically choose different implementation details
+        for their OAuth systems; a client of one provider is not generally
+        guaranteed to have access to the servers of another.
+       </para>
+       <para>
+        This use of the term "provider" is not standard, but it seems to be in
+        wide use colloquially. (It should not be confused with OpenID's similar
+        term "Identity Provider". While the implementation of OAuth in
+        <productname>PostgreSQL</productname> is intended to be interoperable
+        and compatible with OpenID Connect/OIDC, it is not itself an OIDC client
+        and does not require its use.)
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term>Authorization Server</term>
+      <listitem>
+       <para>
+        The system which receives requests from, and issues access tokens to,
+        the client after the authenticated resource owner has given approval.
+        <productname>PostgreSQL</productname> does not provide an authorization
+        server; it is the responsibility of the OAuth provider.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term id="auth-oauth-issuer">Issuer</term>
+      <listitem>
+       <para>
+        An identifier for an authorization server, printed as an
+        <literal>https://</literal> URL, which provides a trusted "namespace"
+        for OAuth clients and applications. The issuer identifier allows a
+        single authorization server to talk to the clients of mutually
+        untrusting entities, as long as they maintain separate issuers.
+       </para>
+      </listitem>
+     </varlistentry>
+
+    </variablelist>
+
+    <note>
+     <para>
+      For small deployments, there may not be a meaningful distinction between
+      the "provider", "authorization server", and "issuer". However, for more
+      complicated setups, there may be a one-to-many (or many-to-many)
+      relationship: a provider may rent out multiple issuer identifiers to
+      separate tenants, then provide multiple authorization servers, possibly
+      with different supported feature sets, to interact with their clients.
+     </para>
+    </note>
+   </para>
+
+   <para>
+    <productname>PostgreSQL</productname> supports bearer tokens, defined in
+    <ulink url="https://datatracker.ietf.org/doc/html/rfc6750">RFC 6750</ulink>,
+    which are a type of access token used with OAuth 2.0 where the token is an
+    opaque string.  The format of the access token is implementation specific
+    and is chosen by each authorization server.
+   </para>
+
+   <para>
+    The following configuration options are supported for OAuth:
+    <variablelist>
+     <varlistentry>
+      <term><literal>issuer</literal></term>
+      <listitem>
+       <para>
+        An HTTPS URL which is either the exact
+        <link linkend="auth-oauth-issuer">issuer identifier</link> of the
+        authorization server, as defined by its discovery document, or a
+        well-known URI that points directly to that discovery document. This
+        parameter is required.
+       </para>
+       <para>
+        When an OAuth client connects to the server, a URL for the discovery
+        document will be constructed using the issuer identifier. By default,
+        this URL uses the conventions of OpenID Connect Discovery: the path
+        <literal>/.well-known/openid-configuration</literal> will be appended
+        to the end of the issuer identifier. Alternatively, if the
+        <literal>issuer</literal> contains a <literal>/.well-known/</literal>
+        path segment, that URL will be provided to the client as-is.
+       </para>
+       <warning>
+        <para>
+         The OAuth client in libpq requires the server's issuer setting to
+         exactly match the issuer identifier which is provided in the discovery
+         document, which must in turn match the client's
+         <xref linkend="libpq-connect-oauth-issuer"/> setting. No variations in
+         case or formatting are permitted.
+        </para>
+       </warning>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term><literal>scope</literal></term>
+      <listitem>
+       <para>
+        A space-separated list of the OAuth scopes needed for the server to
+        both authorize the client and authenticate the user.  Appropriate values
+        are determined by the authorization server and the OAuth validation
+        module used (see <xref linkend="oauth-validators" /> for more
+        information on validators).  This parameter is required.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term><literal>validator</literal></term>
+      <listitem>
+       <para>
+        The library to use for validating bearer tokens. If given, the name must
+        exactly match one of the libraries listed in
+        <xref linkend="guc-oauth-validator-libraries" />.  This parameter is
+        optional unless <literal>oauth_validator_libraries</literal> contains
+        more than one library, in which case it is required.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term><literal>map</literal></term>
+      <listitem>
+       <para>
+        Allows for mapping between OAuth identity provider and database user
+        names.  See <xref linkend="auth-username-maps"/> for details.  If a
+        map is not specified, the user name associated with the token (as
+        determined by the OAuth validator) must exactly match the role name
+        being requested.  This parameter is optional.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term id="auth-oauth-delegate-ident-mapping" xreflabel="delegate_ident_mapping">
+       <literal>delegate_ident_mapping</literal>
+      </term>
+      <listitem>
+       <para>
+        An advanced option which is not intended for common use.
+       </para>
+       <para>
+        When set to <literal>1</literal>, standard user mapping with
+        <filename>pg_ident.conf</filename> is skipped, and the OAuth validator
+        takes full responsibility for mapping end user identities to database
+        roles.  If the validator authorizes the token, the server trusts that
+        the user is allowed to connect under the requested role, and the
+        connection is allowed to proceed regardless of the authentication
+        status of the user.
+       </para>
+       <para>
+        This parameter is incompatible with <literal>map</literal>.
+       </para>
+       <warning>
+        <para>
+         <literal>delegate_ident_mapping</literal> provides additional
+         flexibility in the design of the authentication system, but it also
+         requires careful implementation of the OAuth validator, which must
+         determine whether the provided token carries sufficient end-user
+         privileges in addition to the <link linkend="oauth-validators">standard
+         checks</link> required of all validators.  Use with caution.
+        </para>
+       </warning>
+      </listitem>
+     </varlistentry>
+    </variablelist>
+   </para>
+  </sect1>
+
   <sect1 id="client-authentication-problems">
    <title>Authentication Problems</title>
 
diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml
index 5e4f201e099..6591a54124c 100644
--- a/doc/src/sgml/config.sgml
+++ b/doc/src/sgml/config.sgml
@@ -1209,6 +1209,32 @@ include_dir 'conf.d'
        </para>
       </listitem>
      </varlistentry>
+
+     <varlistentry id="guc-oauth-validator-libraries" xreflabel="oauth_validator_libraries">
+      <term><varname>oauth_validator_libraries</varname> (<type>string</type>)
+      <indexterm>
+       <primary><varname>oauth_validator_libraries</varname> configuration parameter</primary>
+      </indexterm>
+      </term>
+      <listitem>
+       <para>
+        The library/libraries to use for validating OAuth connection tokens. If
+        only one validator library is provided, it will be used by default for
+        any OAuth connections; otherwise, all
+        <link linkend="auth-oauth"><literal>oauth</literal> HBA entries</link>
+        must explicitly set a <literal>validator</literal> chosen from this
+        list. If set to an empty string (the default), OAuth connections will be
+        refused. This parameter can only be set in the
+        <filename>postgresql.conf</filename> file.
+       </para>
+       <para>
+        Validator modules must be implemented/obtained separately;
+        <productname>PostgreSQL</productname> does not ship with any default
+        implementations. For more information on implementing OAuth validators,
+        see <xref linkend="oauth-validators" />.
+       </para>
+      </listitem>
+     </varlistentry>
      </variablelist>
      </sect2>
 
diff --git a/doc/src/sgml/filelist.sgml b/doc/src/sgml/filelist.sgml
index 66e6dccd4c9..25fb99cee69 100644
--- a/doc/src/sgml/filelist.sgml
+++ b/doc/src/sgml/filelist.sgml
@@ -111,6 +111,7 @@
 <!ENTITY generic-wal SYSTEM "generic-wal.sgml">
 <!ENTITY custom-rmgr SYSTEM "custom-rmgr.sgml">
 <!ENTITY backup-manifest SYSTEM "backup-manifest.sgml">
+<!ENTITY oauth-validators SYSTEM "oauth-validators.sgml">
 
 <!-- contrib information -->
 <!ENTITY contrib         SYSTEM "contrib.sgml">
diff --git a/doc/src/sgml/installation.sgml b/doc/src/sgml/installation.sgml
index 3f0a7e9c069..3c95c15a1e4 100644
--- a/doc/src/sgml/installation.sgml
+++ b/doc/src/sgml/installation.sgml
@@ -1143,6 +1143,19 @@ build-postgresql:
        </listitem>
       </varlistentry>
 
+      <varlistentry id="configure-option-with-libcurl">
+       <term><option>--with-libcurl</option></term>
+       <listitem>
+        <para>
+         Build with libcurl support for OAuth 2.0 client flows.
+         Libcurl version 7.61.0 or later is required for this feature.
+         Building with this will check for the required header files
+         and libraries to make sure that your <productname>curl</productname>
+         installation is sufficient before proceeding.
+        </para>
+       </listitem>
+      </varlistentry>
+
       <varlistentry id="configure-option-with-libxml">
        <term><option>--with-libxml</option></term>
        <listitem>
@@ -2584,6 +2597,20 @@ ninja install
       </listitem>
      </varlistentry>
 
+     <varlistentry id="configure-with-libcurl-meson">
+      <term><option>-Dlibcurl={ auto | enabled | disabled }</option></term>
+      <listitem>
+       <para>
+        Build with libcurl support for OAuth 2.0 client flows.
+        Libcurl version 7.61.0 or later is required for this feature.
+        Building with this will check for the required header files
+        and libraries to make sure that your <productname>Curl</productname>
+        installation is sufficient before proceeding. The default for this
+        option is auto.
+       </para>
+      </listitem>
+     </varlistentry>
+
      <varlistentry id="configure-with-libxml-meson">
       <term><option>-Dlibxml={ auto | enabled | disabled }</option></term>
       <listitem>
diff --git a/doc/src/sgml/libpq.sgml b/doc/src/sgml/libpq.sgml
index c49e975b082..ca84226755d 100644
--- a/doc/src/sgml/libpq.sgml
+++ b/doc/src/sgml/libpq.sgml
@@ -1385,6 +1385,15 @@ postgresql://%2Fvar%2Flib%2Fpostgresql/dbname
           </listitem>
          </varlistentry>
 
+         <varlistentry>
+          <term><literal>oauth</literal></term>
+          <listitem>
+           <para>
+            The server must request an OAuth bearer token from the client.
+           </para>
+          </listitem>
+         </varlistentry>
+
          <varlistentry>
           <term><literal>none</literal></term>
           <listitem>
@@ -2373,6 +2382,107 @@ postgresql://%2Fvar%2Flib%2Fpostgresql/dbname
        </para>
       </listitem>
      </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-issuer" xreflabel="oauth_issuer">
+      <term><literal>oauth_issuer</literal></term>
+      <listitem>
+       <para>
+        The HTTPS URL of a trusted issuer to contact if the server requests an
+        OAuth token for the connection. This parameter is required for all OAuth
+        connections; it should exactly match the <literal>issuer</literal>
+        setting in <link linkend="auth-oauth">the server's HBA configuration</link>.
+       </para>
+       <para>
+        As part of the standard authentication handshake, <application>libpq</application>
+        will ask the server for a <emphasis>discovery document:</emphasis> a URL
+        providing a set of OAuth configuration parameters. The server must
+        provide a URL that is directly constructed from the components of the
+        <literal>oauth_issuer</literal>, and this value must exactly match the
+        issuer identifier that is declared in the discovery document itself, or
+        the connection will fail. This is required to prevent a class of
+        <ulink url="https://mailarchive.ietf.org/arch/msg/oauth/JIVxFBGsJBVtm7ljwJhPUm3Fr-w/">
+        "mix-up attacks"</ulink> on OAuth clients.
+       </para>
+       <para>
+        You may also explicitly set <literal>oauth_issuer</literal> to the
+        <literal>/.well-known/</literal> URI used for OAuth discovery. In this
+        case, if the server asks for a different URL, the connection will fail,
+        but a <link linkend="libpq-oauth-authdata-hooks">custom OAuth flow</link>
+        may be able to speed up the standard handshake by using previously
+        cached tokens. (In this case, it is recommended that
+        <xref linkend="libpq-connect-oauth-scope"/> be set as well, since the
+        client will not have a chance to ask the server for a correct scope
+        setting, and the default scopes for a token may not be sufficient to
+        connect.) <application>libpq</application> currently supports the
+        following well-known endpoints:
+        <itemizedlist spacing="compact">
+         <listitem><para><literal>/.well-known/openid-configuration</literal></para></listitem>
+         <listitem><para><literal>/.well-known/oauth-authorization-server</literal></para></listitem>
+        </itemizedlist>
+       </para>
+       <warning>
+        <para>
+         Issuers are highly privileged during the OAuth connection handshake. As
+         a rule of thumb, if you would not trust the operator of a URL to handle
+         access to your servers, or to impersonate you directly, that URL should
+         not be trusted as an <literal>oauth_issuer</literal>.
+        </para>
+       </warning>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-client-id" xreflabel="oauth_client_id">
+      <term><literal>oauth_client_id</literal></term>
+      <listitem>
+       <para>
+        An OAuth 2.0 client identifier, as issued by the authorization server.
+        If the <productname>PostgreSQL</productname> server
+        <link linkend="auth-oauth">requests an OAuth token</link> for the
+        connection (and if no <link linkend="libpq-oauth-authdata-hooks">custom
+        OAuth hook</link> is installed to provide one), then this parameter must
+        be set; otherwise, the connection will fail.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-client-secret" xreflabel="oauth_client_secret">
+      <term><literal>oauth_client_secret</literal></term>
+      <listitem>
+       <para>
+        The client password, if any, to use when contacting the OAuth
+        authorization server. Whether this parameter is required or not is
+        determined by the OAuth provider; "public" clients generally do not use
+        a secret, whereas "confidential" clients generally do.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-scope" xreflabel="oauth_scope">
+      <term><literal>oauth_scope</literal></term>
+      <listitem>
+       <para>
+        The scope of the access request sent to the authorization server,
+        specified as a (possibly empty) space-separated list of OAuth scope
+        identifiers. This parameter is optional and intended for advanced usage.
+       </para>
+       <para>
+        Usually the client will obtain appropriate scope settings from the
+        <productname>PostgreSQL</productname> server. If this parameter is used,
+        the server's requested scope list will be ignored. This can prevent a
+        less-trusted server from requesting inappropriate access scopes from the
+        end user. However, if the client's scope setting does not contain the
+        server's required scopes, the server is likely to reject the issued
+        token, and the connection will fail.
+       </para>
+       <para>
+        The meaning of an empty scope list is provider-dependent. An OAuth
+        authorization server may choose to issue a token with "default scope",
+        whatever that happens to be, or it may reject the token request
+        entirely.
+       </para>
+      </listitem>
+     </varlistentry>
+
     </variablelist>
    </para>
   </sect2>
@@ -10020,6 +10130,329 @@ void PQinitSSL(int do_ssl);
 
  </sect1>
 
+ <sect1 id="libpq-oauth">
+  <title>OAuth Support</title>
+
+  <para>
+   libpq implements support for the OAuth v2 Device Authorization client flow,
+   documented in
+   <ulink url="https://datatracker.ietf.org/doc/html/rfc8628">RFC 8628</ulink>,
+   which it will attempt to use by default if the server
+   <link linkend="auth-oauth">requests a bearer token</link> during
+   authentication. This flow can be utilized even if the system running the
+   client application does not have a usable web browser, for example when
+   running a client via <application>SSH</application>. Client applications may implement their own flows
+   instead; see <xref linkend="libpq-oauth-authdata-hooks"/>.
+  </para>
+  <para>
+   The builtin flow will, by default, print a URL to visit and a user code to
+   enter there:
+<programlisting>
+$ psql 'dbname=postgres oauth_issuer=https://example.com oauth_client_id=...'
+Visit https://example.com/device and enter the code: ABCD-EFGH
+</programlisting>
+   (This prompt may be
+   <link linkend="libpq-oauth-authdata-prompt-oauth-device">customized</link>.)
+   The user will then log into their OAuth provider, which will ask whether
+   to allow libpq and the server to perform actions on their behalf. It is always
+   a good idea to carefully review the URL and permissions displayed, to ensure
+   they match expectations, before continuing. Permissions should not be given
+   to untrusted third parties.
+  </para>
+  <para>
+   For an OAuth client flow to be usable, the connection string must at minimum
+   contain <xref linkend="libpq-connect-oauth-issuer"/> and
+   <xref linkend="libpq-connect-oauth-client-id"/>. (These settings are
+   determined by your organization's OAuth provider.) The builtin flow
+   additionally requires the OAuth authorization server to publish a device
+   authorization endpoint.
+  </para>
+
+  <note>
+   <para>
+    The builtin Device Authorization flow is not currently supported on Windows.
+    Custom client flows may still be implemented.
+   </para>
+  </note>
+
+  <sect2 id="libpq-oauth-authdata-hooks">
+   <title>Authdata Hooks</title>
+
+   <para>
+    The behavior of the OAuth flow may be modified or replaced by a client using
+    the following hook API:
+
+    <variablelist>
+     <varlistentry id="libpq-PQsetAuthDataHook">
+      <term><function>PQsetAuthDataHook</function><indexterm><primary>PQsetAuthDataHook</primary></indexterm></term>
+
+      <listitem>
+       <para>
+        Sets the <symbol>PGauthDataHook</symbol>, overriding
+        <application>libpq</application>'s handling of one or more aspects of
+        its OAuth client flow.
+<synopsis>
+void PQsetAuthDataHook(PQauthDataHook_type hook);
+</synopsis>
+        If <replaceable>hook</replaceable> is <literal>NULL</literal>, the
+        default handler will be reinstalled. Otherwise, the application passes
+        a pointer to a callback function with the signature:
+<programlisting>
+int hook_fn(PGauthData type, PGconn *conn, void *data);
+</programlisting>
+        which <application>libpq</application> will call when an action is
+        required of the application. <replaceable>type</replaceable> describes
+        the request being made, <replaceable>conn</replaceable> is the
+        connection handle being authenticated, and <replaceable>data</replaceable>
+        points to request-specific metadata. The contents of this pointer are
+        determined by <replaceable>type</replaceable>; see
+        <xref linkend="libpq-oauth-authdata-hooks-types"/> for the supported
+        list.
+       </para>
+       <para>
+        Hooks can be chained together to allow cooperative and/or fallback
+        behavior. In general, a hook implementation should examine the incoming
+        <replaceable>type</replaceable> (and, potentially, the request metadata
+        and/or the settings for the particular <replaceable>conn</replaceable>
+        in use) to decide whether or not to handle a specific piece of authdata.
+        If not, it should delegate to the previous hook in the chain
+        (retrievable via <function>PQgetAuthDataHook</function>).
+       </para>
+       <para>
+        Success is indicated by returning an integer greater than zero.
+        Returning a negative integer signals an error condition and abandons the
+        connection attempt. (A zero value is reserved for the default
+        implementation.)
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="libpq-PQgetAuthDataHook">
+      <term><function>PQgetAuthDataHook</function><indexterm><primary>PQgetAuthDataHook</primary></indexterm></term>
+
+      <listitem>
+       <para>
+        Retrieves the current value of <symbol>PGauthDataHook</symbol>.
+<synopsis>
+PQauthDataHook_type PQgetAuthDataHook(void);
+</synopsis>
+        At initialization time (before the first call to
+        <function>PQsetAuthDataHook</function>), this function will return
+        <symbol>PQdefaultAuthDataHook</symbol>.
+       </para>
+      </listitem>
+     </varlistentry>
+    </variablelist>
+   </para>
+
+   <sect3 id="libpq-oauth-authdata-hooks-types">
+    <title>Hook Types</title>
+    <para>
+     The following <symbol>PGauthData</symbol> types and their corresponding
+     <replaceable>data</replaceable> structures are defined:
+
+     <variablelist>
+      <varlistentry id="libpq-oauth-authdata-prompt-oauth-device">
+       <term>
+        <symbol>PQAUTHDATA_PROMPT_OAUTH_DEVICE</symbol>
+        <indexterm><primary>PQAUTHDATA_PROMPT_OAUTH_DEVICE</primary></indexterm>
+       </term>
+       <listitem>
+        <para>
+         Replaces the default user prompt during the builtin device
+         authorization client flow. <replaceable>data</replaceable> points to
+         an instance of <symbol>PGpromptOAuthDevice</symbol>:
+<synopsis>
+typedef struct _PGpromptOAuthDevice
+{
+    const char *verification_uri;   /* verification URI to visit */
+    const char *user_code;          /* user code to enter */
+    const char *verification_uri_complete;  /* optional combination of URI and
+                                             * code, or NULL */
+    int         expires_in;         /* seconds until user code expires */
+} PGpromptOAuthDevice;
+</synopsis>
+        </para>
+        <para>
+         The OAuth Device Authorization flow included in <application>libpq</application>
+         requires the end user to visit a URL with a browser, then enter a code
+         which permits <application>libpq</application> to connect to the server
+         on their behalf. The default prompt simply prints the
+         <literal>verification_uri</literal> and <literal>user_code</literal>
+         on standard error. Replacement implementations may display this
+         information using any preferred method, for example with a GUI.
+        </para>
+        <para>
+         This callback is only invoked during the builtin device
+         authorization flow. If the application installs a
+         <link linkend="libpq-oauth-authdata-oauth-bearer-token">custom OAuth
+         flow</link>, this authdata type will not be used.
+        </para>
+        <para>
+         If a non-NULL <structfield>verification_uri_complete</structfield> is
+         provided, it may optionally be used for non-textual verification (for
+         example, by displaying a QR code). The URL and user code should still
+         be displayed to the end user in this case, because the code will be
+         manually confirmed by the provider, and the URL lets users continue
+         even if they can't use the non-textual method. For more information,
+         see section 3.3.1 in
+         <ulink url="https://datatracker.ietf.org/doc/html/rfc8628#section-3.3.1">RFC 8628</ulink>.
+        </para>
+       </listitem>
+      </varlistentry>
+
+      <varlistentry id="libpq-oauth-authdata-oauth-bearer-token">
+       <term>
+        <symbol>PQAUTHDATA_OAUTH_BEARER_TOKEN</symbol>
+        <indexterm><primary>PQAUTHDATA_OAUTH_BEARER_TOKEN</primary></indexterm>
+       </term>
+       <listitem>
+        <para>
+         Replaces the entire OAuth flow with a custom implementation. The hook
+         should either directly return a Bearer token for the current
+         user/issuer/scope combination, if one is available without blocking, or
+         else set up an asynchronous callback to retrieve one.
+        </para>
+        <para>
+         <replaceable>data</replaceable> points to an instance
+         of <symbol>PGoauthBearerRequest</symbol>, which should be filled in
+         by the implementation:
+<synopsis>
+typedef struct _PGoauthBearerRequest
+{
+    /* Hook inputs (constant across all calls) */
+    const char *const openid_configuration; /* OIDC discovery URL */
+    const char *const scope;                /* required scope(s), or NULL */
+
+    /* Hook outputs */
+
+    /* Callback implementing a custom asynchronous OAuth flow. */
+    PostgresPollingStatusType (*async) (PGconn *conn,
+                                        struct _PGoauthBearerRequest *request,
+                                        SOCKTYPE *altsock);
+
+    /* Callback to clean up custom allocations. */
+    void        (*cleanup) (PGconn *conn, struct _PGoauthBearerRequest *request);
+
+    char       *token;   /* acquired Bearer token */
+    void       *user;    /* hook-defined allocated data */
+} PGoauthBearerRequest;
+</synopsis>
+        </para>
+        <para>
+         Two pieces of information are provided to the hook by
+         <application>libpq</application>:
+         <replaceable>openid_configuration</replaceable> contains the URL of an
+         OAuth discovery document describing the authorization server's
+         supported flows, and <replaceable>scope</replaceable> contains a
+         (possibly empty) space-separated list of OAuth scopes which are
+         required to access the server. Either or both may be
+         <literal>NULL</literal> to indicate that the information was not
+         discoverable. (In this case, implementations may be able to establish
+         the requirements using some other preconfigured knowledge, or they may
+         choose to fail.)
+        </para>
+        <para>
+         The final output of the hook is <replaceable>token</replaceable>, which
+         must point to a valid Bearer token for use on the connection. (This
+         token should be issued by the
+         <xref linkend="libpq-connect-oauth-issuer"/> and hold the requested
+         scopes, or the connection will be rejected by the server's validator
+         module.) The allocated token string must remain valid until
+         <application>libpq</application> is finished connecting; the hook
+         should set a <replaceable>cleanup</replaceable> callback which will be
+         called when <application>libpq</application> no longer requires it.
+        </para>
+        <para>
+         If an implementation cannot immediately produce a
+         <replaceable>token</replaceable> during the initial call to the hook,
+         it should set the <replaceable>async</replaceable> callback to handle
+         nonblocking communication with the authorization server.
+         <footnote>
+          <para>
+           Performing blocking operations during the
+           <symbol>PQAUTHDATA_OAUTH_BEARER_TOKEN</symbol> hook callback will
+           interfere with nonblocking connection APIs such as
+           <function>PQconnectPoll</function> and prevent concurrent connections
+           from making progress. Applications which only ever use the
+           synchronous connection primitives, such as
+           <function>PQconnectdb</function>, may synchronously retrieve a token
+           during the hook instead of implementing the
+           <replaceable>async</replaceable> callback, but they will necessarily
+           be limited to one connection at a time.
+          </para>
+         </footnote>
+         This will be called to begin the flow immediately upon return from the
+         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
+         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>
+         after setting <replaceable>token</replaceable> when the flow is
+         complete, or <symbol>PGRES_POLLING_FAILED</symbol> to indicate failure.
+        </para>
+        <para>
+         Implementations may wish to store additional data for bookkeeping
+         across calls to the <replaceable>async</replaceable> and
+         <replaceable>cleanup</replaceable> callbacks. The
+         <replaceable>user</replaceable> pointer is provided for this purpose;
+         <application>libpq</application> will not touch its contents and the
+         application may use it at its convenience. (Remember to free any
+         allocations during token cleanup.)
+        </para>
+       </listitem>
+      </varlistentry>
+     </variablelist>
+    </para>
+   </sect3>
+  </sect2>
+
+  <sect2 id="libpq-oauth-debugging">
+   <title>Debugging and Developer Settings</title>
+
+   <para>
+    A "dangerous debugging mode" may be enabled by setting the environment
+    variable <envar>PGOAUTHDEBUG=UNSAFE</envar>. This functionality is provided
+    for ease of local development and testing only. It does several things that
+    you will not want a production system to do:
+
+    <itemizedlist spacing="compact">
+     <listitem>
+      <para>
+       permits the use of unencrypted HTTP during the OAuth provider exchange
+      </para>
+     </listitem>
+     <listitem>
+      <para>
+       allows the system's trusted CA list to be completely replaced using the
+       <envar>PGOAUTHCAFILE</envar> environment variable
+      </para>
+     </listitem>
+     <listitem>
+      <para>
+       prints HTTP traffic (containing several critical secrets) to standard
+       error during the OAuth flow
+      </para>
+     </listitem>
+     <listitem>
+      <para>
+       permits the use of zero-second retry intervals, which can cause the
+       client to busy-loop and pointlessly consume CPU
+      </para>
+     </listitem>
+    </itemizedlist>
+   </para>
+   <warning>
+    <para>
+     Do not share the output of the OAuth flow traffic with third parties. It
+     contains secrets that can be used to attack your clients and servers.
+    </para>
+   </warning>
+  </sect2>
+ </sect1>
+
 
  <sect1 id="libpq-threading">
   <title>Behavior in Threaded Programs</title>
@@ -10092,6 +10525,18 @@ int PQisthreadsafe();
    <application>libpq</application> source code for a way to do cooperative
    locking between <application>libpq</application> and your application.
   </para>
+
+  <para>
+   Similarly, if you are using <productname></productname>Curl</productname> inside your application,
+   <emphasis>and</emphasis> you do not already
+   <ulink url="https://curl.se/libcurl/c/curl_global_init.html">initialize
+   libcurl globally</ulink> before starting new threads, you will need to
+   cooperatively lock (again via <function>PQregisterThreadLock</function>)
+   around any code that may initialize libcurl. This restriction is lifted for
+   more recent versions of <productname>Curl</productname> that are built to support thread-safe
+   initialization; those builds can be identified by the advertisement of a
+   <literal>threadsafe</literal> feature in their version metadata.
+  </para>
  </sect1>
 
 
diff --git a/doc/src/sgml/oauth-validators.sgml b/doc/src/sgml/oauth-validators.sgml
new file mode 100644
index 00000000000..e9d28d3daea
--- /dev/null
+++ b/doc/src/sgml/oauth-validators.sgml
@@ -0,0 +1,414 @@
+<!-- doc/src/sgml/oauth-validators.sgml -->
+
+<chapter id="oauth-validators">
+ <title>OAuth Validator Modules</title>
+ <indexterm zone="oauth-validators">
+  <primary>OAuth Validators</primary>
+ </indexterm>
+ <para>
+  <productname>PostgreSQL</productname> provides infrastructure for creating
+  custom modules to perform server-side validation of OAuth bearer tokens.
+  Because OAuth implementations vary so wildly, and bearer token validation is
+  heavily dependent on the issuing party, the server cannot check the token
+  itself; validator modules provide the integration layer between the server
+  and the OAuth provider in use.
+ </para>
+ <para>
+  OAuth validator modules must at least consist of an initialization function
+  (see <xref linkend="oauth-validator-init"/>) and the required callback for
+  performing validation (see <xref linkend="oauth-validator-callback-validate"/>).
+ </para>
+ <warning>
+  <para>
+   Since a misbehaving validator might let unauthorized users into the database,
+   validating the correctness of the implementation is critical. See
+   <xref linkend="oauth-validator-design"/> for design considerations.
+  </para>
+ </warning>
+
+ <sect1 id="oauth-validator-design">
+  <title>Safely Designing a Validator Module</title>
+  <warning>
+   <para>
+    Read and understand the entirety of this section before implementing a
+    validator module. A malfunctioning validator is potentially worse than no
+    authentication at all, both because of the false sense of security it
+    provides, and because it may contribute to attacks against other pieces of
+    an OAuth ecosystem.
+   </para>
+  </warning>
+
+  <sect2 id="oauth-validator-design-responsibilities">
+   <title>Validator Responsibilities</title>
+   <para>
+    Although different modules may take very different approaches to token
+    validation, implementations generally need to perform three separate
+    actions:
+   </para>
+   <variablelist>
+    <varlistentry>
+     <term>Validate the Token</term>
+     <listitem>
+      <para>
+       The validator must first ensure that the presented token is in fact a
+       valid Bearer token for use in client authentication. The correct way to
+       do this depends on the provider, but it generally involves either
+       cryptographic operations to prove that the token was created by a trusted
+       party (offline validation), or the presentation of the token to that
+       trusted party so that it can perform validation for you (online
+       validation).
+      </para>
+      <para>
+       Online validation, usually implemented via
+       <ulink url="https://datatracker.ietf.org/doc/html/rfc7662">OAuth Token
+       Introspection</ulink>, requires fewer steps of a validator module and
+       allows central revocation of a token in the event that it is stolen
+       or misissued. However, it does require the module to make at least one
+       network call per authentication attempt (all of which must complete
+       within the configured <xref linkend="guc-authentication-timeout"/>).
+       Additionally, your provider may not provide introspection endpoints for
+       use by external resource servers.
+      </para>
+      <para>
+       Offline validation is much more involved, typically requiring a validator
+       to maintain a list of trusted signing keys for a provider and then
+       check the token's cryptographic signature along with its contents.
+       Implementations must follow the provider's instructions to the letter,
+       including any verification of issuer ("where is this token from?"),
+       audience ("who is this token for?"), and validity period ("when can this
+       token be used?"). Since there is no communication between the module and
+       the provider, tokens cannot be centrally revoked using this method;
+       offline validator implementations may wish to place restrictions on the
+       maximum length of a token's validity period.
+      </para>
+      <para>
+       If the token cannot be validated, the module should immediately fail.
+       Further authentication/authorization is pointless if the bearer token
+       wasn't issued by a trusted party.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Authorize the Client</term>
+     <listitem>
+      <para>
+       Next the validator must ensure that the end user has given the client
+       permission to access the server on their behalf. This generally involves
+       checking the scopes that have been assigned to the token, to make sure
+       that they cover database access for the current HBA parameters.
+      </para>
+      <para>
+       The purpose of this step is to prevent an OAuth client from obtaining a
+       token under false pretenses. If the validator requires all tokens to
+       carry scopes that cover database access, the provider should then loudly
+       prompt the user to grant that access during the flow. This gives them the
+       opportunity to reject the request if the client isn't supposed to be
+       using their credentials to connect to databases.
+      </para>
+      <para>
+       While it is possible to establish client authorization without explicit
+       scopes by using out-of-band knowledge of the deployed architecture, doing
+       so removes the user from the loop, which prevents them from catching
+       deployment mistakes and allows any such mistakes to be exploited
+       silently. Access to the database must be tightly restricted to only
+       trusted clients
+       <footnote>
+        <para>
+         That is, "trusted" in the sense that the OAuth client and the
+         <productname>PostgreSQL</productname> server are controlled by the same
+         entity. Notably, the Device Authorization client flow supported by
+         libpq does not usually meet this bar, since it's designed for use by
+         public/untrusted clients.
+        </para>
+       </footnote>
+       if users are not prompted for additional scopes.
+      </para>
+      <para>
+       Even if authorization fails, a module may choose to continue to pull
+       authentication information from the token for use in auditing and
+       debugging.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Authenticate the End User</term>
+     <listitem>
+      <para>
+       Finally, the validator should determine a user identifier for the token,
+       either by asking the provider for this information or by extracting it
+       from the token itself, and return that identifier to the server (which
+       will then make a final authorization decision using the HBA
+       configuration). This identifier will be available within the session via
+       <link linkend="functions-info-session-table"><function>system_user</function></link>
+       and recorded in the server logs if <xref linkend="guc-log-connections"/>
+       is enabled.
+      </para>
+      <para>
+       Different providers may record a variety of different authentication
+       information for an end user, typically referred to as
+       <emphasis>claims</emphasis>. Providers usually document which of these
+       claims are trustworthy enough to use for authorization decisions and
+       which are not. (For instance, it would probably not be wise to use an
+       end user's full name as the identifier for authentication, since many
+       providers allow users to change their display names arbitrarily.)
+       Ultimately, the choice of which claim (or combination of claims) to use
+       comes down to the provider implementation and application requirements.
+      </para>
+      <para>
+       Note that anonymous/pseudonymous login is possible as well, by enabling
+       usermap delegation; see
+       <xref linkend="oauth-validator-design-usermap-delegation"/>.
+      </para>
+     </listitem>
+    </varlistentry>
+   </variablelist>
+  </sect2>
+
+  <sect2 id="oauth-validator-design-guidelines">
+   <title>General Coding Guidelines</title>
+   <para>
+    Developers should keep the following in mind when implementing token
+    validation:
+   </para>
+   <variablelist>
+    <varlistentry>
+     <term>Token Confidentiality</term>
+     <listitem>
+      <para>
+       Modules should not write tokens, or pieces of tokens, into the server
+       log. This is true even if the module considers the token invalid; an
+       attacker who confuses a client into communicating with the wrong provider
+       should not be able to retrieve that (otherwise valid) token from the
+       disk.
+      </para>
+      <para>
+       Implementations that send tokens over the network (for example, to
+       perform online token validation with a provider) must authenticate the
+       peer and ensure that strong transport security is in use.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Logging</term>
+     <listitem>
+      <para>
+       Modules may use the same <link linkend="error-message-reporting">logging
+       facilities</link> as standard extensions; however, the rules for emitting
+       log entries to the client are subtly different during the authentication
+       phase of the connection. Generally speaking, modules should log
+       verification problems at the <symbol>COMMERROR</symbol> level and return
+       normally, instead of using <symbol>ERROR</symbol>/<symbol>FATAL</symbol>
+       to unwind the stack, to avoid leaking information to unauthenticated
+       clients.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Interruptibility</term>
+     <listitem>
+      <para>
+       Modules must remain interruptible by signals so that the server can
+       correctly handle authentication timeouts and shutdown signals from
+       <application>pg_ctl</application>. For example, a module receiving
+       <symbol>EINTR</symbol>/<symbol>EAGAIN</symbol> from a blocking call
+       should call <function>CHECK_FOR_INTERRUPTS()</function> before retrying.
+       The same should be done during any long-running loops. Failure to follow
+       this guidance may result in unresponsive backend sessions.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Testing</term>
+     <listitem>
+      <para>
+       The breadth of testing an OAuth system is well beyond the scope of this
+       documentation, but at minimum, negative testing should be considered
+       mandatory. It's trivial to design a module that lets authorized users in;
+       the whole point of the system is to keep unauthorized users out.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Documentation</term>
+     <listitem>
+      <para>
+       Validator implementations should document the contents and format of the
+       authenticated ID that is reported to the server for each end user, since
+       DBAs may need to use this information to construct pg_ident maps. (For
+       instance, is it an email address? an organizational ID number? a UUID?)
+       They should also document whether or not it is safe to use the module in
+       <symbol>delegate_ident_mapping=1</symbol> mode, and what additional
+       configuration is required in order to do so.
+      </para>
+     </listitem>
+    </varlistentry>
+   </variablelist>
+  </sect2>
+
+  <sect2 id="oauth-validator-design-usermap-delegation">
+   <title>Authorizing Users (Usermap Delegation)</title>
+   <para>
+    The standard deliverable of a validation module is the user identifier,
+    which the server will then compare to any configured
+    <link linkend="auth-username-maps"><filename>pg_ident.conf</filename>
+    mappings</link> and determine whether the end user is authorized to connect.
+    However, OAuth is itself an authorization framework, and tokens may carry
+    information about user privileges. For example, a token may be associated
+    with the organizational groups that a user belongs to, or list the roles
+    that a user may assume, and duplicating that knowledge into local usermaps
+    for every server may not be desirable.
+   </para>
+   <para>
+    To bypass username mapping entirely, and have the validator module assume
+    the additional responsibility of authorizing user connections, the HBA may
+    be configured with <xref linkend="auth-oauth-delegate-ident-mapping"/>.
+    The module may then use token scopes or an equivalent method to decide
+    whether the user is allowed to connect under their desired role. The user
+    identifier will still be recorded by the server, but it plays no part in
+    determining whether to continue the connection.
+   </para>
+   <para>
+    Using this scheme, authentication itself is optional. As long as the module
+    reports that the connection is authorized, login will continue even if there
+    is no recorded user identifier at all. This makes it possible to implement
+    anonymous or pseudonymous access to the database, where the third-party
+    provider performs all necessary authentication but does not provide any
+    user-identifying information to the server. (Some providers may create an
+    anonymized ID number that can be recorded instead, for later auditing.)
+   </para>
+   <para>
+    Usermap delegation provides the most architectural flexibility, but it turns
+    the validator module into a single point of failure for connection
+    authorization. Use with caution.
+   </para>
+  </sect2>
+ </sect1>
+
+ <sect1 id="oauth-validator-init">
+  <title>Initialization Functions</title>
+  <indexterm zone="oauth-validator-init">
+   <primary>_PG_oauth_validator_module_init</primary>
+  </indexterm>
+  <para>
+   OAuth validator modules are dynamically loaded from the shared
+   libraries listed in <xref linkend="guc-oauth-validator-libraries"/>.
+   Modules are loaded on demand when requested from a login in progress.
+   The normal library search path is used to locate the library. To
+   provide the validator callbacks and to indicate that the library is an OAuth
+   validator module a function named
+   <function>_PG_oauth_validator_module_init</function> must be provided. The
+   return value of the function must be a pointer to a struct of type
+   <structname>OAuthValidatorCallbacks</structname>, which contains a magic
+   number and pointers to the module's token validation functions. The returned
+   pointer must be of server lifetime, which is typically achieved by defining
+   it as a <literal>static const</literal> variable in global scope.
+<programlisting>
+typedef struct OAuthValidatorCallbacks
+{
+    uint32        magic;            /* must be set to PG_OAUTH_VALIDATOR_MAGIC */
+
+    ValidatorStartupCB startup_cb;
+    ValidatorShutdownCB shutdown_cb;
+    ValidatorValidateCB validate_cb;
+} OAuthValidatorCallbacks;
+
+typedef const OAuthValidatorCallbacks *(*OAuthValidatorModuleInit) (void);
+</programlisting>
+
+   Only the <function>validate_cb</function> callback is required, the others
+   are optional.
+  </para>
+ </sect1>
+
+ <sect1 id="oauth-validator-callbacks">
+  <title>OAuth Validator Callbacks</title>
+  <para>
+   OAuth validator modules implement their functionality by defining a set of
+   callbacks. The server will call them as required to process the
+   authentication request from the user.
+  </para>
+
+  <sect2 id="oauth-validator-callback-startup">
+   <title>Startup Callback</title>
+   <para>
+    The <function>startup_cb</function> callback is executed directly after
+    loading the module. This callback can be used to set up local state and
+    perform additional initialization if required. If the validator module
+    has state it can use <structfield>state->private_data</structfield> to
+    store it.
+
+<programlisting>
+typedef void (*ValidatorStartupCB) (ValidatorModuleState *state);
+</programlisting>
+   </para>
+  </sect2>
+
+  <sect2 id="oauth-validator-callback-validate">
+   <title>Validate Callback</title>
+   <para>
+    The <function>validate_cb</function> callback is executed during the OAuth
+    exchange when a user attempts to authenticate using OAuth.  Any state set in
+    previous calls will be available in <structfield>state->private_data</structfield>.
+
+<programlisting>
+typedef bool (*ValidatorValidateCB) (const ValidatorModuleState *state,
+                                     const char *token, const char *role,
+                                     ValidatorModuleResult *result);
+</programlisting>
+
+    <replaceable>token</replaceable> will contain the bearer token to validate.
+    <application>libpq</application> has ensured that the token is well-formed syntactically, but no
+    other validation has been performed.  <replaceable>role</replaceable> will
+    contain the role the user has requested to log in as.  The callback must
+    set output parameters in the <literal>result</literal> struct, which is
+    defined as below:
+
+<programlisting>
+typedef struct ValidatorModuleResult
+{
+    bool        authorized;
+    char       *authn_id;
+} ValidatorModuleResult;
+</programlisting>
+
+    The connection will only proceed if the module sets
+    <structfield>result->authorized</structfield> to <literal>true</literal>.  To
+    authenticate the user, the authenticated user name (as determined using the
+    token) shall be palloc'd and returned in the <structfield>result->authn_id</structfield>
+    field.  Alternatively, <structfield>result->authn_id</structfield> may be set to
+    NULL if the token is valid but the associated user identity cannot be
+    determined.
+   </para>
+   <para>
+    A validator may return <literal>false</literal> to signal an internal error,
+    in which case any result parameters are ignored and the connection fails.
+    Otherwise the validator should return <literal>true</literal> to indicate
+    that it has processed the token and made an authorization decision.
+   </para>
+   <para>
+    The behavior after <function>validate_cb</function> returns depends on the
+    specific HBA setup.  Normally, the <structfield>result->authn_id</structfield> user
+    name must exactly match the role that the user is logging in as.  (This
+    behavior may be modified with a usermap.)  But when authenticating against
+    an HBA rule with <literal>delegate_ident_mapping</literal> turned on,
+    <productname>PostgreSQL</productname> will not perform any checks on the value of
+    <structfield>result->authn_id</structfield> at all; in this case it is up to the
+    validator to ensure that the token carries enough privileges for the user to
+    log in under the indicated <replaceable>role</replaceable>.
+   </para>
+  </sect2>
+
+  <sect2 id="oauth-validator-callback-shutdown">
+   <title>Shutdown Callback</title>
+   <para>
+    The <function>shutdown_cb</function> callback is executed when the backend
+    process associated with the connection exits. If the validator module has
+    any allocated state, this callback should free it to avoid resource leaks.
+<programlisting>
+typedef void (*ValidatorShutdownCB) (ValidatorModuleState *state);
+</programlisting>
+   </para>
+  </sect2>
+
+ </sect1>
+</chapter>
diff --git a/doc/src/sgml/postgres.sgml b/doc/src/sgml/postgres.sgml
index 7be25c58507..af476c82fcc 100644
--- a/doc/src/sgml/postgres.sgml
+++ b/doc/src/sgml/postgres.sgml
@@ -229,6 +229,7 @@ break is not needed in a wider output rendering.
   &logicaldecoding;
   &replication-origins;
   &archive-modules;
+  &oauth-validators;
 
  </part>
 
diff --git a/doc/src/sgml/protocol.sgml b/doc/src/sgml/protocol.sgml
index fb5dec1172e..3bd9e68e6ce 100644
--- a/doc/src/sgml/protocol.sgml
+++ b/doc/src/sgml/protocol.sgml
@@ -1688,11 +1688,11 @@ SELCT 1/0;<!-- this typo is intentional -->
 
   <para>
    <firstterm>SASL</firstterm> is a framework for authentication in connection-oriented
-   protocols. At the moment, <productname>PostgreSQL</productname> implements two SASL
-   authentication mechanisms, SCRAM-SHA-256 and SCRAM-SHA-256-PLUS. More
-   might be added in the future. The below steps illustrate how SASL
-   authentication is performed in general, while the next subsection gives
-   more details on SCRAM-SHA-256 and SCRAM-SHA-256-PLUS.
+   protocols. At the moment, <productname>PostgreSQL</productname> implements three
+   SASL authentication mechanisms: SCRAM-SHA-256, SCRAM-SHA-256-PLUS, and
+   OAUTHBEARER. More might be added in the future. The below steps illustrate how SASL
+   authentication is performed in general, while the next subsections give
+   more details on particular mechanisms.
   </para>
 
   <procedure>
@@ -1727,7 +1727,7 @@ SELCT 1/0;<!-- this typo is intentional -->
    <step id="sasl-auth-end">
     <para>
      Finally, when the authentication exchange is completed successfully, the
-     server sends an AuthenticationSASLFinal message, followed
+     server sends an optional AuthenticationSASLFinal message, followed
      immediately by an AuthenticationOk message. The AuthenticationSASLFinal
      contains additional server-to-client data, whose content is particular to the
      selected authentication mechanism. If the authentication mechanism doesn't
@@ -1746,9 +1746,9 @@ SELCT 1/0;<!-- this typo is intentional -->
    <title>SCRAM-SHA-256 Authentication</title>
 
    <para>
-    The implemented SASL mechanisms at the moment
-    are <literal>SCRAM-SHA-256</literal> and its variant with channel
-    binding <literal>SCRAM-SHA-256-PLUS</literal>. They are described in
+    <literal>SCRAM-SHA-256</literal>, and its variant with channel
+    binding <literal>SCRAM-SHA-256-PLUS</literal>, are password-based
+    authentication mechanisms. They are described in
     detail in <ulink url="https://datatracker.ietf.org/doc/html/rfc7677">RFC 7677</ulink>
     and <ulink url="https://datatracker.ietf.org/doc/html/rfc5802">RFC 5802</ulink>.
    </para>
@@ -1850,6 +1850,121 @@ SELCT 1/0;<!-- this typo is intentional -->
     </step>
    </procedure>
   </sect2>
+
+  <sect2 id="sasl-oauthbearer">
+   <title>OAUTHBEARER Authentication</title>
+
+   <para>
+    <literal>OAUTHBEARER</literal> is a token-based mechanism for federated
+    authentication. It is described in detail in
+    <ulink url="https://datatracker.ietf.org/doc/html/rfc7628">RFC 7628</ulink>.
+   </para>
+
+   <para>
+    A typical exchange differs depending on whether or not the client already
+    has a bearer token cached for the current user. If it does not, the exchange
+    will take place over two connections: the first "discovery" connection to
+    obtain OAuth metadata from the server, and the second connection to send
+    the token after the client has obtained it. (libpq does not currently
+    implement a caching method as part of its builtin flow, so it uses the
+    two-connection exchange.)
+   </para>
+
+   <para>
+    This mechanism is client-initiated, like SCRAM. The client initial response
+    consists of the standard "GS2" header used by SCRAM, followed by a list of
+    <literal>key=value</literal> pairs. The only key currently supported by
+    the server is <literal>auth</literal>, which contains the bearer token.
+    <literal>OAUTHBEARER</literal> additionally specifies three optional
+    components of the client initial response (the <literal>authzid</literal> of
+    the GS2 header, and the <structfield>host</structfield> and
+    <structfield>port</structfield> keys) which are currently ignored by the
+    server.
+   </para>
+
+   <para>
+    <literal>OAUTHBEARER</literal> does not support channel binding, and there
+    is no "OAUTHBEARER-PLUS" mechanism. This mechanism does not make use of
+    server data during a successful authentication, so the
+    AuthenticationSASLFinal message is not used in the exchange.
+   </para>
+
+   <procedure>
+    <title>Example</title>
+    <step>
+     <para>
+      During the first exchange, the server sends an AuthenticationSASL message
+      with the <literal>OAUTHBEARER</literal> mechanism advertised.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      The client responds by sending a SASLInitialResponse message which
+      indicates the <literal>OAUTHBEARER</literal> mechanism. Assuming the
+      client does not already have a valid bearer token for the current user,
+      the <structfield>auth</structfield> field is empty, indicating a discovery
+      connection.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      Server sends an AuthenticationSASLContinue message containing an error
+      <literal>status</literal> alongside a well-known URI and scopes that the
+      client should use to conduct an OAuth flow.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      Client sends a SASLResponse message containing the empty set (a single
+      <literal>0x01</literal> byte) to finish its half of the discovery
+      exchange.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      Server sends an ErrorMessage to fail the first exchange.
+     </para>
+     <para>
+      At this point, the client conducts one of many possible OAuth flows to
+      obtain a bearer token, using any metadata that it has been configured with
+      in addition to that provided by the server. (This description is left
+      deliberately vague; <literal>OAUTHBEARER</literal> does not specify or
+      mandate any particular method for obtaining a token.)
+     </para>
+     <para>
+      Once it has a token, the client reconnects to the server for the final
+      exchange:
+     </para>
+    </step>
+
+    <step>
+     <para>
+      The server once again sends an AuthenticationSASL message with the
+      <literal>OAUTHBEARER</literal> mechanism advertised.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      The client responds by sending a SASLInitialResponse message, but this
+      time the <structfield>auth</structfield> field in the message contains the
+      bearer token that was obtained during the client flow.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      The server validates the token according to the instructions of the
+      token provider. If the client is authorized to connect, it sends an
+      AuthenticationOk message to end the SASL exchange.
+     </para>
+    </step>
+   </procedure>
+  </sect2>
  </sect1>
 
  <sect1 id="protocol-replication">
diff --git a/doc/src/sgml/regress.sgml b/doc/src/sgml/regress.sgml
index 7c474559bdf..0e5e8e8f309 100644
--- a/doc/src/sgml/regress.sgml
+++ b/doc/src/sgml/regress.sgml
@@ -347,6 +347,16 @@ make check-world PG_TEST_EXTRA='kerberos ldap ssl load_balance libpq_encryption'
       </para>
      </listitem>
     </varlistentry>
+
+    <varlistentry>
+     <term><literal>oauth</literal></term>
+     <listitem>
+      <para>
+       Runs the test suite under <filename>src/test/modules/oauth_validator</filename>.
+       This opens TCP/IP listen sockets for a test-server running HTTPS.
+      </para>
+     </listitem>
+    </varlistentry>
    </variablelist>
 
    Tests for features that are not supported by the current build
diff --git a/meson.build b/meson.build
index 7dd7110318d..574f992ed49 100644
--- a/meson.build
+++ b/meson.build
@@ -855,6 +855,101 @@ endif
 
 
 
+###############################################################
+# Library: libcurl
+###############################################################
+
+libcurlopt = get_option('libcurl')
+if not libcurlopt.disabled()
+  # Check for libcurl 7.61.0 or higher (corresponding to RHEL8 and the ability
+  # to explicitly set TLS 1.3 ciphersuites).
+  libcurl = dependency('libcurl', version: '>= 7.61.0', required: libcurlopt)
+  if libcurl.found()
+    cdata.set('USE_LIBCURL', 1)
+
+    # Check to see whether the current platform supports thread-safe Curl
+    # initialization.
+    libcurl_threadsafe_init = false
+
+    if not meson.is_cross_build()
+      r = cc.run('''
+        #include <curl/curl.h>
+
+        int main(void)
+        {
+            curl_version_info_data *info;
+
+            if (curl_global_init(CURL_GLOBAL_ALL))
+                return -1;
+
+            info = curl_version_info(CURLVERSION_NOW);
+        #ifdef CURL_VERSION_THREADSAFE
+            if (info->features & CURL_VERSION_THREADSAFE)
+                return 0;
+        #endif
+
+            return 1;
+        }''',
+        name: 'test for curl_global_init thread safety',
+        dependencies: libcurl,
+      )
+
+      assert(r.compiled())
+      if r.returncode() == 0
+        libcurl_threadsafe_init = true
+        message('curl_global_init is thread-safe')
+      elif r.returncode() == 1
+        message('curl_global_init is not thread-safe')
+      else
+        message('curl_global_init failed; assuming not thread-safe')
+      endif
+    endif
+
+    if libcurl_threadsafe_init
+      cdata.set('HAVE_THREADSAFE_CURL_GLOBAL_INIT', 1)
+    endif
+
+    # Warn if a thread-friendly DNS resolver isn't built.
+    libcurl_async_dns = false
+
+    if not meson.is_cross_build()
+      r = cc.run('''
+        #include <curl/curl.h>
+
+        int main(void)
+        {
+            curl_version_info_data *info;
+
+            if (curl_global_init(CURL_GLOBAL_ALL))
+                return -1;
+
+            info = curl_version_info(CURLVERSION_NOW);
+            return (info->features & CURL_VERSION_ASYNCHDNS) ? 0 : 1;
+        }''',
+        name: 'test for curl support for asynchronous DNS',
+        dependencies: libcurl,
+      )
+
+      assert(r.compiled())
+      if r.returncode() == 0
+        libcurl_async_dns = true
+      endif
+    endif
+
+    if not libcurl_async_dns
+      warning('''
+*** The installed version of libcurl does not support asynchronous DNS
+*** lookups. Connection timeouts will not be honored during DNS resolution,
+*** which may lead to hangs in client programs.''')
+    endif
+  endif
+
+else
+  libcurl = not_found_dep
+endif
+
+
+
 ###############################################################
 # Library: libxml
 ###############################################################
@@ -3045,6 +3140,10 @@ libpq_deps += [
 
   gssapi,
   ldap_r,
+  # XXX libcurl must link after libgssapi_krb5 on FreeBSD to avoid segfaults
+  # during gss_acquire_cred(). This is possibly related to Curl's Heimdal
+  # dependency on that platform?
+  libcurl,
   libintl,
   ssl,
 ]
@@ -3721,6 +3820,7 @@ if meson.version().version_compare('>=0.57')
       'gss': gssapi,
       'icu': icu,
       'ldap': ldap,
+      'libcurl': libcurl,
       'libxml': libxml,
       'libxslt': libxslt,
       'llvm': llvm,
diff --git a/meson_options.txt b/meson_options.txt
index d9c7ddccbc4..702c4517145 100644
--- a/meson_options.txt
+++ b/meson_options.txt
@@ -100,6 +100,9 @@ option('icu', type: 'feature', value: 'auto',
 option('ldap', type: 'feature', value: 'auto',
   description: 'LDAP support')
 
+option('libcurl', type : 'feature', value: 'auto',
+  description: 'libcurl support')
+
 option('libedit_preferred', type: 'boolean', value: false,
   description: 'Prefer BSD Libedit over GNU Readline')
 
diff --git a/src/Makefile.global.in b/src/Makefile.global.in
index bbe11e75bf0..3b620bac5ac 100644
--- a/src/Makefile.global.in
+++ b/src/Makefile.global.in
@@ -190,6 +190,7 @@ with_systemd	= @with_systemd@
 with_gssapi	= @with_gssapi@
 with_krb_srvnam	= @with_krb_srvnam@
 with_ldap	= @with_ldap@
+with_libcurl	= @with_libcurl@
 with_libxml	= @with_libxml@
 with_libxslt	= @with_libxslt@
 with_llvm	= @with_llvm@
diff --git a/src/backend/libpq/Makefile b/src/backend/libpq/Makefile
index 6d385fd6a45..98eb2a8242d 100644
--- a/src/backend/libpq/Makefile
+++ b/src/backend/libpq/Makefile
@@ -15,6 +15,7 @@ include $(top_builddir)/src/Makefile.global
 # be-fsstubs is here for historical reasons, probably belongs elsewhere
 
 OBJS = \
+	auth-oauth.o \
 	auth-sasl.o \
 	auth-scram.o \
 	auth.o \
diff --git a/src/backend/libpq/auth-oauth.c b/src/backend/libpq/auth-oauth.c
new file mode 100644
index 00000000000..830f2002683
--- /dev/null
+++ b/src/backend/libpq/auth-oauth.c
@@ -0,0 +1,894 @@
+/*-------------------------------------------------------------------------
+ *
+ * auth-oauth.c
+ *	  Server-side implementation of the SASL OAUTHBEARER mechanism.
+ *
+ * See the following RFC for more details:
+ * - RFC 7628: https://datatracker.ietf.org/doc/html/rfc7628
+ *
+ * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/backend/libpq/auth-oauth.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include <unistd.h>
+#include <fcntl.h>
+
+#include "common/oauth-common.h"
+#include "fmgr.h"
+#include "lib/stringinfo.h"
+#include "libpq/auth.h"
+#include "libpq/hba.h"
+#include "libpq/oauth.h"
+#include "libpq/sasl.h"
+#include "storage/fd.h"
+#include "storage/ipc.h"
+#include "utils/json.h"
+#include "utils/varlena.h"
+
+/* GUC */
+char	   *oauth_validator_libraries_string = NULL;
+
+static void oauth_get_mechanisms(Port *port, StringInfo buf);
+static void *oauth_init(Port *port, const char *selected_mech, const char *shadow_pass);
+static int	oauth_exchange(void *opaq, const char *input, int inputlen,
+						   char **output, int *outputlen, const char **logdetail);
+
+static void load_validator_library(const char *libname);
+static void shutdown_validator_library(void *arg);
+
+static ValidatorModuleState *validator_module_state;
+static const OAuthValidatorCallbacks *ValidatorCallbacks;
+
+/* Mechanism declaration */
+const pg_be_sasl_mech pg_be_oauth_mech = {
+	.get_mechanisms = oauth_get_mechanisms,
+	.init = oauth_init,
+	.exchange = oauth_exchange,
+
+	.max_message_length = PG_MAX_AUTH_TOKEN_LENGTH,
+};
+
+/* Valid states for the oauth_exchange() machine. */
+enum oauth_state
+{
+	OAUTH_STATE_INIT = 0,
+	OAUTH_STATE_ERROR,
+	OAUTH_STATE_FINISHED,
+};
+
+/* Mechanism callback state. */
+struct oauth_ctx
+{
+	enum oauth_state state;
+	Port	   *port;
+	const char *issuer;
+	const char *scope;
+};
+
+static char *sanitize_char(char c);
+static char *parse_kvpairs_for_auth(char **input);
+static void generate_error_response(struct oauth_ctx *ctx, char **output, int *outputlen);
+static bool validate(Port *port, const char *auth);
+
+/* Constants seen in an OAUTHBEARER client initial response. */
+#define KVSEP 0x01				/* separator byte for key/value pairs */
+#define AUTH_KEY "auth"			/* key containing the Authorization header */
+#define BEARER_SCHEME "Bearer " /* required header scheme (case-insensitive!) */
+
+/*
+ * Retrieves the OAUTHBEARER mechanism list (currently a single item).
+ *
+ * For a full description of the API, see libpq/sasl.h.
+ */
+static void
+oauth_get_mechanisms(Port *port, StringInfo buf)
+{
+	/* Only OAUTHBEARER is supported. */
+	appendStringInfoString(buf, OAUTHBEARER_NAME);
+	appendStringInfoChar(buf, '\0');
+}
+
+/*
+ * Initializes mechanism state and loads the configured validator module.
+ *
+ * For a full description of the API, see libpq/sasl.h.
+ */
+static void *
+oauth_init(Port *port, const char *selected_mech, const char *shadow_pass)
+{
+	struct oauth_ctx *ctx;
+
+	if (strcmp(selected_mech, OAUTHBEARER_NAME) != 0)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("client selected an invalid SASL authentication mechanism"));
+
+	ctx = palloc0(sizeof(*ctx));
+
+	ctx->state = OAUTH_STATE_INIT;
+	ctx->port = port;
+
+	Assert(port->hba);
+	ctx->issuer = port->hba->oauth_issuer;
+	ctx->scope = port->hba->oauth_scope;
+
+	load_validator_library(port->hba->oauth_validator);
+
+	return ctx;
+}
+
+/*
+ * Implements the OAUTHBEARER SASL exchange (RFC 7628, Sec. 3.2). This pulls
+ * apart the client initial response and validates the Bearer token. It also
+ * handles the dummy error response for a failed handshake, as described in
+ * Sec. 3.2.3.
+ *
+ * For a full description of the API, see libpq/sasl.h.
+ */
+static int
+oauth_exchange(void *opaq, const char *input, int inputlen,
+			   char **output, int *outputlen, const char **logdetail)
+{
+	char	   *input_copy;
+	char	   *p;
+	char		cbind_flag;
+	char	   *auth;
+	int			status;
+
+	struct oauth_ctx *ctx = opaq;
+
+	*output = NULL;
+	*outputlen = -1;
+
+	/*
+	 * If the client didn't include an "Initial Client Response" in the
+	 * SASLInitialResponse message, send an empty challenge, to which the
+	 * client will respond with the same data that usually comes in the
+	 * Initial Client Response.
+	 */
+	if (input == NULL)
+	{
+		Assert(ctx->state == OAUTH_STATE_INIT);
+
+		*output = pstrdup("");
+		*outputlen = 0;
+		return PG_SASL_EXCHANGE_CONTINUE;
+	}
+
+	/*
+	 * Check that the input length agrees with the string length of the input.
+	 */
+	if (inputlen == 0)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("The message is empty."));
+	if (inputlen != strlen(input))
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message length does not match input length."));
+
+	switch (ctx->state)
+	{
+		case OAUTH_STATE_INIT:
+			/* Handle this case below. */
+			break;
+
+		case OAUTH_STATE_ERROR:
+
+			/*
+			 * Only one response is valid for the client during authentication
+			 * failure: a single kvsep.
+			 */
+			if (inputlen != 1 || *input != KVSEP)
+				ereport(ERROR,
+						errcode(ERRCODE_PROTOCOL_VIOLATION),
+						errmsg("malformed OAUTHBEARER message"),
+						errdetail("Client did not send a kvsep response."));
+
+			/* The (failed) handshake is now complete. */
+			ctx->state = OAUTH_STATE_FINISHED;
+			return PG_SASL_EXCHANGE_FAILURE;
+
+		default:
+			elog(ERROR, "invalid OAUTHBEARER exchange state");
+			return PG_SASL_EXCHANGE_FAILURE;
+	}
+
+	/* Handle the client's initial message. */
+	p = input_copy = pstrdup(input);
+
+	/*
+	 * OAUTHBEARER does not currently define a channel binding (so there is no
+	 * OAUTHBEARER-PLUS, and we do not accept a 'p' specifier). We accept a
+	 * 'y' specifier purely for the remote chance that a future specification
+	 * could define one; then future clients can still interoperate with this
+	 * server implementation. 'n' is the expected case.
+	 */
+	cbind_flag = *p;
+	switch (cbind_flag)
+	{
+		case 'p':
+			ereport(ERROR,
+					errcode(ERRCODE_PROTOCOL_VIOLATION),
+					errmsg("malformed OAUTHBEARER message"),
+					errdetail("The server does not support channel binding for OAuth, but the client message includes channel binding data."));
+			break;
+
+		case 'y':				/* fall through */
+		case 'n':
+			p++;
+			if (*p != ',')
+				ereport(ERROR,
+						errcode(ERRCODE_PROTOCOL_VIOLATION),
+						errmsg("malformed OAUTHBEARER message"),
+						errdetail("Comma expected, but found character \"%s\".",
+								  sanitize_char(*p)));
+			p++;
+			break;
+
+		default:
+			ereport(ERROR,
+					errcode(ERRCODE_PROTOCOL_VIOLATION),
+					errmsg("malformed OAUTHBEARER message"),
+					errdetail("Unexpected channel-binding flag \"%s\".",
+							  sanitize_char(cbind_flag)));
+	}
+
+	/*
+	 * Forbid optional authzid (authorization identity).  We don't support it.
+	 */
+	if (*p == 'a')
+		ereport(ERROR,
+				errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				errmsg("client uses authorization identity, but it is not supported"));
+	if (*p != ',')
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Unexpected attribute \"%s\" in client-first-message.",
+						  sanitize_char(*p)));
+	p++;
+
+	/* All remaining fields are separated by the RFC's kvsep (\x01). */
+	if (*p != KVSEP)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Key-value separator expected, but found character \"%s\".",
+						  sanitize_char(*p)));
+	p++;
+
+	auth = parse_kvpairs_for_auth(&p);
+	if (!auth)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message does not contain an auth value."));
+
+	/* We should be at the end of our message. */
+	if (*p)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message contains additional data after the final terminator."));
+
+	if (!validate(ctx->port, auth))
+	{
+		generate_error_response(ctx, output, outputlen);
+
+		ctx->state = OAUTH_STATE_ERROR;
+		status = PG_SASL_EXCHANGE_CONTINUE;
+	}
+	else
+	{
+		ctx->state = OAUTH_STATE_FINISHED;
+		status = PG_SASL_EXCHANGE_SUCCESS;
+	}
+
+	/* Don't let extra copies of the bearer token hang around. */
+	explicit_bzero(input_copy, inputlen);
+
+	return status;
+}
+
+/*
+ * Convert an arbitrary byte to printable form.  For error messages.
+ *
+ * If it's a printable ASCII character, print it as a single character.
+ * otherwise, print it in hex.
+ *
+ * The returned pointer points to a static buffer.
+ */
+static char *
+sanitize_char(char c)
+{
+	static char buf[5];
+
+	if (c >= 0x21 && c <= 0x7E)
+		snprintf(buf, sizeof(buf), "'%c'", c);
+	else
+		snprintf(buf, sizeof(buf), "0x%02x", (unsigned char) c);
+	return buf;
+}
+
+/*
+ * Performs syntactic validation of a key and value from the initial client
+ * response. (Semantic validation of interesting values must be performed
+ * later.)
+ */
+static void
+validate_kvpair(const char *key, const char *val)
+{
+	/*-----
+	 * From Sec 3.1:
+	 *     key            = 1*(ALPHA)
+	 */
+	static const char *key_allowed_set =
+		"abcdefghijklmnopqrstuvwxyz"
+		"ABCDEFGHIJKLMNOPQRSTUVWXYZ";
+
+	size_t		span;
+
+	if (!key[0])
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message contains an empty key name."));
+
+	span = strspn(key, key_allowed_set);
+	if (key[span] != '\0')
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message contains an invalid key name."));
+
+	/*-----
+	 * From Sec 3.1:
+	 *     value          = *(VCHAR / SP / HTAB / CR / LF )
+	 *
+	 * The VCHAR (visible character) class is large; a loop is more
+	 * straightforward than strspn().
+	 */
+	for (; *val; ++val)
+	{
+		if (0x21 <= *val && *val <= 0x7E)
+			continue;			/* VCHAR */
+
+		switch (*val)
+		{
+			case ' ':
+			case '\t':
+			case '\r':
+			case '\n':
+				continue;		/* SP, HTAB, CR, LF */
+
+			default:
+				ereport(ERROR,
+						errcode(ERRCODE_PROTOCOL_VIOLATION),
+						errmsg("malformed OAUTHBEARER message"),
+						errdetail("Message contains an invalid value."));
+		}
+	}
+}
+
+/*
+ * Consumes all kvpairs in an OAUTHBEARER exchange message. If the "auth" key is
+ * found, its value is returned.
+ */
+static char *
+parse_kvpairs_for_auth(char **input)
+{
+	char	   *pos = *input;
+	char	   *auth = NULL;
+
+	/*----
+	 * The relevant ABNF, from Sec. 3.1:
+	 *
+	 *     kvsep          = %x01
+	 *     key            = 1*(ALPHA)
+	 *     value          = *(VCHAR / SP / HTAB / CR / LF )
+	 *     kvpair         = key "=" value kvsep
+	 *   ;;gs2-header     = See RFC 5801
+	 *     client-resp    = (gs2-header kvsep *kvpair kvsep) / kvsep
+	 *
+	 * By the time we reach this code, the gs2-header and initial kvsep have
+	 * already been validated. We start at the beginning of the first kvpair.
+	 */
+
+	while (*pos)
+	{
+		char	   *end;
+		char	   *sep;
+		char	   *key;
+		char	   *value;
+
+		/*
+		 * Find the end of this kvpair. Note that input is null-terminated by
+		 * the SASL code, so the strchr() is bounded.
+		 */
+		end = strchr(pos, KVSEP);
+		if (!end)
+			ereport(ERROR,
+					errcode(ERRCODE_PROTOCOL_VIOLATION),
+					errmsg("malformed OAUTHBEARER message"),
+					errdetail("Message contains an unterminated key/value pair."));
+		*end = '\0';
+
+		if (pos == end)
+		{
+			/* Empty kvpair, signifying the end of the list. */
+			*input = pos + 1;
+			return auth;
+		}
+
+		/*
+		 * Find the end of the key name.
+		 */
+		sep = strchr(pos, '=');
+		if (!sep)
+			ereport(ERROR,
+					errcode(ERRCODE_PROTOCOL_VIOLATION),
+					errmsg("malformed OAUTHBEARER message"),
+					errdetail("Message contains a key without a value."));
+		*sep = '\0';
+
+		/* Both key and value are now safely terminated. */
+		key = pos;
+		value = sep + 1;
+		validate_kvpair(key, value);
+
+		if (strcmp(key, AUTH_KEY) == 0)
+		{
+			if (auth)
+				ereport(ERROR,
+						errcode(ERRCODE_PROTOCOL_VIOLATION),
+						errmsg("malformed OAUTHBEARER message"),
+						errdetail("Message contains multiple auth values."));
+
+			auth = value;
+		}
+		else
+		{
+			/*
+			 * The RFC also defines the host and port keys, but they are not
+			 * required for OAUTHBEARER and we do not use them. Also, per Sec.
+			 * 3.1, any key/value pairs we don't recognize must be ignored.
+			 */
+		}
+
+		/* Move to the next pair. */
+		pos = end + 1;
+	}
+
+	ereport(ERROR,
+			errcode(ERRCODE_PROTOCOL_VIOLATION),
+			errmsg("malformed OAUTHBEARER message"),
+			errdetail("Message did not contain a final terminator."));
+
+	pg_unreachable();
+	return NULL;
+}
+
+/*
+ * Builds the JSON response for failed authentication (RFC 7628, Sec. 3.2.2).
+ * This contains the required scopes for entry and a pointer to the OAuth/OpenID
+ * discovery document, which the client may use to conduct its OAuth flow.
+ */
+static void
+generate_error_response(struct oauth_ctx *ctx, char **output, int *outputlen)
+{
+	StringInfoData buf;
+	StringInfoData issuer;
+
+	/*
+	 * The admin needs to set an issuer and scope for OAuth to work. There's
+	 * not really a way to hide this from the user, either, because we can't
+	 * choose a "default" issuer, so be honest in the failure message. (In
+	 * practice such configurations are rejected during HBA parsing.)
+	 */
+	if (!ctx->issuer || !ctx->scope)
+		ereport(FATAL,
+				errcode(ERRCODE_INTERNAL_ERROR),
+				errmsg("OAuth is not properly configured for this user"),
+				errdetail_log("The issuer and scope parameters must be set in pg_hba.conf."));
+
+	/*
+	 * Build a default .well-known URI based on our issuer, unless the HBA has
+	 * already provided one.
+	 */
+	initStringInfo(&issuer);
+	appendStringInfoString(&issuer, ctx->issuer);
+	if (strstr(ctx->issuer, "/.well-known/") == NULL)
+		appendStringInfoString(&issuer, "/.well-known/openid-configuration");
+
+	initStringInfo(&buf);
+
+	/*
+	 * Escaping the string here is belt-and-suspenders defensive programming
+	 * since escapable characters aren't valid in either the issuer URI or the
+	 * scope list, but the HBA doesn't enforce that yet.
+	 */
+	appendStringInfoString(&buf, "{ \"status\": \"invalid_token\", ");
+
+	appendStringInfoString(&buf, "\"openid-configuration\": ");
+	escape_json(&buf, issuer.data);
+	pfree(issuer.data);
+
+	appendStringInfoString(&buf, ", \"scope\": ");
+	escape_json(&buf, ctx->scope);
+
+	appendStringInfoString(&buf, " }");
+
+	*output = buf.data;
+	*outputlen = buf.len;
+}
+
+/*-----
+ * Validates the provided Authorization header and returns the token from
+ * within it. NULL is returned on validation failure.
+ *
+ * Only Bearer tokens are accepted. The ABNF is defined in RFC 6750, Sec.
+ * 2.1:
+ *
+ *      b64token    = 1*( ALPHA / DIGIT /
+ *                        "-" / "." / "_" / "~" / "+" / "/" ) *"="
+ *      credentials = "Bearer" 1*SP b64token
+ *
+ * The "credentials" construction is what we receive in our auth value.
+ *
+ * Since that spec is subordinate to HTTP (i.e. the HTTP Authorization
+ * header format; RFC 9110 Sec. 11), the "Bearer" scheme string must be
+ * compared case-insensitively. (This is not mentioned in RFC 6750, but the
+ * OAUTHBEARER spec points it out: RFC 7628 Sec. 4.)
+ *
+ * Invalid formats are technically a protocol violation, but we shouldn't
+ * reflect any information about the sensitive Bearer token back to the
+ * client; log at COMMERROR instead.
+ */
+static const char *
+validate_token_format(const char *header)
+{
+	size_t		span;
+	const char *token;
+	static const char *const b64token_allowed_set =
+		"abcdefghijklmnopqrstuvwxyz"
+		"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+		"0123456789-._~+/";
+
+	/* Missing auth headers should be handled by the caller. */
+	Assert(header);
+
+	if (header[0] == '\0')
+	{
+		/*
+		 * A completely empty auth header represents a query for
+		 * authentication parameters. The client expects it to fail; there's
+		 * no need to make any extra noise in the logs.
+		 *
+		 * TODO: should we find a way to return STATUS_EOF at the top level,
+		 * to suppress the authentication error entirely?
+		 */
+		return NULL;
+	}
+
+	if (pg_strncasecmp(header, BEARER_SCHEME, strlen(BEARER_SCHEME)))
+	{
+		ereport(COMMERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAuth bearer token"),
+				errdetail_log("Client response indicated a non-Bearer authentication scheme."));
+		return NULL;
+	}
+
+	/* Pull the bearer token out of the auth value. */
+	token = header + strlen(BEARER_SCHEME);
+
+	/* Swallow any additional spaces. */
+	while (*token == ' ')
+		token++;
+
+	/* Tokens must not be empty. */
+	if (!*token)
+	{
+		ereport(COMMERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAuth bearer token"),
+				errdetail_log("Bearer token is empty."));
+		return NULL;
+	}
+
+	/*
+	 * Make sure the token contains only allowed characters. Tokens may end
+	 * with any number of '=' characters.
+	 */
+	span = strspn(token, b64token_allowed_set);
+	while (token[span] == '=')
+		span++;
+
+	if (token[span] != '\0')
+	{
+		/*
+		 * This error message could be more helpful by printing the
+		 * problematic character(s), but that'd be a bit like printing a piece
+		 * of someone's password into the logs.
+		 */
+		ereport(COMMERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAuth bearer token"),
+				errdetail_log("Bearer token is not in the correct format."));
+		return NULL;
+	}
+
+	return token;
+}
+
+/*
+ * Checks that the "auth" kvpair in the client response contains a syntactically
+ * valid Bearer token, then passes it along to the loaded validator module for
+ * authorization. Returns true if validation succeeds.
+ */
+static bool
+validate(Port *port, const char *auth)
+{
+	int			map_status;
+	ValidatorModuleResult *ret;
+	const char *token;
+	bool		status;
+
+	/* Ensure that we have a correct token to validate */
+	if (!(token = validate_token_format(auth)))
+		return false;
+
+	/*
+	 * Ensure that we have a validation library loaded, this should always be
+	 * the case and an error here is indicative of a bug.
+	 */
+	if (!ValidatorCallbacks || !ValidatorCallbacks->validate_cb)
+		ereport(FATAL,
+				errcode(ERRCODE_INTERNAL_ERROR),
+				errmsg("validation of OAuth token requested without a validator loaded"));
+
+	/* Call the validation function from the validator module */
+	ret = palloc0(sizeof(ValidatorModuleResult));
+	if (!ValidatorCallbacks->validate_cb(validator_module_state, token,
+										 port->user_name, ret))
+	{
+		ereport(WARNING,
+				errcode(ERRCODE_INTERNAL_ERROR),
+				errmsg("internal error in OAuth validator module"));
+		return false;
+	}
+
+	/*
+	 * Log any authentication results even if the token isn't authorized; it
+	 * might be useful for auditing or troubleshooting.
+	 */
+	if (ret->authn_id)
+		set_authn_id(port, ret->authn_id);
+
+	if (!ret->authorized)
+	{
+		ereport(LOG,
+				errmsg("OAuth bearer authentication failed for user \"%s\"",
+					   port->user_name),
+				errdetail_log("Validator failed to authorize the provided token."));
+
+		status = false;
+		goto cleanup;
+	}
+
+	if (port->hba->oauth_skip_usermap)
+	{
+		/*
+		 * If the validator is our authorization authority, we're done.
+		 * Authentication may or may not have been performed depending on the
+		 * validator implementation; all that matters is that the validator
+		 * says the user can log in with the target role.
+		 */
+		status = true;
+		goto cleanup;
+	}
+
+	/* Make sure the validator authenticated the user. */
+	if (ret->authn_id == NULL || ret->authn_id[0] == '\0')
+	{
+		ereport(LOG,
+				errmsg("OAuth bearer authentication failed for user \"%s\"",
+					   port->user_name),
+				errdetail_log("Validator provided no identity."));
+
+		status = false;
+		goto cleanup;
+	}
+
+	/* Finally, check the user map. */
+	map_status = check_usermap(port->hba->usermap, port->user_name,
+							   MyClientConnectionInfo.authn_id, false);
+	status = (map_status == STATUS_OK);
+
+cleanup:
+
+	/*
+	 * Clear and free the validation result from the validator module once
+	 * we're done with it.
+	 */
+	if (ret->authn_id != NULL)
+		pfree(ret->authn_id);
+	pfree(ret);
+
+	return status;
+}
+
+/*
+ * load_validator_library
+ *
+ * Load the configured validator library in order to perform token validation.
+ * There is no built-in fallback since validation is implementation specific. If
+ * no validator library is configured, or if it fails to load, then error out
+ * since token validation won't be possible.
+ */
+static void
+load_validator_library(const char *libname)
+{
+	OAuthValidatorModuleInit validator_init;
+	MemoryContextCallback *mcb;
+
+	/*
+	 * Thre presence, and validity, of libname has already been established by
+	 * check_oauth_validator so we don't need to perform more than Assert level
+	 * checking here.
+	 */
+	Assert(libname && *libname);
+
+	validator_init = (OAuthValidatorModuleInit)
+		load_external_function(libname, "_PG_oauth_validator_module_init",
+							   false, NULL);
+
+	/*
+	 * The validator init function is required since it will set the callbacks
+	 * for the validator library.
+	 */
+	if (validator_init == NULL)
+		ereport(ERROR,
+				errmsg("%s module \"%s\" must define the symbol %s",
+					   "OAuth validator", libname, "_PG_oauth_validator_module_init"));
+
+	ValidatorCallbacks = (*validator_init) ();
+	Assert(ValidatorCallbacks);
+
+	/*
+	 * Check the magic number, to protect against break-glass scenarios where
+	 * the ABI must change within a major version. load_external_function()
+	 * already checks for compatibility across major versions.
+	 */
+	if (ValidatorCallbacks->magic != PG_OAUTH_VALIDATOR_MAGIC)
+		ereport(ERROR,
+				errmsg("%s module \"%s\": magic number mismatch",
+					   "OAuth validator", libname),
+				errdetail("Server has magic number 0x%08X, module has 0x%08X.",
+						  PG_OAUTH_VALIDATOR_MAGIC, ValidatorCallbacks->magic));
+
+	/*
+	 * Make sure all required callbacks are present in the ValidatorCallbacks
+	 * structure. Right now only the validation callback is required.
+	 */
+	if (ValidatorCallbacks->validate_cb == NULL)
+		ereport(ERROR,
+				errmsg("%s module \"%s\" must define the symbol %s",
+					   "OAuth validator", libname, "validate_cb"));
+
+	/* Allocate memory for validator library private state data */
+	validator_module_state = (ValidatorModuleState *) palloc0(sizeof(ValidatorModuleState));
+	validator_module_state->sversion = PG_VERSION_NUM;
+
+	if (ValidatorCallbacks->startup_cb != NULL)
+		ValidatorCallbacks->startup_cb(validator_module_state);
+
+	/* Shut down the library before cleaning up its state. */
+	mcb = palloc0(sizeof(*mcb));
+	mcb->func = shutdown_validator_library;
+
+	MemoryContextRegisterResetCallback(CurrentMemoryContext, mcb);
+}
+
+/*
+ * Call the validator module's shutdown callback, if one is provided. This is
+ * invoked during memory context reset.
+ */
+static void
+shutdown_validator_library(void *arg)
+{
+	if (ValidatorCallbacks->shutdown_cb != NULL)
+		ValidatorCallbacks->shutdown_cb(validator_module_state);
+}
+
+/*
+ * Ensure an OAuth validator named in the HBA is permitted by the configuration.
+ *
+ * If the validator is currently unset and exactly one library is declared in
+ * oauth_validator_libraries, then that library will be used as the validator.
+ * Otherwise the name must be present in the list of oauth_validator_libraries.
+ */
+bool
+check_oauth_validator(HbaLine *hbaline, int elevel, char **err_msg)
+{
+	int			line_num = hbaline->linenumber;
+	const char *file_name = hbaline->sourcefile;
+	char	   *rawstring;
+	List	   *elemlist = NIL;
+
+	*err_msg = NULL;
+
+	if (oauth_validator_libraries_string[0] == '\0')
+	{
+		ereport(elevel,
+				errcode(ERRCODE_CONFIG_FILE_ERROR),
+				errmsg("oauth_validator_libraries must be set for authentication method %s",
+					   "oauth"),
+				errcontext("line %d of configuration file \"%s\"",
+						   line_num, file_name));
+		*err_msg = psprintf("oauth_validator_libraries must be set for authentication method %s",
+							"oauth");
+		return false;
+	}
+
+	/* SplitDirectoriesString needs a modifiable copy */
+	rawstring = pstrdup(oauth_validator_libraries_string);
+
+	if (!SplitDirectoriesString(rawstring, ',', &elemlist))
+	{
+		/* syntax error in list */
+		ereport(elevel,
+				errcode(ERRCODE_CONFIG_FILE_ERROR),
+				errmsg("invalid list syntax in parameter \"%s\"",
+					   "oauth_validator_libraries"));
+		*err_msg = psprintf("invalid list syntax in parameter \"%s\"",
+							"oauth_validator_libraries");
+		goto done;
+	}
+
+	if (!hbaline->oauth_validator)
+	{
+		if (elemlist->length == 1)
+		{
+			hbaline->oauth_validator = pstrdup(linitial(elemlist));
+			goto done;
+		}
+
+		ereport(elevel,
+				errcode(ERRCODE_CONFIG_FILE_ERROR),
+				errmsg("authentication method \"oauth\" requires argument \"validator\" to be set when oauth_validator_libraries contains multiple options"),
+				errcontext("line %d of configuration file \"%s\"",
+						   line_num, file_name));
+		*err_msg = "authentication method \"oauth\" requires argument \"validator\" to be set when oauth_validator_libraries contains multiple options";
+		goto done;
+	}
+
+	foreach_ptr(char, allowed, elemlist)
+	{
+		if (strcmp(allowed, hbaline->oauth_validator) == 0)
+			goto done;
+	}
+
+	ereport(elevel,
+			errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+			errmsg("validator \"%s\" is not permitted by %s",
+				   hbaline->oauth_validator, "oauth_validator_libraries"),
+			errcontext("line %d of configuration file \"%s\"",
+					   line_num, file_name));
+	*err_msg = psprintf("validator \"%s\" is not permitted by %s",
+						hbaline->oauth_validator, "oauth_validator_libraries");
+
+done:
+	list_free_deep(elemlist);
+	pfree(rawstring);
+
+	return (*err_msg == NULL);
+}
diff --git a/src/backend/libpq/auth.c b/src/backend/libpq/auth.c
index d6ef32cc823..0f65014e64f 100644
--- a/src/backend/libpq/auth.c
+++ b/src/backend/libpq/auth.c
@@ -29,6 +29,7 @@
 #include "libpq/auth.h"
 #include "libpq/crypt.h"
 #include "libpq/libpq.h"
+#include "libpq/oauth.h"
 #include "libpq/pqformat.h"
 #include "libpq/sasl.h"
 #include "libpq/scram.h"
@@ -45,7 +46,6 @@
  */
 static void auth_failed(Port *port, int status, const char *logdetail);
 static char *recv_password_packet(Port *port);
-static void set_authn_id(Port *port, const char *id);
 
 
 /*----------------------------------------------------------------
@@ -289,6 +289,9 @@ auth_failed(Port *port, int status, const char *logdetail)
 		case uaRADIUS:
 			errstr = gettext_noop("RADIUS authentication failed for user \"%s\"");
 			break;
+		case uaOAuth:
+			errstr = gettext_noop("OAuth bearer authentication failed for user \"%s\"");
+			break;
 		default:
 			errstr = gettext_noop("authentication failed for user \"%s\": invalid authentication method");
 			break;
@@ -324,7 +327,7 @@ auth_failed(Port *port, int status, const char *logdetail)
  * lifetime of MyClientConnectionInfo, so it is safe to pass a string that is
  * managed by an external library.
  */
-static void
+void
 set_authn_id(Port *port, const char *id)
 {
 	Assert(id);
@@ -611,6 +614,9 @@ ClientAuthentication(Port *port)
 		case uaTrust:
 			status = STATUS_OK;
 			break;
+		case uaOAuth:
+			status = CheckSASLAuth(&pg_be_oauth_mech, port, NULL, NULL);
+			break;
 	}
 
 	if ((status == STATUS_OK && port->hba->clientcert == clientCertFull)
diff --git a/src/backend/libpq/hba.c b/src/backend/libpq/hba.c
index 510c9ffc6d7..332fad27835 100644
--- a/src/backend/libpq/hba.c
+++ b/src/backend/libpq/hba.c
@@ -32,6 +32,7 @@
 #include "libpq/hba.h"
 #include "libpq/ifaddr.h"
 #include "libpq/libpq-be.h"
+#include "libpq/oauth.h"
 #include "postmaster/postmaster.h"
 #include "regex/regex.h"
 #include "replication/walsender.h"
@@ -114,7 +115,8 @@ static const char *const UserAuthName[] =
 	"ldap",
 	"cert",
 	"radius",
-	"peer"
+	"peer",
+	"oauth",
 };
 
 /*
@@ -1747,6 +1749,8 @@ parse_hba_line(TokenizedAuthLine *tok_line, int elevel)
 #endif
 	else if (strcmp(token->string, "radius") == 0)
 		parsedline->auth_method = uaRADIUS;
+	else if (strcmp(token->string, "oauth") == 0)
+		parsedline->auth_method = uaOAuth;
 	else
 	{
 		ereport(elevel,
@@ -2039,6 +2043,36 @@ parse_hba_line(TokenizedAuthLine *tok_line, int elevel)
 		parsedline->clientcert = clientCertFull;
 	}
 
+	/*
+	 * Enforce proper configuration of OAuth authentication.
+	 */
+	if (parsedline->auth_method == uaOAuth)
+	{
+		MANDATORY_AUTH_ARG(parsedline->oauth_scope, "scope", "oauth");
+		MANDATORY_AUTH_ARG(parsedline->oauth_issuer, "issuer", "oauth");
+
+		/* Ensure a validator library is set and permitted by the config. */
+		if (!check_oauth_validator(parsedline, elevel, err_msg))
+			return NULL;
+
+		/*
+		 * Supplying a usermap combined with the option to skip usermapping is
+		 * nonsensical and indicates a configuration error.
+		 */
+		if (parsedline->oauth_skip_usermap && parsedline->usermap != NULL)
+		{
+			ereport(elevel,
+					errcode(ERRCODE_CONFIG_FILE_ERROR),
+			/* translator: strings are replaced with hba options */
+					errmsg("%s cannot be used in combination with %s",
+						   "map", "delegate_ident_mapping"),
+					errcontext("line %d of configuration file \"%s\"",
+							   line_num, file_name));
+			*err_msg = "map cannot be used in combination with delegate_ident_mapping";
+			return NULL;
+		}
+	}
+
 	return parsedline;
 }
 
@@ -2066,8 +2100,9 @@ parse_hba_auth_opt(char *name, char *val, HbaLine *hbaline,
 			hbaline->auth_method != uaPeer &&
 			hbaline->auth_method != uaGSS &&
 			hbaline->auth_method != uaSSPI &&
-			hbaline->auth_method != uaCert)
-			INVALID_AUTH_OPTION("map", gettext_noop("ident, peer, gssapi, sspi, and cert"));
+			hbaline->auth_method != uaCert &&
+			hbaline->auth_method != uaOAuth)
+			INVALID_AUTH_OPTION("map", gettext_noop("ident, peer, gssapi, sspi, cert, and oauth"));
 		hbaline->usermap = pstrdup(val);
 	}
 	else if (strcmp(name, "clientcert") == 0)
@@ -2450,6 +2485,29 @@ parse_hba_auth_opt(char *name, char *val, HbaLine *hbaline,
 		hbaline->radiusidentifiers = parsed_identifiers;
 		hbaline->radiusidentifiers_s = pstrdup(val);
 	}
+	else if (strcmp(name, "issuer") == 0)
+	{
+		REQUIRE_AUTH_OPTION(uaOAuth, "issuer", "oauth");
+		hbaline->oauth_issuer = pstrdup(val);
+	}
+	else if (strcmp(name, "scope") == 0)
+	{
+		REQUIRE_AUTH_OPTION(uaOAuth, "scope", "oauth");
+		hbaline->oauth_scope = pstrdup(val);
+	}
+	else if (strcmp(name, "validator") == 0)
+	{
+		REQUIRE_AUTH_OPTION(uaOAuth, "validator", "oauth");
+		hbaline->oauth_validator = pstrdup(val);
+	}
+	else if (strcmp(name, "delegate_ident_mapping") == 0)
+	{
+		REQUIRE_AUTH_OPTION(uaOAuth, "delegate_ident_mapping", "oauth");
+		if (strcmp(val, "1") == 0)
+			hbaline->oauth_skip_usermap = true;
+		else
+			hbaline->oauth_skip_usermap = false;
+	}
 	else
 	{
 		ereport(elevel,
diff --git a/src/backend/libpq/meson.build b/src/backend/libpq/meson.build
index 0f0421037e4..31aa2faae1e 100644
--- a/src/backend/libpq/meson.build
+++ b/src/backend/libpq/meson.build
@@ -1,6 +1,7 @@
 # Copyright (c) 2022-2025, PostgreSQL Global Development Group
 
 backend_sources += files(
+  'auth-oauth.c',
   'auth-sasl.c',
   'auth-scram.c',
   'auth.c',
diff --git a/src/backend/libpq/pg_hba.conf.sample b/src/backend/libpq/pg_hba.conf.sample
index bad13497a34..b64c8dea97c 100644
--- a/src/backend/libpq/pg_hba.conf.sample
+++ b/src/backend/libpq/pg_hba.conf.sample
@@ -53,8 +53,8 @@
 # directly connected to.
 #
 # METHOD can be "trust", "reject", "md5", "password", "scram-sha-256",
-# "gss", "sspi", "ident", "peer", "pam", "ldap", "radius" or "cert".
-# Note that "password" sends passwords in clear text; "md5" or
+# "gss", "sspi", "ident", "peer", "pam", "oauth", "ldap", "radius" or
+# "cert".  Note that "password" sends passwords in clear text; "md5" or
 # "scram-sha-256" are preferred since they send encrypted passwords.
 #
 # OPTIONS are a set of options for the authentication in the format
diff --git a/src/backend/utils/adt/hbafuncs.c b/src/backend/utils/adt/hbafuncs.c
index 03c38e8c451..b62c3d944cf 100644
--- a/src/backend/utils/adt/hbafuncs.c
+++ b/src/backend/utils/adt/hbafuncs.c
@@ -152,6 +152,25 @@ get_hba_options(HbaLine *hba)
 				CStringGetTextDatum(psprintf("radiusports=%s", hba->radiusports_s));
 	}
 
+	if (hba->auth_method == uaOAuth)
+	{
+		if (hba->oauth_issuer)
+			options[noptions++] =
+				CStringGetTextDatum(psprintf("issuer=%s", hba->oauth_issuer));
+
+		if (hba->oauth_scope)
+			options[noptions++] =
+				CStringGetTextDatum(psprintf("scope=%s", hba->oauth_scope));
+
+		if (hba->oauth_validator)
+			options[noptions++] =
+				CStringGetTextDatum(psprintf("validator=%s", hba->oauth_validator));
+
+		if (hba->oauth_skip_usermap)
+			options[noptions++] =
+				CStringGetTextDatum(psprintf("delegate_ident_mapping=true"));
+	}
+
 	/* If you add more options, consider increasing MAX_HBA_OPTIONS. */
 	Assert(noptions <= MAX_HBA_OPTIONS);
 
diff --git a/src/backend/utils/misc/guc_tables.c b/src/backend/utils/misc/guc_tables.c
index 226af43fe23..68833ca5fa3 100644
--- a/src/backend/utils/misc/guc_tables.c
+++ b/src/backend/utils/misc/guc_tables.c
@@ -49,6 +49,7 @@
 #include "jit/jit.h"
 #include "libpq/auth.h"
 #include "libpq/libpq.h"
+#include "libpq/oauth.h"
 #include "libpq/scram.h"
 #include "nodes/queryjumble.h"
 #include "optimizer/cost.h"
@@ -4852,6 +4853,17 @@ struct config_string ConfigureNamesString[] =
 		check_restrict_nonsystem_relation_kind, assign_restrict_nonsystem_relation_kind, NULL
 	},
 
+	{
+		{"oauth_validator_libraries", PGC_SIGHUP, CONN_AUTH_AUTH,
+			gettext_noop("Lists libraries that may be called to validate OAuth v2 bearer tokens."),
+			NULL,
+			GUC_LIST_INPUT | GUC_LIST_QUOTE | GUC_SUPERUSER_ONLY
+		},
+		&oauth_validator_libraries_string,
+		"",
+		NULL, NULL, NULL
+	},
+
 	/* End-of-list marker */
 	{
 		{NULL, 0, 0, NULL, NULL}, NULL, NULL, NULL, NULL, NULL
diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample
index d472987ed46..ccefd214143 100644
--- a/src/backend/utils/misc/postgresql.conf.sample
+++ b/src/backend/utils/misc/postgresql.conf.sample
@@ -121,6 +121,9 @@
 #ssl_passphrase_command = ''
 #ssl_passphrase_command_supports_reload = off
 
+# OAuth
+#oauth_validator_libraries = ''	# comma-separated list of trusted validator modules
+
 
 #------------------------------------------------------------------------------
 # RESOURCE USAGE (except WAL)
diff --git a/src/include/common/oauth-common.h b/src/include/common/oauth-common.h
new file mode 100644
index 00000000000..5fb559d84b2
--- /dev/null
+++ b/src/include/common/oauth-common.h
@@ -0,0 +1,19 @@
+/*-------------------------------------------------------------------------
+ *
+ * oauth-common.h
+ *		Declarations for helper functions used for OAuth/OIDC authentication
+ *
+ * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/include/common/oauth-common.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef OAUTH_COMMON_H
+#define OAUTH_COMMON_H
+
+/* Name of SASL mechanism per IANA */
+#define OAUTHBEARER_NAME "OAUTHBEARER"
+
+#endif							/* OAUTH_COMMON_H */
diff --git a/src/include/libpq/auth.h b/src/include/libpq/auth.h
index 902c5f6de32..25b5742068f 100644
--- a/src/include/libpq/auth.h
+++ b/src/include/libpq/auth.h
@@ -39,6 +39,7 @@ extern PGDLLIMPORT bool pg_gss_accept_delegation;
 extern void ClientAuthentication(Port *port);
 extern void sendAuthRequest(Port *port, AuthRequest areq, const char *extradata,
 							int extralen);
+extern void set_authn_id(Port *port, const char *id);
 
 /* Hook for plugins to get control in ClientAuthentication() */
 typedef void (*ClientAuthentication_hook_type) (Port *, int);
diff --git a/src/include/libpq/hba.h b/src/include/libpq/hba.h
index b20d0051f7d..3657f182db3 100644
--- a/src/include/libpq/hba.h
+++ b/src/include/libpq/hba.h
@@ -39,7 +39,8 @@ typedef enum UserAuth
 	uaCert,
 	uaRADIUS,
 	uaPeer,
-#define USER_AUTH_LAST uaPeer	/* Must be last value of this enum */
+	uaOAuth,
+#define USER_AUTH_LAST uaOAuth	/* Must be last value of this enum */
 } UserAuth;
 
 /*
@@ -135,6 +136,10 @@ typedef struct HbaLine
 	char	   *radiusidentifiers_s;
 	List	   *radiusports;
 	char	   *radiusports_s;
+	char	   *oauth_issuer;
+	char	   *oauth_scope;
+	char	   *oauth_validator;
+	bool		oauth_skip_usermap;
 } HbaLine;
 
 typedef struct IdentLine
diff --git a/src/include/libpq/oauth.h b/src/include/libpq/oauth.h
new file mode 100644
index 00000000000..2f01b669633
--- /dev/null
+++ b/src/include/libpq/oauth.h
@@ -0,0 +1,101 @@
+/*-------------------------------------------------------------------------
+ *
+ * oauth.h
+ *	  Interface to libpq/auth-oauth.c
+ *
+ * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/include/libpq/oauth.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PG_OAUTH_H
+#define PG_OAUTH_H
+
+#include "libpq/libpq-be.h"
+#include "libpq/sasl.h"
+
+extern PGDLLIMPORT char *oauth_validator_libraries_string;
+
+typedef struct ValidatorModuleState
+{
+	/* Holds the server's PG_VERSION_NUM. Reserved for future extensibility. */
+	int			sversion;
+
+	/*
+	 * Private data pointer for use by a validator module. This can be used to
+	 * store state for the module that will be passed to each of its
+	 * callbacks.
+	 */
+	void	   *private_data;
+} ValidatorModuleState;
+
+typedef struct ValidatorModuleResult
+{
+	/*
+	 * Should be set to true if the token carries sufficient permissions for
+	 * the bearer to connect.
+	 */
+	bool		authorized;
+
+	/*
+	 * If the token authenticates the user, this should be set to a palloc'd
+	 * string containing the SYSTEM_USER to use for HBA mapping. Consider
+	 * setting this even if result->authorized is false so that DBAs may use
+	 * the logs to match end users to token failures.
+	 *
+	 * This is required if the module is not configured for ident mapping
+	 * delegation. See the validator module documentation for details.
+	 */
+	char	   *authn_id;
+} ValidatorModuleResult;
+
+/*
+ * Validator module callbacks
+ *
+ * These callback functions should be defined by validator modules and returned
+ * via _PG_oauth_validator_module_init().  ValidatorValidateCB is the only
+ * required callback. For more information about the purpose of each callback,
+ * refer to the OAuth validator modules documentation.
+ */
+typedef void (*ValidatorStartupCB) (ValidatorModuleState *state);
+typedef void (*ValidatorShutdownCB) (ValidatorModuleState *state);
+typedef bool (*ValidatorValidateCB) (const ValidatorModuleState *state,
+									 const char *token, const char *role,
+									 ValidatorModuleResult *result);
+
+/*
+ * Identifies the compiled ABI version of the validator module. Since the server
+ * already enforces the PG_MODULE_MAGIC number for modules across major
+ * versions, this is reserved for emergency use within a stable release line.
+ * May it never need to change.
+ */
+#define PG_OAUTH_VALIDATOR_MAGIC 0x20250207
+
+typedef struct OAuthValidatorCallbacks
+{
+	uint32		magic;			/* must be set to PG_OAUTH_VALIDATOR_MAGIC */
+
+	ValidatorStartupCB startup_cb;
+	ValidatorShutdownCB shutdown_cb;
+	ValidatorValidateCB validate_cb;
+} OAuthValidatorCallbacks;
+
+/*
+ * Type of the shared library symbol _PG_oauth_validator_module_init which is
+ * required for all validator modules.  This function will be invoked during
+ * module loading.
+ */
+typedef const OAuthValidatorCallbacks *(*OAuthValidatorModuleInit) (void);
+extern PGDLLEXPORT const OAuthValidatorCallbacks *_PG_oauth_validator_module_init(void);
+
+/* Implementation */
+extern const pg_be_sasl_mech pg_be_oauth_mech;
+
+/*
+ * Ensure a validator named in the HBA is permitted by the configuration.
+ */
+extern bool check_oauth_validator(HbaLine *hba, int elevel, char **err_msg);
+
+#endif							/* PG_OAUTH_H */
diff --git a/src/include/pg_config.h.in b/src/include/pg_config.h.in
index 07b2f798abd..db6454090d2 100644
--- a/src/include/pg_config.h.in
+++ b/src/include/pg_config.h.in
@@ -229,6 +229,9 @@
 /* Define to 1 if you have the `crypto' library (-lcrypto). */
 #undef HAVE_LIBCRYPTO
 
+/* Define to 1 if you have the `curl' library (-lcurl). */
+#undef HAVE_LIBCURL
+
 /* Define to 1 if you have the `ldap' library (-lldap). */
 #undef HAVE_LIBLDAP
 
@@ -442,6 +445,9 @@
 /* Define to 1 if you have the <termios.h> header file. */
 #undef HAVE_TERMIOS_H
 
+/* Define to 1 if curl_global_init() is guaranteed to be thread-safe. */
+#undef HAVE_THREADSAFE_CURL_GLOBAL_INIT
+
 /* Define to 1 if your compiler understands `typeof' or something similar. */
 #undef HAVE_TYPEOF
 
@@ -663,6 +669,9 @@
 /* Define to 1 to build with LDAP support. (--with-ldap) */
 #undef USE_LDAP
 
+/* Define to 1 to build with libcurl support. (--with-libcurl) */
+#undef USE_LIBCURL
+
 /* Define to 1 to build with XML support. (--with-libxml) */
 #undef USE_LIBXML
 
diff --git a/src/interfaces/libpq/Makefile b/src/interfaces/libpq/Makefile
index 701810a272a..90b0b65db6f 100644
--- a/src/interfaces/libpq/Makefile
+++ b/src/interfaces/libpq/Makefile
@@ -31,6 +31,7 @@ endif
 
 OBJS = \
 	$(WIN32RES) \
+	fe-auth-oauth.o \
 	fe-auth-scram.o \
 	fe-cancel.o \
 	fe-connect.o \
@@ -63,6 +64,10 @@ OBJS += \
 	fe-secure-gssapi.o
 endif
 
+ifeq ($(with_libcurl),yes)
+OBJS += fe-auth-oauth-curl.o
+endif
+
 ifeq ($(PORTNAME), cygwin)
 override shlib = cyg$(NAME)$(DLSUFFIX)
 endif
@@ -81,7 +86,7 @@ endif
 # that are built correctly for use in a shlib.
 SHLIB_LINK_INTERNAL = -lpgcommon_shlib -lpgport_shlib
 ifneq ($(PORTNAME), win32)
-SHLIB_LINK += $(filter -lcrypt -ldes -lcom_err -lcrypto -lk5crypto -lkrb5 -lgssapi_krb5 -lgss -lgssapi -lssl -lsocket -lnsl -lresolv -lintl -lm, $(LIBS)) $(LDAP_LIBS_FE) $(PTHREAD_LIBS)
+SHLIB_LINK += $(filter -lcrypt -ldes -lcom_err -lcrypto -lk5crypto -lkrb5 -lgssapi_krb5 -lgss -lgssapi -lssl -lcurl -lsocket -lnsl -lresolv -lintl -lm, $(LIBS)) $(LDAP_LIBS_FE) $(PTHREAD_LIBS)
 else
 SHLIB_LINK += $(filter -lcrypt -ldes -lcom_err -lcrypto -lk5crypto -lkrb5 -lgssapi32 -lssl -lsocket -lnsl -lresolv -lintl -lm $(PTHREAD_LIBS), $(LIBS)) $(LDAP_LIBS_FE)
 endif
@@ -110,6 +115,8 @@ backend_src = $(top_srcdir)/src/backend
 # which seems to insert references to that even in pure C code. Excluding
 # __tsan_func_exit is necessary when using ThreadSanitizer data race detector
 # which use this function for instrumentation of function exit.
+# libcurl registers an exit handler in the memory debugging code when running
+# with LeakSanitizer.
 # Skip the test when profiling, as gcc may insert exit() calls for that.
 # Also skip the test on platforms where libpq infrastructure may be provided
 # by statically-linked libraries, as we can't expect them to honor this
@@ -117,7 +124,7 @@ backend_src = $(top_srcdir)/src/backend
 libpq-refs-stamp: $(shlib)
 ifneq ($(enable_coverage), yes)
 ifeq (,$(filter solaris,$(PORTNAME)))
-	@if nm -A -u $< 2>/dev/null | grep -v -e __cxa_atexit -e __tsan_func_exit | grep exit; then \
+	@if nm -A -u $< 2>/dev/null | grep -v -e __cxa_atexit -e __tsan_func_exit -e _atexit | grep exit; then \
 		echo 'libpq must not be calling any function which invokes exit'; exit 1; \
 	fi
 endif
diff --git a/src/interfaces/libpq/exports.txt b/src/interfaces/libpq/exports.txt
index 2ad2cbf5ca3..9b789cbec0b 100644
--- a/src/interfaces/libpq/exports.txt
+++ b/src/interfaces/libpq/exports.txt
@@ -206,3 +206,6 @@ PQsocketPoll              203
 PQsetChunkedRowsMode      204
 PQgetCurrentTimeUSec      205
 PQservice                 206
+PQsetAuthDataHook         207
+PQgetAuthDataHook         208
+PQdefaultAuthDataHook     209
diff --git a/src/interfaces/libpq/fe-auth-oauth-curl.c b/src/interfaces/libpq/fe-auth-oauth-curl.c
new file mode 100644
index 00000000000..c9aa51b1007
--- /dev/null
+++ b/src/interfaces/libpq/fe-auth-oauth-curl.c
@@ -0,0 +1,2876 @@
+/*-------------------------------------------------------------------------
+ *
+ * fe-auth-oauth-curl.c
+ *	   The libcurl implementation of OAuth/OIDC authentication, using the
+ *	   OAuth Device Authorization Grant (RFC 8628).
+ *
+ * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/interfaces/libpq/fe-auth-oauth-curl.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres_fe.h"
+
+#include <curl/curl.h>
+#include <math.h>
+#ifdef HAVE_SYS_EPOLL_H
+#include <sys/epoll.h>
+#include <sys/timerfd.h>
+#endif
+#ifdef HAVE_SYS_EVENT_H
+#include <sys/event.h>
+#endif
+#include <unistd.h>
+
+#include "common/jsonapi.h"
+#include "fe-auth.h"
+#include "fe-auth-oauth.h"
+#include "libpq-int.h"
+#include "mb/pg_wchar.h"
+
+/*
+ * It's generally prudent to set a maximum response size to buffer in memory,
+ * but it's less clear what size to choose. The biggest of our expected
+ * responses is the server metadata JSON, which will only continue to grow in
+ * size; the number of IANA-registered parameters in that document is up to 78
+ * as of February 2025.
+ *
+ * Even if every single parameter were to take up 2k on average (a previously
+ * common limit on the size of a URL), 256k gives us 128 parameter values before
+ * we give up. (That's almost certainly complete overkill in practice; 2-4k
+ * appears to be common among popular providers at the moment.)
+ */
+#define MAX_OAUTH_RESPONSE_SIZE (256 * 1024)
+
+/*
+ * Parsed JSON Representations
+ *
+ * As a general rule, we parse and cache only the fields we're currently using.
+ * When adding new fields, ensure the corresponding free_*() function is updated
+ * too.
+ */
+
+/*
+ * The OpenID Provider configuration (alternatively named "authorization server
+ * metadata") jointly described by OpenID Connect Discovery 1.0 and RFC 8414:
+ *
+ *     https://openid.net/specs/openid-connect-discovery-1_0.html
+ *     https://www.rfc-editor.org/rfc/rfc8414#section-3.2
+ */
+struct provider
+{
+	char	   *issuer;
+	char	   *token_endpoint;
+	char	   *device_authorization_endpoint;
+	struct curl_slist *grant_types_supported;
+};
+
+static void
+free_provider(struct provider *provider)
+{
+	free(provider->issuer);
+	free(provider->token_endpoint);
+	free(provider->device_authorization_endpoint);
+	curl_slist_free_all(provider->grant_types_supported);
+}
+
+/*
+ * The Device Authorization response, described by RFC 8628:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc8628#section-3.2
+ */
+struct device_authz
+{
+	char	   *device_code;
+	char	   *user_code;
+	char	   *verification_uri;
+	char	   *verification_uri_complete;
+	char	   *expires_in_str;
+	char	   *interval_str;
+
+	/* Fields below are parsed from the corresponding string above. */
+	int			expires_in;
+	int			interval;
+};
+
+static void
+free_device_authz(struct device_authz *authz)
+{
+	free(authz->device_code);
+	free(authz->user_code);
+	free(authz->verification_uri);
+	free(authz->verification_uri_complete);
+	free(authz->expires_in_str);
+	free(authz->interval_str);
+}
+
+/*
+ * The Token Endpoint error response, as described by RFC 6749:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc6749#section-5.2
+ *
+ * Note that this response type can also be returned from the Device
+ * Authorization Endpoint.
+ */
+struct token_error
+{
+	char	   *error;
+	char	   *error_description;
+};
+
+static void
+free_token_error(struct token_error *err)
+{
+	free(err->error);
+	free(err->error_description);
+}
+
+/*
+ * The Access Token response, as described by RFC 6749:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc6749#section-4.1.4
+ *
+ * During the Device Authorization flow, several temporary errors are expected
+ * as part of normal operation. To make it easy to handle these in the happy
+ * path, this contains an embedded token_error that is filled in if needed.
+ */
+struct token
+{
+	/* for successful responses */
+	char	   *access_token;
+	char	   *token_type;
+
+	/* for error responses */
+	struct token_error err;
+};
+
+static void
+free_token(struct token *tok)
+{
+	free(tok->access_token);
+	free(tok->token_type);
+	free_token_error(&tok->err);
+}
+
+/*
+ * Asynchronous State
+ */
+
+/* States for the overall async machine. */
+enum OAuthStep
+{
+	OAUTH_STEP_INIT = 0,
+	OAUTH_STEP_DISCOVERY,
+	OAUTH_STEP_DEVICE_AUTHORIZATION,
+	OAUTH_STEP_TOKEN_REQUEST,
+	OAUTH_STEP_WAIT_INTERVAL,
+};
+
+/*
+ * The async_ctx holds onto state that needs to persist across multiple calls
+ * to pg_fe_run_oauth_flow(). Almost everything interacts with this in some
+ * way.
+ */
+struct async_ctx
+{
+	enum OAuthStep step;		/* where are we in the flow? */
+
+	int			timerfd;		/* descriptor for signaling async timeouts */
+	pgsocket	mux;			/* the multiplexer socket containing all
+								 * descriptors tracked by libcurl, plus the
+								 * timerfd */
+	CURLM	   *curlm;			/* top-level multi handle for libcurl
+								 * operations */
+	CURL	   *curl;			/* the (single) easy handle for serial
+								 * requests */
+
+	struct curl_slist *headers; /* common headers for all requests */
+	PQExpBufferData work_data;	/* scratch buffer for general use (remember to
+								 * clear out prior contents first!) */
+
+	/*------
+	 * Since a single logical operation may stretch across multiple calls to
+	 * our entry point, errors have three parts:
+	 *
+	 * - errctx:	an optional static string, describing the global operation
+	 *				currently in progress. It'll be translated for you.
+	 *
+	 * - errbuf:	contains the actual error message. Generally speaking, use
+	 *				actx_error[_str] to manipulate this. This must be filled
+	 *				with something useful on an error.
+	 *
+	 * - curl_err:	an optional static error buffer used by libcurl to put
+	 *				detailed information about failures. Unfortunately
+	 *				untranslatable.
+	 *
+	 * These pieces will be combined into a single error message looking
+	 * something like the following, with errctx and/or curl_err omitted when
+	 * absent:
+	 *
+	 *     connection to server ... failed: errctx: errbuf (curl_err)
+	 */
+	const char *errctx;			/* not freed; must point to static allocation */
+	PQExpBufferData errbuf;
+	char		curl_err[CURL_ERROR_SIZE];
+
+	/*
+	 * These documents need to survive over multiple calls, and are therefore
+	 * cached directly in the async_ctx.
+	 */
+	struct provider provider;
+	struct device_authz authz;
+
+	int			running;		/* is asynchronous work in progress? */
+	bool		user_prompted;	/* have we already sent the authz prompt? */
+	bool		used_basic_auth;	/* did we send a client secret? */
+	bool		debugging;		/* can we give unsafe developer assistance? */
+};
+
+/*
+ * Tears down the Curl handles and frees the async_ctx.
+ */
+static void
+free_async_ctx(PGconn *conn, struct async_ctx *actx)
+{
+	/*
+	 * In general, none of the error cases below should ever happen if we have
+	 * no bugs above. But if we do hit them, surfacing those errors somehow
+	 * might be the only way to have a chance to debug them.
+	 *
+	 * TODO: At some point it'd be nice to have a standard way to warn about
+	 * teardown failures. Appending to the connection's error message only
+	 * helps if the bug caused a connection failure; otherwise it'll be
+	 * buried...
+	 */
+
+	if (actx->curlm && actx->curl)
+	{
+		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));
+	}
+
+	if (actx->curl)
+	{
+		/*
+		 * curl_multi_cleanup() doesn't free any associated easy handles; we
+		 * need to do that separately. We only ever have one easy handle per
+		 * multi handle.
+		 */
+		curl_easy_cleanup(actx->curl);
+	}
+
+	if (actx->curlm)
+	{
+		CURLMcode	err = curl_multi_cleanup(actx->curlm);
+
+		if (err)
+			libpq_append_conn_error(conn,
+									"libcurl multi handle cleanup failed: %s",
+									curl_multi_strerror(err));
+	}
+
+	free_provider(&actx->provider);
+	free_device_authz(&actx->authz);
+
+	curl_slist_free_all(actx->headers);
+	termPQExpBuffer(&actx->work_data);
+	termPQExpBuffer(&actx->errbuf);
+
+	if (actx->mux != PGINVALID_SOCKET)
+		close(actx->mux);
+	if (actx->timerfd >= 0)
+		close(actx->timerfd);
+
+	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.
+ */
+void
+pg_fe_cleanup_oauth_flow(PGconn *conn)
+{
+	fe_oauth_state *state = conn->sasl_state;
+
+	if (state->async_ctx)
+	{
+		free_async_ctx(conn, state->async_ctx);
+		state->async_ctx = NULL;
+	}
+
+	conn->altsock = PGINVALID_SOCKET;
+}
+
+/*
+ * Macros for manipulating actx->errbuf. actx_error() translates and formats a
+ * string for you; actx_error_str() appends a string directly without
+ * translation.
+ */
+
+#define actx_error(ACTX, FMT, ...) \
+	appendPQExpBuffer(&(ACTX)->errbuf, libpq_gettext(FMT), ##__VA_ARGS__)
+
+#define actx_error_str(ACTX, S) \
+	appendPQExpBufferStr(&(ACTX)->errbuf, S)
+
+/*
+ * Macros for getting and setting state for the connection's two libcurl
+ * handles, so you don't have to write out the error handling every time.
+ */
+
+#define CHECK_MSETOPT(ACTX, OPT, VAL, FAILACTION) \
+	do { \
+		struct async_ctx *_actx = (ACTX); \
+		CURLMcode	_setopterr = curl_multi_setopt(_actx->curlm, OPT, VAL); \
+		if (_setopterr) { \
+			actx_error(_actx, "failed to set %s on OAuth connection: %s",\
+					   #OPT, curl_multi_strerror(_setopterr)); \
+			FAILACTION; \
+		} \
+	} while (0)
+
+#define CHECK_SETOPT(ACTX, OPT, VAL, FAILACTION) \
+	do { \
+		struct async_ctx *_actx = (ACTX); \
+		CURLcode	_setopterr = curl_easy_setopt(_actx->curl, OPT, VAL); \
+		if (_setopterr) { \
+			actx_error(_actx, "failed to set %s on OAuth connection: %s",\
+					   #OPT, curl_easy_strerror(_setopterr)); \
+			FAILACTION; \
+		} \
+	} while (0)
+
+#define CHECK_GETINFO(ACTX, INFO, OUT, FAILACTION) \
+	do { \
+		struct async_ctx *_actx = (ACTX); \
+		CURLcode	_getinfoerr = curl_easy_getinfo(_actx->curl, INFO, OUT); \
+		if (_getinfoerr) { \
+			actx_error(_actx, "failed to get %s from OAuth response: %s",\
+					   #INFO, curl_easy_strerror(_getinfoerr)); \
+			FAILACTION; \
+		} \
+	} while (0)
+
+/*
+ * General JSON Parsing for OAuth Responses
+ */
+
+/*
+ * Represents a single name/value pair in a JSON object. This is the primary
+ * interface to parse_oauth_json().
+ *
+ * All fields are stored internally as strings or lists of strings, so clients
+ * have to explicitly parse other scalar types (though they will have gone
+ * through basic lexical validation). Storing nested objects is not currently
+ * supported, nor is parsing arrays of anything other than strings.
+ */
+struct json_field
+{
+	const char *name;			/* name (key) of the member */
+
+	JsonTokenType type;			/* currently supports JSON_TOKEN_STRING,
+								 * JSON_TOKEN_NUMBER, and
+								 * JSON_TOKEN_ARRAY_START */
+	union
+	{
+		char	  **scalar;		/* for all scalar types */
+		struct curl_slist **array;	/* for type == JSON_TOKEN_ARRAY_START */
+	}			target;
+
+	bool		required;		/* REQUIRED field, or just OPTIONAL? */
+};
+
+/* Documentation macros for json_field.required. */
+#define REQUIRED true
+#define OPTIONAL false
+
+/* Parse state for parse_oauth_json(). */
+struct oauth_parse
+{
+	PQExpBuffer errbuf;			/* detail message for JSON_SEM_ACTION_FAILED */
+	int			nested;			/* nesting level (zero is the top) */
+
+	const struct json_field *fields;	/* field definition array */
+	const struct json_field *active;	/* points inside the fields array */
+};
+
+#define oauth_parse_set_error(ctx, fmt, ...) \
+	appendPQExpBuffer((ctx)->errbuf, libpq_gettext(fmt), ##__VA_ARGS__)
+
+static void
+report_type_mismatch(struct oauth_parse *ctx)
+{
+	char	   *msgfmt;
+
+	Assert(ctx->active);
+
+	/*
+	 * At the moment, the only fields we're interested in are strings,
+	 * numbers, and arrays of strings.
+	 */
+	switch (ctx->active->type)
+	{
+		case JSON_TOKEN_STRING:
+			msgfmt = "field \"%s\" must be a string";
+			break;
+
+		case JSON_TOKEN_NUMBER:
+			msgfmt = "field \"%s\" must be a number";
+			break;
+
+		case JSON_TOKEN_ARRAY_START:
+			msgfmt = "field \"%s\" must be an array of strings";
+			break;
+
+		default:
+			Assert(false);
+			msgfmt = "field \"%s\" has unexpected type";
+	}
+
+	oauth_parse_set_error(ctx, msgfmt, ctx->active->name);
+}
+
+static JsonParseErrorType
+oauth_json_object_start(void *state)
+{
+	struct oauth_parse *ctx = state;
+
+	if (ctx->active)
+	{
+		/*
+		 * Currently, none of the fields we're interested in can be or contain
+		 * objects, so we can reject this case outright.
+		 */
+		report_type_mismatch(ctx);
+		return JSON_SEM_ACTION_FAILED;
+	}
+
+	++ctx->nested;
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_object_field_start(void *state, char *name, bool isnull)
+{
+	struct oauth_parse *ctx = state;
+
+	/* We care only about the top-level fields. */
+	if (ctx->nested == 1)
+	{
+		const struct json_field *field = ctx->fields;
+
+		/*
+		 * We should never start parsing a new field while a previous one is
+		 * still active.
+		 */
+		if (ctx->active)
+		{
+			Assert(false);
+			oauth_parse_set_error(ctx,
+								  "internal error: started field '%s' before field '%s' was finished",
+								  name, ctx->active->name);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		while (field->name)
+		{
+			if (strcmp(name, field->name) == 0)
+			{
+				ctx->active = field;
+				break;
+			}
+
+			++field;
+		}
+
+		/*
+		 * We don't allow duplicate field names; error out if the target has
+		 * already been set.
+		 */
+		if (ctx->active)
+		{
+			field = ctx->active;
+
+			if ((field->type == JSON_TOKEN_ARRAY_START && *field->target.array)
+				|| (field->type != JSON_TOKEN_ARRAY_START && *field->target.scalar))
+			{
+				oauth_parse_set_error(ctx, "field \"%s\" is duplicated",
+									  field->name);
+				return JSON_SEM_ACTION_FAILED;
+			}
+		}
+	}
+
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_object_end(void *state)
+{
+	struct oauth_parse *ctx = state;
+
+	--ctx->nested;
+
+	/*
+	 * All fields should be fully processed by the end of the top-level
+	 * object.
+	 */
+	if (!ctx->nested && ctx->active)
+	{
+		Assert(false);
+		oauth_parse_set_error(ctx,
+							  "internal error: field '%s' still active at end of object",
+							  ctx->active->name);
+		return JSON_SEM_ACTION_FAILED;
+	}
+
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_array_start(void *state)
+{
+	struct oauth_parse *ctx = state;
+
+	if (!ctx->nested)
+	{
+		oauth_parse_set_error(ctx, "top-level element must be an object");
+		return JSON_SEM_ACTION_FAILED;
+	}
+
+	if (ctx->active)
+	{
+		if (ctx->active->type != JSON_TOKEN_ARRAY_START
+		/* The arrays we care about must not have arrays as values. */
+			|| ctx->nested > 1)
+		{
+			report_type_mismatch(ctx);
+			return JSON_SEM_ACTION_FAILED;
+		}
+	}
+
+	++ctx->nested;
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_array_end(void *state)
+{
+	struct oauth_parse *ctx = state;
+
+	if (ctx->active)
+	{
+		/*
+		 * Clear the target (which should be an array inside the top-level
+		 * object). For this to be safe, no target arrays can contain other
+		 * arrays; we check for that in the array_start callback.
+		 */
+		if (ctx->nested != 2 || ctx->active->type != JSON_TOKEN_ARRAY_START)
+		{
+			Assert(false);
+			oauth_parse_set_error(ctx,
+								  "internal error: found unexpected array end while parsing field '%s'",
+								  ctx->active->name);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		ctx->active = NULL;
+	}
+
+	--ctx->nested;
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_scalar(void *state, char *token, JsonTokenType type)
+{
+	struct oauth_parse *ctx = state;
+
+	if (!ctx->nested)
+	{
+		oauth_parse_set_error(ctx, "top-level element must be an object");
+		return JSON_SEM_ACTION_FAILED;
+	}
+
+	if (ctx->active)
+	{
+		const struct json_field *field = ctx->active;
+		JsonTokenType expected = field->type;
+
+		/* Make sure this matches what the active field expects. */
+		if (expected == JSON_TOKEN_ARRAY_START)
+		{
+			/* Are we actually inside an array? */
+			if (ctx->nested < 2)
+			{
+				report_type_mismatch(ctx);
+				return JSON_SEM_ACTION_FAILED;
+			}
+
+			/* Currently, arrays can only contain strings. */
+			expected = JSON_TOKEN_STRING;
+		}
+
+		if (type != expected)
+		{
+			report_type_mismatch(ctx);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		if (field->type != JSON_TOKEN_ARRAY_START)
+		{
+			/* Ensure that we're parsing the top-level keys... */
+			if (ctx->nested != 1)
+			{
+				Assert(false);
+				oauth_parse_set_error(ctx,
+									  "internal error: scalar target found at nesting level %d",
+									  ctx->nested);
+				return JSON_SEM_ACTION_FAILED;
+			}
+
+			/* ...and that a result has not already been set. */
+			if (*field->target.scalar)
+			{
+				Assert(false);
+				oauth_parse_set_error(ctx,
+									  "internal error: scalar field '%s' would be assigned twice",
+									  ctx->active->name);
+				return JSON_SEM_ACTION_FAILED;
+			}
+
+			*field->target.scalar = strdup(token);
+			if (!*field->target.scalar)
+				return JSON_OUT_OF_MEMORY;
+
+			ctx->active = NULL;
+
+			return JSON_SUCCESS;
+		}
+		else
+		{
+			struct curl_slist *temp;
+
+			/* The target array should be inside the top-level object. */
+			if (ctx->nested != 2)
+			{
+				Assert(false);
+				oauth_parse_set_error(ctx,
+									  "internal error: array member found at nesting level %d",
+									  ctx->nested);
+				return JSON_SEM_ACTION_FAILED;
+			}
+
+			/* Note that curl_slist_append() makes a copy of the token. */
+			temp = curl_slist_append(*field->target.array, token);
+			if (!temp)
+				return JSON_OUT_OF_MEMORY;
+
+			*field->target.array = temp;
+		}
+	}
+	else
+	{
+		/* otherwise we just ignore it */
+	}
+
+	return JSON_SUCCESS;
+}
+
+/*
+ * Checks the Content-Type header against the expected type. Parameters are
+ * allowed but ignored.
+ */
+static bool
+check_content_type(struct async_ctx *actx, const char *type)
+{
+	const size_t type_len = strlen(type);
+	char	   *content_type;
+
+	CHECK_GETINFO(actx, CURLINFO_CONTENT_TYPE, &content_type, return false);
+
+	if (!content_type)
+	{
+		actx_error(actx, "no content type was provided");
+		return false;
+	}
+
+	/*
+	 * We need to perform a length limited comparison and not compare the
+	 * whole string.
+	 */
+	if (pg_strncasecmp(content_type, type, type_len) != 0)
+		goto fail;
+
+	/* On an exact match, we're done. */
+	Assert(strlen(content_type) >= type_len);
+	if (content_type[type_len] == '\0')
+		return true;
+
+	/*
+	 * Only a semicolon (optionally preceded by HTTP optional whitespace) is
+	 * acceptable after the prefix we checked. This marks the start of media
+	 * type parameters, which we currently have no use for.
+	 */
+	for (size_t i = type_len; content_type[i]; ++i)
+	{
+		switch (content_type[i])
+		{
+			case ';':
+				return true;	/* success! */
+
+			case ' ':
+			case '\t':
+				/* HTTP optional whitespace allows only spaces and htabs. */
+				break;
+
+			default:
+				goto fail;
+		}
+	}
+
+fail:
+	actx_error(actx, "unexpected content type: \"%s\"", content_type);
+	return false;
+}
+
+/*
+ * A helper function for general JSON parsing. fields is the array of field
+ * definitions with their backing pointers. The response will be parsed from
+ * actx->curl and actx->work_data (as set up by start_request()), and any
+ * parsing errors will be placed into actx->errbuf.
+ */
+static bool
+parse_oauth_json(struct async_ctx *actx, const struct json_field *fields)
+{
+	PQExpBuffer resp = &actx->work_data;
+	JsonLexContext lex = {0};
+	JsonSemAction sem = {0};
+	JsonParseErrorType err;
+	struct oauth_parse ctx = {0};
+	bool		success = false;
+
+	if (!check_content_type(actx, "application/json"))
+		return false;
+
+	if (strlen(resp->data) != resp->len)
+	{
+		actx_error(actx, "response contains embedded NULLs");
+		return false;
+	}
+
+	/*
+	 * pg_parse_json doesn't validate the incoming UTF-8, so we have to check
+	 * that up front.
+	 */
+	if (pg_encoding_verifymbstr(PG_UTF8, resp->data, resp->len) != resp->len)
+	{
+		actx_error(actx, "response is not valid UTF-8");
+		return false;
+	}
+
+	makeJsonLexContextCstringLen(&lex, resp->data, resp->len, PG_UTF8, true);
+	setJsonLexContextOwnsTokens(&lex, true);	/* must not leak on error */
+
+	ctx.errbuf = &actx->errbuf;
+	ctx.fields = fields;
+	sem.semstate = &ctx;
+
+	sem.object_start = oauth_json_object_start;
+	sem.object_field_start = oauth_json_object_field_start;
+	sem.object_end = oauth_json_object_end;
+	sem.array_start = oauth_json_array_start;
+	sem.array_end = oauth_json_array_end;
+	sem.scalar = oauth_json_scalar;
+
+	err = pg_parse_json(&lex, &sem);
+
+	if (err != JSON_SUCCESS)
+	{
+		/*
+		 * For JSON_SEM_ACTION_FAILED, we've already written the error
+		 * message. Other errors come directly from pg_parse_json(), already
+		 * translated.
+		 */
+		if (err != JSON_SEM_ACTION_FAILED)
+			actx_error_str(actx, json_errdetail(err, &lex));
+
+		goto cleanup;
+	}
+
+	/* Check all required fields. */
+	while (fields->name)
+	{
+		if (fields->required
+			&& !*fields->target.scalar
+			&& !*fields->target.array)
+		{
+			actx_error(actx, "field \"%s\" is missing", fields->name);
+			goto cleanup;
+		}
+
+		fields++;
+	}
+
+	success = true;
+
+cleanup:
+	freeJsonLexContext(&lex);
+	return success;
+}
+
+/*
+ * JSON Parser Definitions
+ */
+
+/*
+ * Parses authorization server metadata. Fields are defined by OIDC Discovery
+ * 1.0 and RFC 8414.
+ */
+static bool
+parse_provider(struct async_ctx *actx, struct provider *provider)
+{
+	struct json_field fields[] = {
+		{"issuer", JSON_TOKEN_STRING, {&provider->issuer}, REQUIRED},
+		{"token_endpoint", JSON_TOKEN_STRING, {&provider->token_endpoint}, REQUIRED},
+
+		/*----
+		 * The following fields are technically REQUIRED, but we don't use
+		 * them anywhere yet:
+		 *
+		 * - jwks_uri
+		 * - response_types_supported
+		 * - subject_types_supported
+		 * - id_token_signing_alg_values_supported
+		 */
+
+		{"device_authorization_endpoint", JSON_TOKEN_STRING, {&provider->device_authorization_endpoint}, OPTIONAL},
+		{"grant_types_supported", JSON_TOKEN_ARRAY_START, {.array = &provider->grant_types_supported}, OPTIONAL},
+
+		{0},
+	};
+
+	return parse_oauth_json(actx, fields);
+}
+
+/*
+ * Parses a valid JSON number into a double. The input must have come from
+ * pg_parse_json(), so that we know the lexer has validated it; there's no
+ * in-band signal for invalid formats.
+ */
+static double
+parse_json_number(const char *s)
+{
+	double		parsed;
+	int			cnt;
+
+	/*
+	 * The JSON lexer has already validated the number, which is stricter than
+	 * the %f format, so we should be good to use sscanf().
+	 */
+	cnt = sscanf(s, "%lf", &parsed);
+
+	if (cnt != 1)
+	{
+		/*
+		 * Either the lexer screwed up or our assumption above isn't true, and
+		 * either way a developer needs to take a look.
+		 */
+		Assert(false);
+		return 0;
+	}
+
+	return parsed;
+}
+
+/*
+ * Parses the "interval" JSON number, corresponding to the number of seconds to
+ * wait between token endpoint requests.
+ *
+ * RFC 8628 is pretty silent on sanity checks for the interval. As a matter of
+ * practicality, round any fractional intervals up to the next second, and clamp
+ * the result at a minimum of one. (Zero-second intervals would result in an
+ * expensive network polling loop.) Tests may remove the lower bound with
+ * PGOAUTHDEBUG, for improved performance.
+ */
+static int
+parse_interval(struct async_ctx *actx, const char *interval_str)
+{
+	double		parsed;
+
+	parsed = parse_json_number(interval_str);
+	parsed = ceil(parsed);
+
+	if (parsed < 1)
+		return actx->debugging ? 0 : 1;
+
+	else if (parsed >= INT_MAX)
+		return INT_MAX;
+
+	return parsed;
+}
+
+/*
+ * Parses the "expires_in" JSON number, corresponding to the number of seconds
+ * remaining in the lifetime of the device code request.
+ *
+ * Similar to parse_interval, but we have even fewer requirements for reasonable
+ * values since we don't use the expiration time directly (it's passed to the
+ * PQAUTHDATA_PROMPT_OAUTH_DEVICE hook, in case the application wants to do
+ * something with it). We simply round and clamp to int range.
+ */
+static int
+parse_expires_in(struct async_ctx *actx, const char *expires_in_str)
+{
+	double		parsed;
+
+	parsed = parse_json_number(expires_in_str);
+	parsed = round(parsed);
+
+	if (parsed >= INT_MAX)
+		return INT_MAX;
+	else if (parsed <= INT_MIN)
+		return INT_MIN;
+
+	return parsed;
+}
+
+/*
+ * Parses the Device Authorization Response (RFC 8628, Sec. 3.2).
+ */
+static bool
+parse_device_authz(struct async_ctx *actx, struct device_authz *authz)
+{
+	struct json_field fields[] = {
+		{"device_code", JSON_TOKEN_STRING, {&authz->device_code}, REQUIRED},
+		{"user_code", JSON_TOKEN_STRING, {&authz->user_code}, REQUIRED},
+		{"verification_uri", JSON_TOKEN_STRING, {&authz->verification_uri}, REQUIRED},
+		{"expires_in", JSON_TOKEN_NUMBER, {&authz->expires_in_str}, REQUIRED},
+
+		/*
+		 * Some services (Google, Azure) spell verification_uri differently.
+		 * We accept either.
+		 */
+		{"verification_url", JSON_TOKEN_STRING, {&authz->verification_uri}, REQUIRED},
+
+		/*
+		 * There is no evidence of verification_uri_complete being spelled
+		 * with "url" instead with any service provider, so only support "uri".
+		 */
+		{"verification_uri_complete", JSON_TOKEN_STRING, {&authz->verification_uri_complete}, OPTIONAL},
+		{"interval", JSON_TOKEN_NUMBER, {&authz->interval_str}, OPTIONAL},
+
+		{0},
+	};
+
+	if (!parse_oauth_json(actx, fields))
+		return false;
+
+	/*
+	 * Parse our numeric fields. Lexing has already completed by this time, so
+	 * we at least know they're valid JSON numbers.
+	 */
+	if (authz->interval_str)
+		authz->interval = parse_interval(actx, authz->interval_str);
+	else
+	{
+		/*
+		 * RFC 8628 specifies 5 seconds as the default value if the server
+		 * doesn't provide an interval.
+		 */
+		authz->interval = 5;
+	}
+
+	Assert(authz->expires_in_str);	/* ensured by parse_oauth_json() */
+	authz->expires_in = parse_expires_in(actx, authz->expires_in_str);
+
+	return true;
+}
+
+/*
+ * Parses the device access token error response (RFC 8628, Sec. 3.5, which
+ * uses the error response defined in RFC 6749, Sec. 5.2).
+ */
+static bool
+parse_token_error(struct async_ctx *actx, struct token_error *err)
+{
+	bool		result;
+	struct json_field fields[] = {
+		{"error", JSON_TOKEN_STRING, {&err->error}, REQUIRED},
+
+		{"error_description", JSON_TOKEN_STRING, {&err->error_description}, OPTIONAL},
+
+		{0},
+	};
+
+	result = parse_oauth_json(actx, fields);
+
+	/*
+	 * Since token errors are parsed during other active error paths, only
+	 * override the errctx if parsing explicitly fails.
+	 */
+	if (!result)
+		actx->errctx = "failed to parse token error response";
+
+	return result;
+}
+
+/*
+ * Constructs a message from the token error response and puts it into
+ * actx->errbuf.
+ */
+static void
+record_token_error(struct async_ctx *actx, const struct token_error *err)
+{
+	if (err->error_description)
+		appendPQExpBuffer(&actx->errbuf, "%s ", err->error_description);
+	else
+	{
+		/*
+		 * Try to get some more helpful detail into the error string. A 401
+		 * status in particular implies that the oauth_client_secret is
+		 * missing or wrong.
+		 */
+		long		response_code;
+
+		CHECK_GETINFO(actx, CURLINFO_RESPONSE_CODE, &response_code, response_code = 0);
+
+		if (response_code == 401)
+		{
+			actx_error(actx, actx->used_basic_auth
+					   ? "provider rejected the oauth_client_secret"
+					   : "provider requires client authentication, and no oauth_client_secret is set");
+			actx_error_str(actx, " ");
+		}
+	}
+
+	appendPQExpBuffer(&actx->errbuf, "(%s)", err->error);
+}
+
+/*
+ * Parses the device access token response (RFC 8628, Sec. 3.5, which uses the
+ * success response defined in RFC 6749, Sec. 5.1).
+ */
+static bool
+parse_access_token(struct async_ctx *actx, struct token *tok)
+{
+	struct json_field fields[] = {
+		{"access_token", JSON_TOKEN_STRING, {&tok->access_token}, REQUIRED},
+		{"token_type", JSON_TOKEN_STRING, {&tok->token_type}, REQUIRED},
+
+		/*---
+		 * We currently have no use for the following OPTIONAL fields:
+		 *
+		 * - expires_in: This will be important for maintaining a token cache,
+		 *               but we do not yet implement one.
+		 *
+		 * - refresh_token: Ditto.
+		 *
+		 * - scope: This is only sent when the authorization server sees fit to
+		 *          change our scope request. It's not clear what we should do
+		 *          about this; either it's been done as a matter of policy, or
+		 *          the user has explicitly denied part of the authorization,
+		 *          and either way the server-side validator is in a better
+		 *          place to complain if the change isn't acceptable.
+		 */
+
+		{0},
+	};
+
+	return parse_oauth_json(actx, fields);
+}
+
+/*
+ * libcurl Multi Setup/Callbacks
+ */
+
+/*
+ * Sets up the actx->mux, which is the altsock that PQconnectPoll clients will
+ * select() on instead of the Postgres socket during OAuth negotiation.
+ *
+ * This is just an epoll set or kqueue abstracting multiple other descriptors.
+ * For epoll, the timerfd is always part of the set; it's just disabled when
+ * we're not using it. For kqueue, the "timerfd" is actually a second kqueue
+ * instance which is only added to the set when needed.
+ */
+static bool
+setup_multiplexer(struct async_ctx *actx)
+{
+#ifdef HAVE_SYS_EPOLL_H
+	struct epoll_event ev = {.events = EPOLLIN};
+
+	actx->mux = epoll_create1(EPOLL_CLOEXEC);
+	if (actx->mux < 0)
+	{
+		actx_error(actx, "failed to create epoll set: %m");
+		return false;
+	}
+
+	actx->timerfd = timerfd_create(CLOCK_MONOTONIC, TFD_CLOEXEC);
+	if (actx->timerfd < 0)
+	{
+		actx_error(actx, "failed to create timerfd: %m");
+		return false;
+	}
+
+	if (epoll_ctl(actx->mux, EPOLL_CTL_ADD, actx->timerfd, &ev) < 0)
+	{
+		actx_error(actx, "failed to add timerfd to epoll set: %m");
+		return false;
+	}
+
+	return true;
+#endif
+#ifdef HAVE_SYS_EVENT_H
+	actx->mux = kqueue();
+	if (actx->mux < 0)
+	{
+		/*- translator: the term "kqueue" (kernel queue) should not be translated */
+		actx_error(actx, "failed to create kqueue: %m");
+		return false;
+	}
+
+	/*
+	 * Originally, we set EVFILT_TIMER directly on the top-level multiplexer.
+	 * This makes it difficult to implement timer_expired(), though, so now we
+	 * set EVFILT_TIMER on a separate actx->timerfd, which is chained to
+	 * actx->mux while the timer is active.
+	 */
+	actx->timerfd = kqueue();
+	if (actx->timerfd < 0)
+	{
+		actx_error(actx, "failed to create timer kqueue: %m");
+		return false;
+	}
+
+	return true;
+#endif
+
+	actx_error(actx, "libpq does not support the Device Authorization flow on this platform");
+	return false;
+}
+
+/*
+ * Adds and removes sockets from the multiplexer set, as directed by the
+ * libcurl multi handle.
+ */
+static int
+register_socket(CURL *curl, curl_socket_t socket, int what, void *ctx,
+				void *socketp)
+{
+#ifdef HAVE_SYS_EPOLL_H
+	struct async_ctx *actx = ctx;
+	struct epoll_event ev = {0};
+	int			res;
+	int			op = EPOLL_CTL_ADD;
+
+	switch (what)
+	{
+		case CURL_POLL_IN:
+			ev.events = EPOLLIN;
+			break;
+
+		case CURL_POLL_OUT:
+			ev.events = EPOLLOUT;
+			break;
+
+		case CURL_POLL_INOUT:
+			ev.events = EPOLLIN | EPOLLOUT;
+			break;
+
+		case CURL_POLL_REMOVE:
+			op = EPOLL_CTL_DEL;
+			break;
+
+		default:
+			actx_error(actx, "unknown libcurl socket operation: %d", what);
+			return -1;
+	}
+
+	res = epoll_ctl(actx->mux, op, socket, &ev);
+	if (res < 0 && errno == EEXIST)
+	{
+		/* We already had this socket in the pollset. */
+		op = EPOLL_CTL_MOD;
+		res = epoll_ctl(actx->mux, op, socket, &ev);
+	}
+
+	if (res < 0)
+	{
+		switch (op)
+		{
+			case EPOLL_CTL_ADD:
+				actx_error(actx, "could not add to epoll set: %m");
+				break;
+
+			case EPOLL_CTL_DEL:
+				actx_error(actx, "could not delete from epoll set: %m");
+				break;
+
+			default:
+				actx_error(actx, "could not update epoll set: %m");
+		}
+
+		return -1;
+	}
+#endif
+#ifdef HAVE_SYS_EVENT_H
+	struct async_ctx *actx = ctx;
+	struct kevent ev[2] = {{0}};
+	struct kevent ev_out[2];
+	struct timespec timeout = {0};
+	int			nev = 0;
+	int			res;
+
+	switch (what)
+	{
+		case CURL_POLL_IN:
+			EV_SET(&ev[nev], socket, EVFILT_READ, EV_ADD | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			break;
+
+		case CURL_POLL_OUT:
+			EV_SET(&ev[nev], socket, EVFILT_WRITE, EV_ADD | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			break;
+
+		case CURL_POLL_INOUT:
+			EV_SET(&ev[nev], socket, EVFILT_READ, EV_ADD | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			EV_SET(&ev[nev], socket, EVFILT_WRITE, EV_ADD | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			break;
+
+		case CURL_POLL_REMOVE:
+
+			/*
+			 * We don't know which of these is currently registered, perhaps
+			 * both, so we try to remove both.  This means we need to tolerate
+			 * ENOENT below.
+			 */
+			EV_SET(&ev[nev], socket, EVFILT_READ, EV_DELETE | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			EV_SET(&ev[nev], socket, EVFILT_WRITE, EV_DELETE | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			break;
+
+		default:
+			actx_error(actx, "unknown libcurl socket operation: %d", what);
+			return -1;
+	}
+
+	res = kevent(actx->mux, ev, nev, ev_out, lengthof(ev_out), &timeout);
+	if (res < 0)
+	{
+		actx_error(actx, "could not modify kqueue: %m");
+		return -1;
+	}
+
+	/*
+	 * We can't use the simple errno version of kevent, because we need to
+	 * skip over ENOENT while still allowing a second change to be processed.
+	 * So we need a longer-form error checking loop.
+	 */
+	for (int i = 0; i < res; ++i)
+	{
+		/*
+		 * EV_RECEIPT should guarantee one EV_ERROR result for every change,
+		 * whether successful or not. Failed entries contain a non-zero errno
+		 * in the data field.
+		 */
+		Assert(ev_out[i].flags & EV_ERROR);
+
+		errno = ev_out[i].data;
+		if (errno && errno != ENOENT)
+		{
+			switch (what)
+			{
+				case CURL_POLL_REMOVE:
+					actx_error(actx, "could not delete from kqueue: %m");
+					break;
+				default:
+					actx_error(actx, "could not add to kqueue: %m");
+			}
+			return -1;
+		}
+	}
+#endif
+
+	return 0;
+}
+
+/*
+ * Enables or disables the timer in the multiplexer set. The timeout value is
+ * in milliseconds (negative values disable the timer).
+ *
+ * For epoll, rather than continually adding and removing the timer, we keep it
+ * in the set at all times and just disarm it when it's not needed. For kqueue,
+ * the timer is removed completely when disabled to prevent stale timeouts from
+ * remaining in the queue.
+ */
+static bool
+set_timer(struct async_ctx *actx, long timeout)
+{
+#if HAVE_SYS_EPOLL_H
+	struct itimerspec spec = {0};
+
+	if (timeout < 0)
+	{
+		/* the zero itimerspec will disarm the timer below */
+	}
+	else if (timeout == 0)
+	{
+		/*
+		 * A zero timeout means libcurl wants us to call back immediately.
+		 * That's not technically an option for timerfd, but we can make the
+		 * timeout ridiculously short.
+		 */
+		spec.it_value.tv_nsec = 1;
+	}
+	else
+	{
+		spec.it_value.tv_sec = timeout / 1000;
+		spec.it_value.tv_nsec = (timeout % 1000) * 1000000;
+	}
+
+	if (timerfd_settime(actx->timerfd, 0 /* no flags */ , &spec, NULL) < 0)
+	{
+		actx_error(actx, "setting timerfd to %ld: %m", timeout);
+		return false;
+	}
+
+	return true;
+#endif
+#ifdef HAVE_SYS_EVENT_H
+	struct kevent ev;
+
+	/* Enable/disable the timer itself. */
+	EV_SET(&ev, 1, EVFILT_TIMER, timeout < 0 ? EV_DELETE : (EV_ADD | EV_ONESHOT),
+		   0, timeout, 0);
+	if (kevent(actx->timerfd, &ev, 1, NULL, 0, NULL) < 0 && errno != ENOENT)
+	{
+		actx_error(actx, "setting kqueue timer to %ld: %m", timeout);
+		return false;
+	}
+
+	/*
+	 * Add/remove the timer to/from the mux. (In contrast with epoll, if we
+	 * allowed the timer to remain registered here after being disabled, the
+	 * mux queue would retain any previous stale timeout notifications and
+	 * remain readable.)
+	 */
+	EV_SET(&ev, actx->timerfd, EVFILT_READ, timeout < 0 ? EV_DELETE : EV_ADD,
+		   0, 0, 0);
+	if (kevent(actx->mux, &ev, 1, NULL, 0, NULL) < 0 && errno != ENOENT)
+	{
+		actx_error(actx, "could not update timer on kqueue: %m");
+		return false;
+	}
+
+	return true;
+#endif
+
+	actx_error(actx, "libpq does not support timers on this platform");
+	return false;
+}
+
+/*
+ * Returns 1 if the timeout in the multiplexer set has expired since the last
+ * call to set_timer(), 0 if the timer is still running, or -1 (with an
+ * actx_error() report) if the timer cannot be queried.
+ */
+static int
+timer_expired(struct async_ctx *actx)
+{
+#if HAVE_SYS_EPOLL_H
+	struct itimerspec spec = {0};
+
+	if (timerfd_gettime(actx->timerfd, &spec) < 0)
+	{
+		actx_error(actx, "getting timerfd value: %m");
+		return -1;
+	}
+
+	/*
+	 * This implementation assumes we're using single-shot timers. If you
+	 * change to using intervals, you'll need to reimplement this function
+	 * too, possibly with the read() or select() interfaces for timerfd.
+	 */
+	Assert(spec.it_interval.tv_sec == 0
+		   && spec.it_interval.tv_nsec == 0);
+
+	/* If the remaining time to expiration is zero, we're done. */
+	return (spec.it_value.tv_sec == 0
+			&& spec.it_value.tv_nsec == 0);
+#endif
+#ifdef HAVE_SYS_EVENT_H
+	int			res;
+
+	/* Is the timer queue ready? */
+	res = PQsocketPoll(actx->timerfd, 1 /* forRead */ , 0, 0);
+	if (res < 0)
+	{
+		actx_error(actx, "checking kqueue for timeout: %m");
+		return -1;
+	}
+
+	return (res > 0);
+#endif
+
+	actx_error(actx, "libpq does not support timers on this platform");
+	return -1;
+}
+
+/*
+ * Adds or removes timeouts from the multiplexer set, as directed by the
+ * libcurl multi handle.
+ */
+static int
+register_timer(CURLM *curlm, long timeout, void *ctx)
+{
+	struct async_ctx *actx = ctx;
+
+	/*
+	 * There might be an optimization opportunity here: if timeout == 0, we
+	 * could signal drive_request to immediately call
+	 * curl_multi_socket_action, rather than returning all the way up the
+	 * stack only to come right back. But it's not clear that the additional
+	 * code complexity is worth it.
+	 */
+	if (!set_timer(actx, timeout))
+		return -1;				/* actx_error already called */
+
+	return 0;
+}
+
+/*
+ * Prints Curl request debugging information to stderr.
+ *
+ * Note that this will expose a number of critical secrets, so users have to opt
+ * into this (see PGOAUTHDEBUG).
+ */
+static int
+debug_callback(CURL *handle, curl_infotype type, char *data, size_t size,
+			   void *clientp)
+{
+	const char *prefix;
+	bool		printed_prefix = false;
+	PQExpBufferData buf;
+
+	/* Prefixes are modeled off of the default libcurl debug output. */
+	switch (type)
+	{
+		case CURLINFO_TEXT:
+			prefix = "*";
+			break;
+
+		case CURLINFO_HEADER_IN:	/* fall through */
+		case CURLINFO_DATA_IN:
+			prefix = "<";
+			break;
+
+		case CURLINFO_HEADER_OUT:	/* fall through */
+		case CURLINFO_DATA_OUT:
+			prefix = ">";
+			break;
+
+		default:
+			return 0;
+	}
+
+	initPQExpBuffer(&buf);
+
+	/*
+	 * Split the output into lines for readability; sometimes multiple headers
+	 * are included in a single call. We also don't allow unprintable ASCII
+	 * through without a basic <XX> escape.
+	 */
+	for (int i = 0; i < size; i++)
+	{
+		char		c = data[i];
+
+		if (!printed_prefix)
+		{
+			appendPQExpBuffer(&buf, "[libcurl] %s ", prefix);
+			printed_prefix = true;
+		}
+
+		if (c >= 0x20 && c <= 0x7E)
+			appendPQExpBufferChar(&buf, c);
+		else if ((type == CURLINFO_HEADER_IN
+				  || type == CURLINFO_HEADER_OUT
+				  || type == CURLINFO_TEXT)
+				 && (c == '\r' || c == '\n'))
+		{
+			/*
+			 * Don't bother emitting <0D><0A> for headers and text; it's not
+			 * helpful noise.
+			 */
+		}
+		else
+			appendPQExpBuffer(&buf, "<%02X>", c);
+
+		if (c == '\n')
+		{
+			appendPQExpBufferChar(&buf, c);
+			printed_prefix = false;
+		}
+	}
+
+	if (printed_prefix)
+		appendPQExpBufferChar(&buf, '\n');	/* finish the line */
+
+	fprintf(stderr, "%s", buf.data);
+	termPQExpBuffer(&buf);
+	return 0;
+}
+
+/*
+ * Initializes the two libcurl handles in the async_ctx. The multi handle,
+ * actx->curlm, is what drives the asynchronous engine and tells us what to do
+ * next. The easy handle, actx->curl, encapsulates the state for a single
+ * request/response. It's added to the multi handle as needed, during
+ * start_request().
+ */
+static bool
+setup_curl_handles(struct async_ctx *actx)
+{
+	/*
+	 * Create our multi handle. This encapsulates the entire conversation with
+	 * libcurl for this connection.
+	 */
+	actx->curlm = curl_multi_init();
+	if (!actx->curlm)
+	{
+		/* We don't get a lot of feedback on the failure reason. */
+		actx_error(actx, "failed to create libcurl multi handle");
+		return false;
+	}
+
+	/*
+	 * The multi handle tells us what to wait on using two callbacks. These
+	 * will manipulate actx->mux as needed.
+	 */
+	CHECK_MSETOPT(actx, CURLMOPT_SOCKETFUNCTION, register_socket, return false);
+	CHECK_MSETOPT(actx, CURLMOPT_SOCKETDATA, actx, return false);
+	CHECK_MSETOPT(actx, CURLMOPT_TIMERFUNCTION, register_timer, return false);
+	CHECK_MSETOPT(actx, CURLMOPT_TIMERDATA, actx, return false);
+
+	/*
+	 * Set up an easy handle. All of our requests are made serially, so we
+	 * only ever need to keep track of one.
+	 */
+	actx->curl = curl_easy_init();
+	if (!actx->curl)
+	{
+		actx_error(actx, "failed to create libcurl handle");
+		return false;
+	}
+
+	/*
+	 * Multi-threaded applications must set CURLOPT_NOSIGNAL. This requires us
+	 * to handle the possibility of SIGPIPE ourselves using pq_block_sigpipe;
+	 * see pg_fe_run_oauth_flow().
+	 *
+	 * NB: If libcurl is not built against a friendly DNS resolver (c-ares or
+	 * threaded), setting this option prevents DNS lookups from timing out
+	 * correctly. We warn about this situation at configure time.
+	 *
+	 * TODO: Perhaps there's a clever way to warn the user about synchronous
+	 * DNS at runtime too? It's not immediately clear how to do that in a
+	 * helpful way: for many standard single-threaded use cases, the user
+	 * might not care at all, so spraying warnings to stderr would probably do
+	 * more harm than good.
+	 */
+	CHECK_SETOPT(actx, CURLOPT_NOSIGNAL, 1L, return false);
+
+	if (actx->debugging)
+	{
+		/*
+		 * Set a callback for retrieving error information from libcurl, the
+		 * function only takes effect when CURLOPT_VERBOSE has been set so
+		 * make sure the order is kept.
+		 */
+		CHECK_SETOPT(actx, CURLOPT_DEBUGFUNCTION, debug_callback, return false);
+		CHECK_SETOPT(actx, CURLOPT_VERBOSE, 1L, return false);
+	}
+
+	CHECK_SETOPT(actx, CURLOPT_ERRORBUFFER, actx->curl_err, return false);
+
+	/*
+	 * Only HTTPS is allowed. (Debug mode additionally allows HTTP; this is
+	 * intended for testing only.)
+	 *
+	 * There's a bit of unfortunate complexity around the choice of
+	 * CURLoption. CURLOPT_PROTOCOLS is deprecated in modern Curls, but its
+	 * replacement didn't show up until relatively recently.
+	 */
+	{
+#if CURL_AT_LEAST_VERSION(7, 85, 0)
+		const CURLoption popt = CURLOPT_PROTOCOLS_STR;
+		const char *protos = "https";
+		const char *const unsafe = "https,http";
+#else
+		const CURLoption popt = CURLOPT_PROTOCOLS;
+		long		protos = CURLPROTO_HTTPS;
+		const long	unsafe = CURLPROTO_HTTPS | CURLPROTO_HTTP;
+#endif
+
+		if (actx->debugging)
+			protos = unsafe;
+
+		CHECK_SETOPT(actx, popt, protos, return false);
+	}
+
+	/*
+	 * If we're in debug mode, allow the developer to change the trusted CA
+	 * list. For now, this is not something we expose outside of the UNSAFE
+	 * mode, because it's not clear that it's useful in production: both libpq
+	 * and the user's browser must trust the same authorization servers for
+	 * the flow to work at all, so any changes to the roots are likely to be
+	 * done system-wide.
+	 */
+	if (actx->debugging)
+	{
+		const char *env;
+
+		if ((env = getenv("PGOAUTHCAFILE")) != NULL)
+			CHECK_SETOPT(actx, CURLOPT_CAINFO, env, return false);
+	}
+
+	/*
+	 * Suppress the Accept header to make our request as minimal as possible.
+	 * (Ideally we would set it to "application/json" instead, but OpenID is
+	 * pretty strict when it comes to provider behavior, so we have to check
+	 * what comes back anyway.)
+	 */
+	actx->headers = curl_slist_append(actx->headers, "Accept:");
+	if (actx->headers == NULL)
+	{
+		actx_error(actx, "out of memory");
+		return false;
+	}
+	CHECK_SETOPT(actx, CURLOPT_HTTPHEADER, actx->headers, return false);
+
+	return true;
+}
+
+/*
+ * Generic HTTP Request Handlers
+ */
+
+/*
+ * Response callback from libcurl which appends the response body into
+ * actx->work_data (see start_request()). The maximum size of the data is
+ * defined by CURL_MAX_WRITE_SIZE which by default is 16kb (and can only be
+ * changed by recompiling libcurl).
+ */
+static size_t
+append_data(char *buf, size_t size, size_t nmemb, void *userdata)
+{
+	struct async_ctx *actx = userdata;
+	PQExpBuffer resp = &actx->work_data;
+	size_t		len = size * nmemb;
+
+	/* In case we receive data over the threshold, abort the transfer */
+	if ((resp->len + len) > MAX_OAUTH_RESPONSE_SIZE)
+	{
+		actx_error(actx, "response is too large");
+		return 0;
+	}
+
+	/* The data passed from libcurl is not null-terminated */
+	appendBinaryPQExpBuffer(resp, buf, len);
+
+	/*
+	 * Signal an error in order to abort the transfer in case we ran out of
+	 * memory in accepting the data.
+	 */
+	if (PQExpBufferBroken(resp))
+	{
+		actx_error(actx, "out of memory");
+		return 0;
+	}
+
+	return len;
+}
+
+/*
+ * Begins an HTTP request on the multi handle. The caller should have set up all
+ * request-specific options on actx->curl first. The server's response body will
+ * be accumulated in actx->work_data (which will be reset, so don't store
+ * anything important there across this call).
+ *
+ * Once a request is queued, it can be driven to completion via drive_request().
+ * If actx->running is zero upon return, the request has already finished and
+ * drive_request() can be called without returning control to the client.
+ */
+static bool
+start_request(struct async_ctx *actx)
+{
+	CURLMcode	err;
+
+	resetPQExpBuffer(&actx->work_data);
+	CHECK_SETOPT(actx, CURLOPT_WRITEFUNCTION, append_data, return false);
+	CHECK_SETOPT(actx, CURLOPT_WRITEDATA, actx, return false);
+
+	err = curl_multi_add_handle(actx->curlm, actx->curl);
+	if (err)
+	{
+		actx_error(actx, "failed to queue HTTP request: %s",
+				   curl_multi_strerror(err));
+		return false;
+	}
+
+	/*
+	 * actx->running tracks the number of running handles, so we can
+	 * immediately call back if no waiting is needed.
+	 *
+	 * Even though this is nominally an asynchronous process, there are some
+	 * operations that can synchronously fail by this point (e.g. connections
+	 * to closed local ports) or even synchronously succeed if the stars align
+	 * (all the libcurl connection caches hit and the server is fast).
+	 */
+	err = curl_multi_socket_action(actx->curlm, CURL_SOCKET_TIMEOUT, 0, &actx->running);
+	if (err)
+	{
+		actx_error(actx, "asynchronous HTTP request failed: %s",
+				   curl_multi_strerror(err));
+		return false;
+	}
+
+	return true;
+}
+
+/*
+ * CURL_IGNORE_DEPRECATION was added in 7.87.0. If it's not defined, we can make
+ * it a no-op.
+ */
+#ifndef CURL_IGNORE_DEPRECATION
+#define CURL_IGNORE_DEPRECATION(x) x
+#endif
+
+/*
+ * Drives the multi handle towards completion. The caller should have already
+ * set up an asynchronous request via start_request().
+ */
+static PostgresPollingStatusType
+drive_request(struct async_ctx *actx)
+{
+	CURLMcode	err;
+	CURLMsg    *msg;
+	int			msgs_left;
+	bool		done;
+
+	if (actx->running)
+	{
+		/*---
+		 * There's an async request in progress. Pump the multi handle.
+		 *
+		 * curl_multi_socket_all() is officially deprecated, because it's
+		 * inefficient and pointless if your event loop has already handed you
+		 * the exact sockets that are ready. But that's not our use case --
+		 * our client has no way to tell us which sockets are ready. (They
+		 * don't even know there are sockets to begin with.)
+		 *
+		 * We can grab the list of triggered events from the multiplexer
+		 * ourselves, but that's effectively what curl_multi_socket_all() is
+		 * going to do. And there are currently no plans for the Curl project
+		 * to remove or break this API, so ignore the deprecation. See
+		 *
+		 *    https://curl.se/mail/lib-2024-11/0028.html
+		 *
+		 */
+		CURL_IGNORE_DEPRECATION(
+			err = curl_multi_socket_all(actx->curlm, &actx->running);
+		)
+
+		if (err)
+		{
+			actx_error(actx, "asynchronous HTTP request failed: %s",
+					   curl_multi_strerror(err));
+			return PGRES_POLLING_FAILED;
+		}
+
+		if (actx->running)
+		{
+			/* We'll come back again. */
+			return PGRES_POLLING_READING;
+		}
+	}
+
+	done = false;
+	while ((msg = curl_multi_info_read(actx->curlm, &msgs_left)) != NULL)
+	{
+		if (msg->msg != CURLMSG_DONE)
+		{
+			/*
+			 * Future libcurl versions may define new message types; we don't
+			 * know how to handle them, so we'll ignore them.
+			 */
+			continue;
+		}
+
+		/* First check the status of the request itself. */
+		if (msg->data.result != CURLE_OK)
+		{
+			/*
+			 * If a more specific error hasn't already been reported, use
+			 * libcurl's description.
+			 */
+			if (actx->errbuf.len == 0)
+				actx_error_str(actx, curl_easy_strerror(msg->data.result));
+
+			return PGRES_POLLING_FAILED;
+		}
+
+		/* Now remove the finished handle; we'll add it back later if needed. */
+		err = curl_multi_remove_handle(actx->curlm, msg->easy_handle);
+		if (err)
+		{
+			actx_error(actx, "libcurl easy handle removal failed: %s",
+					   curl_multi_strerror(err));
+			return PGRES_POLLING_FAILED;
+		}
+
+		done = true;
+	}
+
+	/* Sanity check. */
+	if (!done)
+	{
+		actx_error(actx, "no result was retrieved for the finished handle");
+		return PGRES_POLLING_FAILED;
+	}
+
+	return PGRES_POLLING_OK;
+}
+
+/*
+ * URL-Encoding Helpers
+ */
+
+/*
+ * Encodes a string using the application/x-www-form-urlencoded format, and
+ * appends it to the given buffer.
+ */
+static void
+append_urlencoded(PQExpBuffer buf, const char *s)
+{
+	char	   *escaped;
+	char	   *haystack;
+	char	   *match;
+
+	/* The first parameter to curl_easy_escape is deprecated by Curl */
+	escaped = curl_easy_escape(NULL, s, 0);
+	if (!escaped)
+	{
+		termPQExpBuffer(buf);	/* mark the buffer broken */
+		return;
+	}
+
+	/*
+	 * curl_easy_escape() almost does what we want, but we need the
+	 * query-specific flavor which uses '+' instead of '%20' for spaces. The
+	 * Curl command-line tool does this with a simple search-and-replace, so
+	 * follow its lead.
+	 */
+	haystack = escaped;
+
+	while ((match = strstr(haystack, "%20")) != NULL)
+	{
+		/* Append the unmatched portion, followed by the plus sign. */
+		appendBinaryPQExpBuffer(buf, haystack, match - haystack);
+		appendPQExpBufferChar(buf, '+');
+
+		/* Keep searching after the match. */
+		haystack = match + 3 /* strlen("%20") */ ;
+	}
+
+	/* Push the remainder of the string onto the buffer. */
+	appendPQExpBufferStr(buf, haystack);
+
+	curl_free(escaped);
+}
+
+/*
+ * Convenience wrapper for encoding a single string. Returns NULL on allocation
+ * failure.
+ */
+static char *
+urlencode(const char *s)
+{
+	PQExpBufferData buf;
+
+	initPQExpBuffer(&buf);
+	append_urlencoded(&buf, s);
+
+	return PQExpBufferDataBroken(buf) ? NULL : buf.data;
+}
+
+/*
+ * Appends a key/value pair to the end of an application/x-www-form-urlencoded
+ * list.
+ */
+static void
+build_urlencoded(PQExpBuffer buf, const char *key, const char *value)
+{
+	if (buf->len)
+		appendPQExpBufferChar(buf, '&');
+
+	append_urlencoded(buf, key);
+	appendPQExpBufferChar(buf, '=');
+	append_urlencoded(buf, value);
+}
+
+/*
+ * Specific HTTP Request Handlers
+ *
+ * This is finally the beginning of the actual application logic. Generally
+ * speaking, a single request consists of a start_* and a finish_* step, with
+ * drive_request() pumping the machine in between.
+ */
+
+/*
+ * Queue an OpenID Provider Configuration Request:
+ *
+ *     https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationRequest
+ *     https://www.rfc-editor.org/rfc/rfc8414#section-3.1
+ *
+ * This is done first to get the endpoint URIs we need to contact and to make
+ * sure the provider provides a device authorization flow. finish_discovery()
+ * will fill in actx->provider.
+ */
+static bool
+start_discovery(struct async_ctx *actx, const char *discovery_uri)
+{
+	CHECK_SETOPT(actx, CURLOPT_HTTPGET, 1L, return false);
+	CHECK_SETOPT(actx, CURLOPT_URL, discovery_uri, return false);
+
+	return start_request(actx);
+}
+
+static bool
+finish_discovery(struct async_ctx *actx)
+{
+	long		response_code;
+
+	/*----
+	 * Now check the response. OIDC Discovery 1.0 is pretty strict:
+	 *
+	 *     A successful response MUST use the 200 OK HTTP status code and
+	 *     return a JSON object using the application/json content type that
+	 *     contains a set of Claims as its members that are a subset of the
+	 *     Metadata values defined in Section 3.
+	 *
+	 * Compared to standard HTTP semantics, this makes life easy -- we don't
+	 * need to worry about redirections (which would call the Issuer host
+	 * validation into question), or non-authoritative responses, or any other
+	 * complications.
+	 */
+	CHECK_GETINFO(actx, CURLINFO_RESPONSE_CODE, &response_code, return false);
+
+	if (response_code != 200)
+	{
+		actx_error(actx, "unexpected response code %ld", response_code);
+		return false;
+	}
+
+	/*
+	 * Pull the fields we care about from the document.
+	 */
+	actx->errctx = "failed to parse OpenID discovery document";
+	if (!parse_provider(actx, &actx->provider))
+		return false;			/* error message already set */
+
+	/*
+	 * Fill in any defaults for OPTIONAL/RECOMMENDED fields we care about.
+	 */
+	if (!actx->provider.grant_types_supported)
+	{
+		/*
+		 * Per Section 3, the default is ["authorization_code", "implicit"].
+		 */
+		struct curl_slist *temp = actx->provider.grant_types_supported;
+
+		temp = curl_slist_append(temp, "authorization_code");
+		if (temp)
+		{
+			temp = curl_slist_append(temp, "implicit");
+		}
+
+		if (!temp)
+		{
+			actx_error(actx, "out of memory");
+			return false;
+		}
+
+		actx->provider.grant_types_supported = temp;
+	}
+
+	return true;
+}
+
+/*
+ * Ensure that the discovery document is provided by the expected issuer.
+ * Currently, issuers are statically configured in the connection string.
+ */
+static bool
+check_issuer(struct async_ctx *actx, PGconn *conn)
+{
+	const struct provider *provider = &actx->provider;
+
+	Assert(conn->oauth_issuer_id);	/* ensured by setup_oauth_parameters() */
+	Assert(provider->issuer);	/* ensured by parse_provider() */
+
+	/*---
+	 * We require strict equality for issuer identifiers -- no path or case
+	 * normalization, no substitution of default ports and schemes, etc. This
+	 * is done to match the rules in OIDC Discovery Sec. 4.3 for config
+	 * validation:
+	 *
+	 *    The issuer value returned MUST be identical to the Issuer URL that
+	 *    was used as the prefix to /.well-known/openid-configuration to
+	 *    retrieve the configuration information.
+	 *
+	 * as well as the rules set out in RFC 9207 for avoiding mix-up attacks:
+	 *
+	 *    Clients MUST then [...] compare the result to the issuer identifier
+	 *    of the authorization server where the authorization request was
+	 *    sent to. This comparison MUST use simple string comparison as defined
+	 *    in Section 6.2.1 of [RFC3986].
+	 */
+	if (strcmp(conn->oauth_issuer_id, provider->issuer) != 0)
+	{
+		actx_error(actx,
+				   "the issuer identifier (%s) does not match oauth_issuer (%s)",
+				   provider->issuer, conn->oauth_issuer_id);
+		return false;
+	}
+
+	return true;
+}
+
+#define HTTPS_SCHEME "https://"
+#define OAUTH_GRANT_TYPE_DEVICE_CODE "urn:ietf:params:oauth:grant-type:device_code"
+
+/*
+ * Ensure that the provider supports the Device Authorization flow (i.e. it
+ * provides an authorization endpoint, and both the token and authorization
+ * endpoint URLs seem reasonable).
+ */
+static bool
+check_for_device_flow(struct async_ctx *actx)
+{
+	const struct provider *provider = &actx->provider;
+
+	Assert(provider->issuer);	/* ensured by parse_provider() */
+	Assert(provider->token_endpoint);	/* ensured by parse_provider() */
+
+	if (!provider->device_authorization_endpoint)
+	{
+		actx_error(actx,
+				   "issuer \"%s\" does not provide a device authorization endpoint",
+				   provider->issuer);
+		return false;
+	}
+
+	/*
+	 * The original implementation checked that OAUTH_GRANT_TYPE_DEVICE_CODE
+	 * was present in the discovery document's grant_types_supported list. MS
+	 * Entra does not advertise this grant type, though, and since it doesn't
+	 * make sense to stand up a device_authorization_endpoint without also
+	 * accepting device codes at the token_endpoint, that's the only thing we
+	 * currently require.
+	 */
+
+	/*
+	 * Although libcurl will fail later if the URL contains an unsupported
+	 * scheme, that error message is going to be a bit opaque. This is a
+	 * decent time to bail out if we're not using HTTPS for the endpoints
+	 * we'll use for the flow.
+	 */
+	if (!actx->debugging)
+	{
+		if (pg_strncasecmp(provider->device_authorization_endpoint,
+						   HTTPS_SCHEME, strlen(HTTPS_SCHEME)) != 0)
+		{
+			actx_error(actx,
+					   "device authorization endpoint \"%s\" must use HTTPS",
+					   provider->device_authorization_endpoint);
+			return false;
+		}
+
+		if (pg_strncasecmp(provider->token_endpoint,
+						   HTTPS_SCHEME, strlen(HTTPS_SCHEME)) != 0)
+		{
+			actx_error(actx,
+					   "token endpoint \"%s\" must use HTTPS",
+					   provider->token_endpoint);
+			return false;
+		}
+	}
+
+	return true;
+}
+
+/*
+ * Adds the client ID (and secret, if provided) to the current request, using
+ * either HTTP headers or the request body.
+ */
+static bool
+add_client_identification(struct async_ctx *actx, PQExpBuffer reqbody, PGconn *conn)
+{
+	bool		success = false;
+	char	   *username = NULL;
+	char	   *password = NULL;
+
+	if (conn->oauth_client_secret)	/* Zero-length secrets are permitted! */
+	{
+		/*----
+		 * Use HTTP Basic auth to send the client_id and secret. Per RFC 6749,
+		 * Sec. 2.3.1,
+		 *
+		 *   Including the client credentials in the request-body using the
+		 *   two parameters is NOT RECOMMENDED and SHOULD be limited to
+		 *   clients unable to directly utilize the HTTP Basic authentication
+		 *   scheme (or other password-based HTTP authentication schemes).
+		 *
+		 * Additionally:
+		 *
+		 *   The client identifier is encoded using the
+		 *   "application/x-www-form-urlencoded" encoding algorithm per Appendix
+		 *   B, and the encoded value is used as the username; the client
+		 *   password is encoded using the same algorithm and used as the
+		 *   password.
+		 *
+		 * (Appendix B modifies application/x-www-form-urlencoded by requiring
+		 * an initial UTF-8 encoding step. Since the client ID and secret must
+		 * both be 7-bit ASCII -- RFC 6749 Appendix A -- we don't worry about
+		 * that in this function.)
+		 *
+		 * client_id is not added to the request body in this case. Not only
+		 * would it be redundant, but some providers in the wild (e.g. Okta)
+		 * refuse to accept it.
+		 */
+		username = urlencode(conn->oauth_client_id);
+		password = urlencode(conn->oauth_client_secret);
+
+		if (!username || !password)
+		{
+			actx_error(actx, "out of memory");
+			goto cleanup;
+		}
+
+		CHECK_SETOPT(actx, CURLOPT_HTTPAUTH, CURLAUTH_BASIC, goto cleanup);
+		CHECK_SETOPT(actx, CURLOPT_USERNAME, username, goto cleanup);
+		CHECK_SETOPT(actx, CURLOPT_PASSWORD, password, goto cleanup);
+
+		actx->used_basic_auth = true;
+	}
+	else
+	{
+		/*
+		 * If we're not otherwise authenticating, client_id is REQUIRED in the
+		 * request body.
+		 */
+		build_urlencoded(reqbody, "client_id", conn->oauth_client_id);
+
+		CHECK_SETOPT(actx, CURLOPT_HTTPAUTH, CURLAUTH_NONE, goto cleanup);
+		actx->used_basic_auth = false;
+	}
+
+	success = true;
+
+cleanup:
+	free(username);
+	free(password);
+
+	return success;
+}
+
+/*
+ * Queue a Device Authorization Request:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc8628#section-3.1
+ *
+ * This is the second step. We ask the provider to verify the end user out of
+ * band and authorize us to act on their behalf; it will give us the required
+ * nonces for us to later poll the request status, which we'll grab in
+ * finish_device_authz().
+ */
+static bool
+start_device_authz(struct async_ctx *actx, PGconn *conn)
+{
+	const char *device_authz_uri = actx->provider.device_authorization_endpoint;
+	PQExpBuffer work_buffer = &actx->work_data;
+
+	Assert(conn->oauth_client_id);	/* ensured by setup_oauth_parameters() */
+	Assert(device_authz_uri);	/* ensured by check_for_device_flow() */
+
+	/* Construct our request body. */
+	resetPQExpBuffer(work_buffer);
+	if (conn->oauth_scope && conn->oauth_scope[0])
+		build_urlencoded(work_buffer, "scope", conn->oauth_scope);
+
+	if (!add_client_identification(actx, work_buffer, conn))
+		return false;
+
+	if (PQExpBufferBroken(work_buffer))
+	{
+		actx_error(actx, "out of memory");
+		return false;
+	}
+
+	/* Make our request. */
+	CHECK_SETOPT(actx, CURLOPT_URL, device_authz_uri, return false);
+	CHECK_SETOPT(actx, CURLOPT_COPYPOSTFIELDS, work_buffer->data, return false);
+
+	return start_request(actx);
+}
+
+static bool
+finish_device_authz(struct async_ctx *actx)
+{
+	long		response_code;
+
+	CHECK_GETINFO(actx, CURLINFO_RESPONSE_CODE, &response_code, return false);
+
+	/*
+	 * Per RFC 8628, Section 3, a successful device authorization response
+	 * uses 200 OK.
+	 */
+	if (response_code == 200)
+	{
+		actx->errctx = "failed to parse device authorization";
+		if (!parse_device_authz(actx, &actx->authz))
+			return false;		/* error message already set */
+
+		return true;
+	}
+
+	/*
+	 * The device authorization endpoint uses the same error response as the
+	 * token endpoint, so the error handling roughly follows
+	 * finish_token_request(). The key difference is that an error here is
+	 * immediately fatal.
+	 */
+	if (response_code == 400 || response_code == 401)
+	{
+		struct token_error err = {0};
+
+		if (!parse_token_error(actx, &err))
+		{
+			free_token_error(&err);
+			return false;
+		}
+
+		/* Copy the token error into the context error buffer */
+		record_token_error(actx, &err);
+
+		free_token_error(&err);
+		return false;
+	}
+
+	/* Any other response codes are considered invalid */
+	actx_error(actx, "unexpected response code %ld", response_code);
+	return false;
+}
+
+/*
+ * Queue an Access Token Request:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc6749#section-4.1.3
+ *
+ * This is the final step. We continually poll the token endpoint to see if the
+ * user has authorized us yet. finish_token_request() will pull either the token
+ * or a (ideally temporary) error status from the provider.
+ */
+static bool
+start_token_request(struct async_ctx *actx, PGconn *conn)
+{
+	const char *token_uri = actx->provider.token_endpoint;
+	const char *device_code = actx->authz.device_code;
+	PQExpBuffer work_buffer = &actx->work_data;
+
+	Assert(conn->oauth_client_id);	/* ensured by setup_oauth_parameters() */
+	Assert(token_uri);			/* ensured by parse_provider() */
+	Assert(device_code);		/* ensured by parse_device_authz() */
+
+	/* Construct our request body. */
+	resetPQExpBuffer(work_buffer);
+	build_urlencoded(work_buffer, "device_code", device_code);
+	build_urlencoded(work_buffer, "grant_type", OAUTH_GRANT_TYPE_DEVICE_CODE);
+
+	if (!add_client_identification(actx, work_buffer, conn))
+		return false;
+
+	if (PQExpBufferBroken(work_buffer))
+	{
+		actx_error(actx, "out of memory");
+		return false;
+	}
+
+	/* Make our request. */
+	CHECK_SETOPT(actx, CURLOPT_URL, token_uri, return false);
+	CHECK_SETOPT(actx, CURLOPT_COPYPOSTFIELDS, work_buffer->data, return false);
+
+	return start_request(actx);
+}
+
+static bool
+finish_token_request(struct async_ctx *actx, struct token *tok)
+{
+	long		response_code;
+
+	CHECK_GETINFO(actx, CURLINFO_RESPONSE_CODE, &response_code, return false);
+
+	/*
+	 * Per RFC 6749, Section 5, a successful response uses 200 OK.
+	 */
+	if (response_code == 200)
+	{
+		actx->errctx = "failed to parse access token response";
+		if (!parse_access_token(actx, tok))
+			return false;		/* error message already set */
+
+		return true;
+	}
+
+	/*
+	 * An error response uses either 400 Bad Request or 401 Unauthorized.
+	 * There are references online to implementations using 403 for error
+	 * return which would violate the specification. For now we stick to the
+	 * specification but we might have to revisit this.
+	 */
+	if (response_code == 400 || response_code == 401)
+	{
+		if (!parse_token_error(actx, &tok->err))
+			return false;
+
+		return true;
+	}
+
+	/* Any other response codes are considered invalid */
+	actx_error(actx, "unexpected response code %ld", response_code);
+	return false;
+}
+
+/*
+ * Finishes the token request and examines the response. If the flow has
+ * completed, a valid token will be returned via the parameter list. Otherwise,
+ * the token parameter remains unchanged, and the caller needs to wait for
+ * another interval (which will have been increased in response to a slow_down
+ * message from the server) before starting a new token request.
+ *
+ * False is returned only for permanent error conditions.
+ */
+static bool
+handle_token_response(struct async_ctx *actx, char **token)
+{
+	bool		success = false;
+	struct token tok = {0};
+	const struct token_error *err;
+
+	if (!finish_token_request(actx, &tok))
+		goto token_cleanup;
+
+	/* A successful token request gives either a token or an in-band error. */
+	Assert(tok.access_token || tok.err.error);
+
+	if (tok.access_token)
+	{
+		*token = tok.access_token;
+		tok.access_token = NULL;
+
+		success = true;
+		goto token_cleanup;
+	}
+
+	/*
+	 * authorization_pending and slow_down are the only acceptable errors;
+	 * anything else and we bail. These are defined in RFC 8628, Sec. 3.5.
+	 */
+	err = &tok.err;
+	if (strcmp(err->error, "authorization_pending") != 0 &&
+		strcmp(err->error, "slow_down") != 0)
+	{
+		record_token_error(actx, err);
+		goto token_cleanup;
+	}
+
+	/*
+	 * A slow_down error requires us to permanently increase our retry
+	 * interval by five seconds.
+	 */
+	if (strcmp(err->error, "slow_down") == 0)
+	{
+		int			prev_interval = actx->authz.interval;
+
+		actx->authz.interval += 5;
+		if (actx->authz.interval < prev_interval)
+		{
+			actx_error(actx, "slow_down interval overflow");
+			goto token_cleanup;
+		}
+	}
+
+	success = true;
+
+token_cleanup:
+	free_token(&tok);
+	return success;
+}
+
+/*
+ * Displays a device authorization prompt for action by the end user, either via
+ * the PQauthDataHook, or by a message on standard error if no hook is set.
+ */
+static bool
+prompt_user(struct async_ctx *actx, PGconn *conn)
+{
+	int			res;
+	PGpromptOAuthDevice prompt = {
+		.verification_uri = actx->authz.verification_uri,
+		.user_code = actx->authz.user_code,
+		.verification_uri_complete = actx->authz.verification_uri_complete,
+		.expires_in = actx->authz.expires_in,
+	};
+
+	res = PQauthDataHook(PQAUTHDATA_PROMPT_OAUTH_DEVICE, conn, &prompt);
+
+	if (!res)
+	{
+		/*
+		 * translator: The first %s is a URL for the user to visit in a
+		 * browser, and the second %s is a code to be copy-pasted there.
+		 */
+		fprintf(stderr, libpq_gettext("Visit %s and enter the code: %s\n"),
+				prompt.verification_uri, prompt.user_code);
+	}
+	else if (res < 0)
+	{
+		actx_error(actx, "device prompt failed");
+		return false;
+	}
+
+	return true;
+}
+
+/*
+ * Calls curl_global_init() in a thread-safe way.
+ *
+ * libcurl has stringent requirements for the thread context in which you call
+ * curl_global_init(), because it's going to try initializing a bunch of other
+ * libraries (OpenSSL, Winsock, etc). Recent versions of libcurl have improved
+ * the thread-safety situation, but there's a chicken-and-egg problem at
+ * runtime: you can't check the thread safety until you've initialized libcurl,
+ * which you can't do from within a thread unless you know it's thread-safe...
+ *
+ * Returns true if initialization was successful. Successful or not, this
+ * function will not try to reinitialize Curl on successive calls.
+ */
+static bool
+initialize_curl(PGconn *conn)
+{
+	/*
+	 * Don't let the compiler play tricks with this variable. In the
+	 * HAVE_THREADSAFE_CURL_GLOBAL_INIT case, we don't care if two threads
+	 * enter simultaneously, but we do care if this gets set transiently to
+	 * PG_BOOL_YES/NO in cases where that's not the final answer.
+	 */
+	static volatile PGTernaryBool init_successful = PG_BOOL_UNKNOWN;
+#if HAVE_THREADSAFE_CURL_GLOBAL_INIT
+	curl_version_info_data *info;
+#endif
+
+#if !HAVE_THREADSAFE_CURL_GLOBAL_INIT
+
+	/*
+	 * Lock around the whole function. If a libpq client performs its own work
+	 * with libcurl, it must either ensure that Curl is initialized safely
+	 * before calling us (in which case our call will be a no-op), or else it
+	 * must guard its own calls to curl_global_init() with a registered
+	 * threadlock handler. See PQregisterThreadLock().
+	 */
+	pglock_thread();
+#endif
+
+	/*
+	 * Skip initialization if we've already done it. (Curl tracks the number
+	 * of calls; there's no point in incrementing the counter every time we
+	 * connect.)
+	 */
+	if (init_successful == PG_BOOL_YES)
+		goto done;
+	else if (init_successful == PG_BOOL_NO)
+	{
+		libpq_append_conn_error(conn,
+								"curl_global_init previously failed during OAuth setup");
+		goto done;
+	}
+
+	/*
+	 * We know we've already initialized Winsock by this point (see
+	 * pqMakeEmptyPGconn()), so we should be able to safely skip that bit. But
+	 * we have to tell libcurl to initialize everything else, because other
+	 * pieces of our client executable may already be using libcurl for their
+	 * own purposes. If we initialize libcurl with only a subset of its
+	 * features, we could break those other clients nondeterministically, and
+	 * that would probably be a nightmare to debug.
+	 *
+	 * If some other part of the program has already called this, it's a
+	 * no-op.
+	 */
+	if (curl_global_init(CURL_GLOBAL_ALL & ~CURL_GLOBAL_WIN32) != CURLE_OK)
+	{
+		libpq_append_conn_error(conn,
+								"curl_global_init failed during OAuth setup");
+		init_successful = PG_BOOL_NO;
+		goto done;
+	}
+
+#if HAVE_THREADSAFE_CURL_GLOBAL_INIT
+
+	/*
+	 * If we determined at configure time that the Curl installation is
+	 * thread-safe, our job here is much easier. We simply initialize above
+	 * without any locking (concurrent or duplicated calls are fine in that
+	 * situation), then double-check to make sure the runtime setting agrees,
+	 * to try to catch silent downgrades.
+	 */
+	info = curl_version_info(CURLVERSION_NOW);
+	if (!(info->features & CURL_VERSION_THREADSAFE))
+	{
+		/*
+		 * 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.");
+		init_successful = PG_BOOL_NO;
+		goto done;
+	}
+#endif
+
+	init_successful = PG_BOOL_YES;
+
+done:
+#if !HAVE_THREADSAFE_CURL_GLOBAL_INIT
+	pgunlock_thread();
+#endif
+	return (init_successful == PG_BOOL_YES);
+}
+
+/*
+ * The core nonblocking libcurl implementation. This will be called several
+ * times to pump the async engine.
+ *
+ * The architecture is based on PQconnectPoll(). The first half drives the
+ * connection state forward as necessary, returning if we're not ready to
+ * proceed to the next step yet. The second half performs the actual transition
+ * between states.
+ *
+ * You can trace the overall OAuth flow through the second half. It's linear
+ * until we get to the end, where we flip back and forth between
+ * OAUTH_STEP_TOKEN_REQUEST and OAUTH_STEP_WAIT_INTERVAL to regularly ping the
+ * provider.
+ */
+static PostgresPollingStatusType
+pg_fe_run_oauth_flow_impl(PGconn *conn)
+{
+	fe_oauth_state *state = conn->sasl_state;
+	struct async_ctx *actx;
+
+	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. */
+		conn->altsock = actx->mux;
+
+		switch (actx->step)
+		{
+			case OAUTH_STEP_INIT:
+				break;
+
+			case OAUTH_STEP_DISCOVERY:
+			case OAUTH_STEP_DEVICE_AUTHORIZATION:
+			case OAUTH_STEP_TOKEN_REQUEST:
+				{
+					PostgresPollingStatusType status;
+
+					status = drive_request(actx);
+
+					if (status == PGRES_POLLING_FAILED)
+						goto error_return;
+					else if (status != PGRES_POLLING_OK)
+					{
+						/* not done yet */
+						return status;
+					}
+
+					break;
+				}
+
+			case OAUTH_STEP_WAIT_INTERVAL:
+
+				/*
+				 * The client application is supposed to wait until our timer
+				 * expires before calling PQconnectPoll() again, but that
+				 * might not happen. To avoid sending a token request early,
+				 * check the timer before continuing.
+				 */
+				if (!timer_expired(actx))
+				{
+					conn->altsock = actx->timerfd;
+					return PGRES_POLLING_READING;
+				}
+
+				/* Disable the expired timer. */
+				if (!set_timer(actx, -1))
+					goto error_return;
+
+				break;
+		}
+
+		/*
+		 * Each case here must ensure that actx->running is set while we're
+		 * waiting on some asynchronous work. Most cases rely on
+		 * start_request() to do that for them.
+		 */
+		switch (actx->step)
+		{
+			case OAUTH_STEP_INIT:
+				actx->errctx = "failed to fetch OpenID discovery document";
+				if (!start_discovery(actx, conn->oauth_discovery_uri))
+					goto error_return;
+
+				actx->step = OAUTH_STEP_DISCOVERY;
+				break;
+
+			case OAUTH_STEP_DISCOVERY:
+				if (!finish_discovery(actx))
+					goto error_return;
+
+				if (!check_issuer(actx, conn))
+					goto error_return;
+
+				actx->errctx = "cannot run OAuth device authorization";
+				if (!check_for_device_flow(actx))
+					goto error_return;
+
+				actx->errctx = "failed to obtain device authorization";
+				if (!start_device_authz(actx, conn))
+					goto error_return;
+
+				actx->step = OAUTH_STEP_DEVICE_AUTHORIZATION;
+				break;
+
+			case OAUTH_STEP_DEVICE_AUTHORIZATION:
+				if (!finish_device_authz(actx))
+					goto error_return;
+
+				actx->errctx = "failed to obtain access token";
+				if (!start_token_request(actx, conn))
+					goto error_return;
+
+				actx->step = OAUTH_STEP_TOKEN_REQUEST;
+				break;
+
+			case OAUTH_STEP_TOKEN_REQUEST:
+				if (!handle_token_response(actx, &conn->oauth_token))
+					goto error_return;
+
+				if (!actx->user_prompted)
+				{
+					/*
+					 * Now that we know the token endpoint isn't broken, give
+					 * the user the login instructions.
+					 */
+					if (!prompt_user(actx, conn))
+						goto error_return;
+
+					actx->user_prompted = true;
+				}
+
+				if (conn->oauth_token)
+					break;		/* done! */
+
+				/*
+				 * Wait for the required interval before issuing the next
+				 * request.
+				 */
+				if (!set_timer(actx, actx->authz.interval * 1000))
+					goto error_return;
+
+				/*
+				 * No Curl requests are running, so we can simplify by having
+				 * the client wait directly on the timerfd rather than the
+				 * multiplexer.
+				 */
+				conn->altsock = actx->timerfd;
+
+				actx->step = OAUTH_STEP_WAIT_INTERVAL;
+				actx->running = 1;
+				break;
+
+			case OAUTH_STEP_WAIT_INTERVAL:
+				actx->errctx = "failed to obtain access token";
+				if (!start_token_request(actx, conn))
+					goto error_return;
+
+				actx->step = OAUTH_STEP_TOKEN_REQUEST;
+				break;
+		}
+
+		/*
+		 * The vast majority of the time, if we don't have a token at this
+		 * point, actx->running will be set. But there are some corner cases
+		 * where we can immediately loop back around; see start_request().
+		 */
+	} while (!conn->oauth_token && !actx->running);
+
+	/* If we've stored a token, we're done. Otherwise come back later. */
+	return conn->oauth_token ? PGRES_POLLING_OK : PGRES_POLLING_READING;
+
+error_return:
+
+	/*
+	 * Assemble the three parts of our error: context, body, and detail. See
+	 * also the documentation for struct async_ctx.
+	 */
+	if (actx->errctx)
+	{
+		appendPQExpBufferStr(&conn->errorMessage,
+							 libpq_gettext(actx->errctx));
+		appendPQExpBufferStr(&conn->errorMessage, ": ");
+	}
+
+	if (PQExpBufferDataBroken(actx->errbuf))
+		appendPQExpBufferStr(&conn->errorMessage,
+							 libpq_gettext("out of memory"));
+	else
+		appendPQExpBufferStr(&conn->errorMessage, actx->errbuf.data);
+
+	if (actx->curl_err[0])
+	{
+		size_t		len;
+
+		appendPQExpBuffer(&conn->errorMessage, " (%s)", actx->curl_err);
+
+		/* Sometimes libcurl adds a newline to the error buffer. :( */
+		len = conn->errorMessage.len;
+		if (len >= 2 && conn->errorMessage.data[len - 2] == '\n')
+		{
+			conn->errorMessage.data[len - 2] = ')';
+			conn->errorMessage.data[len - 1] = '\0';
+			conn->errorMessage.len--;
+		}
+	}
+
+	appendPQExpBufferStr(&conn->errorMessage, "\n");
+
+	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.
+ */
+PostgresPollingStatusType
+pg_fe_run_oauth_flow(PGconn *conn)
+{
+	PostgresPollingStatusType result;
+#ifndef WIN32
+	sigset_t	osigset;
+	bool		sigpipe_pending;
+	bool		masked;
+
+	/*---
+	 * Ignore SIGPIPE on this thread during all Curl processing.
+	 *
+	 * Because we support multiple threads, we have to set up libcurl with
+	 * CURLOPT_NOSIGNAL, which disables its default global handling of
+	 * SIGPIPE. From the Curl docs:
+	 *
+	 *     libcurl makes an effort to never cause such SIGPIPE signals to
+	 *     trigger, but some operating systems have no way to avoid them and
+	 *     even on those that have there are some corner cases when they may
+	 *     still happen, contrary to our desire.
+	 *
+	 * Note that libcurl is also at the mercy of its DNS resolution and SSL
+	 * libraries; if any of them forget a MSG_NOSIGNAL then we're in trouble.
+	 * Modern platforms and libraries seem to get it right, so this is a
+	 * difficult corner case to exercise in practice, and unfortunately it's
+	 * not really clear whether it's necessary in all cases.
+	 */
+	masked = (pq_block_sigpipe(&osigset, &sigpipe_pending) == 0);
+#endif
+
+	result = pg_fe_run_oauth_flow_impl(conn);
+
+#ifndef WIN32
+	if (masked)
+	{
+		/*
+		 * Undo the SIGPIPE mask. Assume we may have gotten EPIPE (we have no
+		 * way of knowing at this level).
+		 */
+		pq_reset_sigpipe(&osigset, sigpipe_pending, true /* EPIPE, maybe */ );
+	}
+#endif
+
+	return result;
+}
diff --git a/src/interfaces/libpq/fe-auth-oauth.c b/src/interfaces/libpq/fe-auth-oauth.c
new file mode 100644
index 00000000000..24448c3e209
--- /dev/null
+++ b/src/interfaces/libpq/fe-auth-oauth.c
@@ -0,0 +1,1153 @@
+/*-------------------------------------------------------------------------
+ *
+ * fe-auth-oauth.c
+ *	   The front-end (client) implementation of OAuth/OIDC authentication
+ *	   using the SASL OAUTHBEARER mechanism.
+ *
+ * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/interfaces/libpq/fe-auth-oauth.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres_fe.h"
+
+#include "common/base64.h"
+#include "common/hmac.h"
+#include "common/jsonapi.h"
+#include "common/oauth-common.h"
+#include "fe-auth.h"
+#include "fe-auth-oauth.h"
+#include "mb/pg_wchar.h"
+
+/* The exported OAuth callback mechanism. */
+static void *oauth_init(PGconn *conn, const char *password,
+						const char *sasl_mechanism);
+static SASLStatus oauth_exchange(void *opaq, bool final,
+								 char *input, int inputlen,
+								 char **output, int *outputlen);
+static bool oauth_channel_bound(void *opaq);
+static void oauth_free(void *opaq);
+
+const pg_fe_sasl_mech pg_oauth_mech = {
+	oauth_init,
+	oauth_exchange,
+	oauth_channel_bound,
+	oauth_free,
+};
+
+/*
+ * Initializes mechanism state for OAUTHBEARER.
+ *
+ * For a full description of the API, see libpq/fe-auth-sasl.h.
+ */
+static void *
+oauth_init(PGconn *conn, const char *password,
+		   const char *sasl_mechanism)
+{
+	fe_oauth_state *state;
+
+	/*
+	 * We only support one SASL mechanism here; anything else is programmer
+	 * error.
+	 */
+	Assert(sasl_mechanism != NULL);
+	Assert(strcmp(sasl_mechanism, OAUTHBEARER_NAME) == 0);
+
+	state = calloc(1, sizeof(*state));
+	if (!state)
+		return NULL;
+
+	state->step = FE_OAUTH_INIT;
+	state->conn = conn;
+
+	return state;
+}
+
+/*
+ * Frees the state allocated by oauth_init().
+ *
+ * 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().
+ */
+static void
+oauth_free(void *opaq)
+{
+	fe_oauth_state *state = opaq;
+
+	/* Any async authentication state should have been cleaned up already. */
+	Assert(!state->async_ctx);
+
+	free(state);
+}
+
+#define kvsep "\x01"
+
+/*
+ * Constructs an OAUTHBEARER client initial response (RFC 7628, Sec. 3.1).
+ *
+ * If discover is true, the initial response will contain a request for the
+ * server's required OAuth parameters (Sec. 4.3). Otherwise, conn->token must
+ * be set; it will be sent as the connection's bearer token.
+ *
+ * Returns the response as a null-terminated string, or NULL on error.
+ */
+static char *
+client_initial_response(PGconn *conn, bool discover)
+{
+	static const char *const resp_format = "n,," kvsep "auth=%s%s" kvsep kvsep;
+
+	PQExpBufferData buf;
+	const char *authn_scheme;
+	char	   *response = NULL;
+	const char *token = conn->oauth_token;
+
+	if (discover)
+	{
+		/* Parameter discovery uses a completely empty auth value. */
+		authn_scheme = token = "";
+	}
+	else
+	{
+		/*
+		 * Use a Bearer authentication scheme (RFC 6750, Sec. 2.1). A trailing
+		 * space is used as a separator.
+		 */
+		authn_scheme = "Bearer ";
+
+		/* conn->token must have been set in this case. */
+		if (!token)
+		{
+			Assert(false);
+			libpq_append_conn_error(conn,
+									"internal error: no OAuth token was set for the connection");
+			return NULL;
+		}
+	}
+
+	initPQExpBuffer(&buf);
+	appendPQExpBuffer(&buf, resp_format, authn_scheme, token);
+
+	if (!PQExpBufferDataBroken(buf))
+		response = strdup(buf.data);
+	termPQExpBuffer(&buf);
+
+	if (!response)
+		libpq_append_conn_error(conn, "out of memory");
+
+	return response;
+}
+
+/*
+ * JSON Parser (for the OAUTHBEARER error result)
+ */
+
+/* Relevant JSON fields in the error result object. */
+#define ERROR_STATUS_FIELD "status"
+#define ERROR_SCOPE_FIELD "scope"
+#define ERROR_OPENID_CONFIGURATION_FIELD "openid-configuration"
+
+struct json_ctx
+{
+	char	   *errmsg;			/* any non-NULL value stops all processing */
+	PQExpBufferData errbuf;		/* backing memory for errmsg */
+	int			nested;			/* nesting level (zero is the top) */
+
+	const char *target_field_name;	/* points to a static allocation */
+	char	  **target_field;	/* see below */
+
+	/* target_field, if set, points to one of the following: */
+	char	   *status;
+	char	   *scope;
+	char	   *discovery_uri;
+};
+
+#define oauth_json_has_error(ctx) \
+	(PQExpBufferDataBroken((ctx)->errbuf) || (ctx)->errmsg)
+
+#define oauth_json_set_error(ctx, ...) \
+	do { \
+		appendPQExpBuffer(&(ctx)->errbuf, __VA_ARGS__); \
+		(ctx)->errmsg = (ctx)->errbuf.data; \
+	} while (0)
+
+static JsonParseErrorType
+oauth_json_object_start(void *state)
+{
+	struct json_ctx *ctx = state;
+
+	if (ctx->target_field)
+	{
+		Assert(ctx->nested == 1);
+
+		oauth_json_set_error(ctx,
+							 libpq_gettext("field \"%s\" must be a string"),
+							 ctx->target_field_name);
+	}
+
+	++ctx->nested;
+	return oauth_json_has_error(ctx) ? JSON_SEM_ACTION_FAILED : JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_object_end(void *state)
+{
+	struct json_ctx *ctx = state;
+
+	--ctx->nested;
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_object_field_start(void *state, char *name, bool isnull)
+{
+	struct json_ctx *ctx = state;
+
+	/* Only top-level keys are considered. */
+	if (ctx->nested == 1)
+	{
+		if (strcmp(name, ERROR_STATUS_FIELD) == 0)
+		{
+			ctx->target_field_name = ERROR_STATUS_FIELD;
+			ctx->target_field = &ctx->status;
+		}
+		else if (strcmp(name, ERROR_SCOPE_FIELD) == 0)
+		{
+			ctx->target_field_name = ERROR_SCOPE_FIELD;
+			ctx->target_field = &ctx->scope;
+		}
+		else if (strcmp(name, ERROR_OPENID_CONFIGURATION_FIELD) == 0)
+		{
+			ctx->target_field_name = ERROR_OPENID_CONFIGURATION_FIELD;
+			ctx->target_field = &ctx->discovery_uri;
+		}
+	}
+
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_array_start(void *state)
+{
+	struct json_ctx *ctx = state;
+
+	if (!ctx->nested)
+	{
+		ctx->errmsg = libpq_gettext("top-level element must be an object");
+	}
+	else if (ctx->target_field)
+	{
+		Assert(ctx->nested == 1);
+
+		oauth_json_set_error(ctx,
+							 libpq_gettext("field \"%s\" must be a string"),
+							 ctx->target_field_name);
+	}
+
+	return oauth_json_has_error(ctx) ? JSON_SEM_ACTION_FAILED : JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_scalar(void *state, char *token, JsonTokenType type)
+{
+	struct json_ctx *ctx = state;
+
+	if (!ctx->nested)
+	{
+		ctx->errmsg = libpq_gettext("top-level element must be an object");
+		return JSON_SEM_ACTION_FAILED;
+	}
+
+	if (ctx->target_field)
+	{
+		if (ctx->nested != 1)
+		{
+			/*
+			 * ctx->target_field should not have been set for nested keys.
+			 * Assert and don't continue any further for production builds.
+			 */
+			Assert(false);
+			oauth_json_set_error(ctx,
+								 "internal error: target scalar found at nesting level %d during OAUTHBEARER parsing",
+								 ctx->nested);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		/*
+		 * We don't allow duplicate field names; error out if the target has
+		 * already been set.
+		 */
+		if (*ctx->target_field)
+		{
+			oauth_json_set_error(ctx,
+								 libpq_gettext("field \"%s\" is duplicated"),
+								 ctx->target_field_name);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		/* The only fields we support are strings. */
+		if (type != JSON_TOKEN_STRING)
+		{
+			oauth_json_set_error(ctx,
+								 libpq_gettext("field \"%s\" must be a string"),
+								 ctx->target_field_name);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		*ctx->target_field = strdup(token);
+		if (!*ctx->target_field)
+			return JSON_OUT_OF_MEMORY;
+
+		ctx->target_field = NULL;
+		ctx->target_field_name = NULL;
+	}
+	else
+	{
+		/* otherwise we just ignore it */
+	}
+
+	return JSON_SUCCESS;
+}
+
+#define HTTPS_SCHEME "https://"
+#define HTTP_SCHEME "http://"
+
+/* We support both well-known suffixes defined by RFC 8414. */
+#define WK_PREFIX "/.well-known/"
+#define OPENID_WK_SUFFIX "openid-configuration"
+#define OAUTH_WK_SUFFIX "oauth-authorization-server"
+
+/*
+ * Derives an issuer identifier from one of our recognized .well-known URIs,
+ * using the rules in RFC 8414.
+ */
+static char *
+issuer_from_well_known_uri(PGconn *conn, const char *wkuri)
+{
+	const char *authority_start = NULL;
+	const char *wk_start;
+	const char *wk_end;
+	char	   *issuer;
+	ptrdiff_t	start_offset,
+				end_offset;
+	size_t		end_len;
+
+	/*
+	 * https:// is required for issuer identifiers (RFC 8414, Sec. 2; OIDC
+	 * Discovery 1.0, Sec. 3). This is a case-insensitive comparison at this
+	 * level (but issuer identifier comparison at the level above this is
+	 * case-sensitive, so in practice it's probably moot).
+	 */
+	if (pg_strncasecmp(wkuri, HTTPS_SCHEME, strlen(HTTPS_SCHEME)) == 0)
+		authority_start = wkuri + strlen(HTTPS_SCHEME);
+
+	if (!authority_start
+		&& oauth_unsafe_debugging_enabled()
+		&& pg_strncasecmp(wkuri, HTTP_SCHEME, strlen(HTTP_SCHEME)) == 0)
+	{
+		/* Allow http:// for testing only. */
+		authority_start = wkuri + strlen(HTTP_SCHEME);
+	}
+
+	if (!authority_start)
+	{
+		libpq_append_conn_error(conn,
+								"OAuth discovery URI \"%s\" must use HTTPS",
+								wkuri);
+		return NULL;
+	}
+
+	/*
+	 * Well-known URIs in general may support queries and fragments, but the
+	 * two types we support here do not. (They must be constructed from the
+	 * components of issuer identifiers, which themselves may not contain any
+	 * queries or fragments.)
+	 *
+	 * It's important to check this first, to avoid getting tricked later by a
+	 * prefix buried inside a query or fragment.
+	 */
+	if (strpbrk(authority_start, "?#") != NULL)
+	{
+		libpq_append_conn_error(conn,
+								"OAuth discovery URI \"%s\" must not contain query or fragment components",
+								wkuri);
+		return NULL;
+	}
+
+	/*
+	 * Find the start of the .well-known prefix. IETF rules (RFC 8615) state
+	 * this must be at the beginning of the path component, but OIDC defined
+	 * it at the end instead (OIDC Discovery 1.0, Sec. 4), so we have to
+	 * search for it anywhere.
+	 */
+	wk_start = strstr(authority_start, WK_PREFIX);
+	if (!wk_start)
+	{
+		libpq_append_conn_error(conn,
+								"OAuth discovery URI \"%s\" is not a .well-known URI",
+								wkuri);
+		return NULL;
+	}
+
+	/*
+	 * Now find the suffix type. We only support the two defined in OIDC
+	 * Discovery 1.0 and RFC 8414.
+	 */
+	wk_end = wk_start + strlen(WK_PREFIX);
+
+	if (strncmp(wk_end, OPENID_WK_SUFFIX, strlen(OPENID_WK_SUFFIX)) == 0)
+		wk_end += strlen(OPENID_WK_SUFFIX);
+	else if (strncmp(wk_end, OAUTH_WK_SUFFIX, strlen(OAUTH_WK_SUFFIX)) == 0)
+		wk_end += strlen(OAUTH_WK_SUFFIX);
+	else
+		wk_end = NULL;
+
+	/*
+	 * Even if there's a match, we still need to check to make sure the suffix
+	 * takes up the entire path segment, to weed out constructions like
+	 * "/.well-known/openid-configuration-bad".
+	 */
+	if (!wk_end || (*wk_end != '/' && *wk_end != '\0'))
+	{
+		libpq_append_conn_error(conn,
+								"OAuth discovery URI \"%s\" uses an unsupported .well-known suffix",
+								wkuri);
+		return NULL;
+	}
+
+	/*
+	 * Finally, make sure the .well-known components are provided either as a
+	 * prefix (IETF style) or as a postfix (OIDC style). In other words,
+	 * "https://localhost/a/.well-known/openid-configuration/b" is not allowed
+	 * to claim association with "https://localhost/a/b".
+	 */
+	if (*wk_end != '\0')
+	{
+		/*
+		 * It's not at the end, so it's required to be at the beginning at the
+		 * path. Find the starting slash.
+		 */
+		const char *path_start;
+
+		path_start = strchr(authority_start, '/');
+		Assert(path_start);		/* otherwise we wouldn't have found WK_PREFIX */
+
+		if (wk_start != path_start)
+		{
+			libpq_append_conn_error(conn,
+									"OAuth discovery URI \"%s\" uses an invalid format",
+									wkuri);
+			return NULL;
+		}
+	}
+
+	/* Checks passed! Now build the issuer. */
+	issuer = strdup(wkuri);
+	if (!issuer)
+	{
+		libpq_append_conn_error(conn, "out of memory");
+		return NULL;
+	}
+
+	/*
+	 * The .well-known components are from [wk_start, wk_end). Remove those to
+	 * form the issuer ID, by shifting the path suffix (which may be empty)
+	 * leftwards.
+	 */
+	start_offset = wk_start - wkuri;
+	end_offset = wk_end - wkuri;
+	end_len = strlen(wk_end) + 1;	/* move the NULL terminator too */
+
+	memmove(issuer + start_offset, issuer + end_offset, end_len);
+
+	return issuer;
+}
+
+/*
+ * Parses the server error result (RFC 7628, Sec. 3.2.2) contained in msg and
+ * stores any discovered openid_configuration and scope settings for the
+ * connection.
+ */
+static bool
+handle_oauth_sasl_error(PGconn *conn, const char *msg, int msglen)
+{
+	JsonLexContext lex = {0};
+	JsonSemAction sem = {0};
+	JsonParseErrorType err;
+	struct json_ctx ctx = {0};
+	char	   *errmsg = NULL;
+	bool		success = false;
+
+	Assert(conn->oauth_issuer_id);	/* ensured by setup_oauth_parameters() */
+
+	/* Sanity check. */
+	if (strlen(msg) != msglen)
+	{
+		libpq_append_conn_error(conn,
+								"server's error message contained an embedded NULL, and was discarded");
+		return false;
+	}
+
+	/*
+	 * pg_parse_json doesn't validate the incoming UTF-8, so we have to check
+	 * that up front.
+	 */
+	if (pg_encoding_verifymbstr(PG_UTF8, msg, msglen) != msglen)
+	{
+		libpq_append_conn_error(conn,
+								"server's error response is not valid UTF-8");
+		return false;
+	}
+
+	makeJsonLexContextCstringLen(&lex, msg, msglen, PG_UTF8, true);
+	setJsonLexContextOwnsTokens(&lex, true);	/* must not leak on error */
+
+	initPQExpBuffer(&ctx.errbuf);
+	sem.semstate = &ctx;
+
+	sem.object_start = oauth_json_object_start;
+	sem.object_end = oauth_json_object_end;
+	sem.object_field_start = oauth_json_object_field_start;
+	sem.array_start = oauth_json_array_start;
+	sem.scalar = oauth_json_scalar;
+
+	err = pg_parse_json(&lex, &sem);
+
+	if (err == JSON_SEM_ACTION_FAILED)
+	{
+		if (PQExpBufferDataBroken(ctx.errbuf))
+			errmsg = libpq_gettext("out of memory");
+		else if (ctx.errmsg)
+			errmsg = ctx.errmsg;
+		else
+		{
+			/*
+			 * Developer error: one of the action callbacks didn't call
+			 * oauth_json_set_error() before erroring out.
+			 */
+			Assert(oauth_json_has_error(&ctx));
+			errmsg = "<unexpected empty error>";
+		}
+	}
+	else if (err != JSON_SUCCESS)
+		errmsg = json_errdetail(err, &lex);
+
+	if (errmsg)
+		libpq_append_conn_error(conn,
+								"failed to parse server's error response: %s",
+								errmsg);
+
+	/* Don't need the error buffer or the JSON lexer anymore. */
+	termPQExpBuffer(&ctx.errbuf);
+	freeJsonLexContext(&lex);
+
+	if (errmsg)
+		goto cleanup;
+
+	if (ctx.discovery_uri)
+	{
+		char	   *discovery_issuer;
+
+		/*
+		 * The URI MUST correspond to our existing issuer, to avoid mix-ups.
+		 *
+		 * Issuer comparison is done byte-wise, rather than performing any URL
+		 * normalization; this follows the suggestions for issuer comparison
+		 * in RFC 9207 Sec. 2.4 (which requires simple string comparison) and
+		 * vastly simplifies things. Since this is the key protection against
+		 * a rogue server sending the client to an untrustworthy location,
+		 * simpler is better.
+		 */
+		discovery_issuer = issuer_from_well_known_uri(conn, ctx.discovery_uri);
+		if (!discovery_issuer)
+			goto cleanup;		/* error message already set */
+
+		if (strcmp(conn->oauth_issuer_id, discovery_issuer) != 0)
+		{
+			libpq_append_conn_error(conn,
+									"server's discovery document at %s (issuer \"%s\") is incompatible with oauth_issuer (%s)",
+									ctx.discovery_uri, discovery_issuer,
+									conn->oauth_issuer_id);
+
+			free(discovery_issuer);
+			goto cleanup;
+		}
+
+		free(discovery_issuer);
+
+		if (!conn->oauth_discovery_uri)
+		{
+			conn->oauth_discovery_uri = ctx.discovery_uri;
+			ctx.discovery_uri = NULL;
+		}
+		else
+		{
+			/* This must match the URI we'd previously determined. */
+			if (strcmp(conn->oauth_discovery_uri, ctx.discovery_uri) != 0)
+			{
+				libpq_append_conn_error(conn,
+										"server's discovery document has moved to %s (previous location was %s)",
+										ctx.discovery_uri,
+										conn->oauth_discovery_uri);
+				goto cleanup;
+			}
+		}
+	}
+
+	if (ctx.scope)
+	{
+		/* Servers may not override a previously set oauth_scope. */
+		if (!conn->oauth_scope)
+		{
+			conn->oauth_scope = ctx.scope;
+			ctx.scope = NULL;
+		}
+	}
+
+	if (!ctx.status)
+	{
+		libpq_append_conn_error(conn,
+								"server sent error response without a status");
+		goto cleanup;
+	}
+
+	if (strcmp(ctx.status, "invalid_token") != 0)
+	{
+		/*
+		 * invalid_token is the only error code we'll automatically retry for;
+		 * otherwise, just bail out now.
+		 */
+		libpq_append_conn_error(conn,
+								"server rejected OAuth bearer token: %s",
+								ctx.status);
+		goto cleanup;
+	}
+
+	success = true;
+
+cleanup:
+	free(ctx.status);
+	free(ctx.scope);
+	free(ctx.discovery_uri);
+
+	return success;
+}
+
+/*
+ * Callback implementation of conn->async_auth() for a user-defined OAuth flow.
+ * Delegates the retrieval of the token to the application's 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_*
+ * statuses for use by PQconnectPoll().
+ */
+static PostgresPollingStatusType
+run_user_oauth_flow(PGconn *conn)
+{
+	fe_oauth_state *state = conn->sasl_state;
+	PGoauthBearerRequest *request = state->async_ctx;
+	PostgresPollingStatusType status;
+
+	if (!request->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);
+	if (status == PGRES_POLLING_FAILED)
+	{
+		libpq_append_conn_error(conn, "user-defined OAuth flow failed");
+		return status;
+	}
+	else if (status == PGRES_POLLING_OK)
+	{
+		/*
+		 * 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.)
+		 */
+		if (!request->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);
+		if (!conn->oauth_token)
+		{
+			libpq_append_conn_error(conn, "out of memory");
+			return PGRES_POLLING_FAILED;
+		}
+
+		return PGRES_POLLING_OK;
+	}
+
+	/* The hook wants the client to poll the altsock. Make sure it set one. */
+	if (conn->altsock == PGINVALID_SOCKET)
+	{
+		libpq_append_conn_error(conn,
+								"user-defined OAuth flow did not provide a socket for polling");
+		return PGRES_POLLING_FAILED;
+	}
+
+	return status;
+}
+
+/*
+ * Cleanup callback for the async user flow. Delegates most of its job to the
+ * user-provided cleanup implementation, then disconnects the altsock.
+ */
+static void
+cleanup_user_oauth_flow(PGconn *conn)
+{
+	fe_oauth_state *state = conn->sasl_state;
+	PGoauthBearerRequest *request = state->async_ctx;
+
+	Assert(request);
+
+	if (request->cleanup)
+		request->cleanup(conn, request);
+	conn->altsock = PGINVALID_SOCKET;
+
+	free(request);
+	state->async_ctx = NULL;
+}
+
+/*
+ * Chooses an OAuth client flow for the connection, which will retrieve a Bearer
+ * 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
+ * asynchronous callbacks which will be managed by run_user_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.)
+ *
+ * If neither a custom handler nor the builtin flow is available, the connection
+ * fails here.
+ */
+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,
+	};
+
+	Assert(request.openid_configuration);
+
+	/* The client may have overridden the OAuth flow. */
+	res = PQauthDataHook(PQAUTHDATA_OAUTH_BEARER_TOKEN, conn, &request);
+	if (res > 0)
+	{
+		PGoauthBearerRequest *request_copy;
+
+		if (request.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);
+			if (!conn->oauth_token)
+			{
+				libpq_append_conn_error(conn, "out of memory");
+				goto fail;
+			}
+
+			/* short-circuit */
+			if (request.cleanup)
+				request.cleanup(conn, &request);
+			return true;
+		}
+
+		request_copy = malloc(sizeof(*request_copy));
+		if (!request_copy)
+		{
+			libpq_append_conn_error(conn, "out of memory");
+			goto fail;
+		}
+
+		memcpy(request_copy, &request, sizeof(request));
+
+		conn->async_auth = run_user_oauth_flow;
+		conn->cleanup_async_auth = cleanup_user_oauth_flow;
+		state->async_ctx = request_copy;
+	}
+	else if (res < 0)
+	{
+		libpq_append_conn_error(conn, "user-defined OAuth flow failed");
+		goto fail;
+	}
+	else
+	{
+#if USE_LIBCURL
+		/* Hand off to our built-in OAuth flow. */
+		conn->async_auth = pg_fe_run_oauth_flow;
+		conn->cleanup_async_auth = pg_fe_cleanup_oauth_flow;
+
+#else
+		libpq_append_conn_error(conn, "no custom OAuth flows are available, and libpq was not built with libcurl support");
+		goto fail;
+
+#endif
+	}
+
+	return true;
+
+fail:
+	if (request.cleanup)
+		request.cleanup(conn, &request);
+	return false;
+}
+
+/*
+ * Fill in our issuer identifier and discovery URI, if possible, using the
+ * connection parameters. If conn->oauth_discovery_uri can't be populated in
+ * this function, it will be requested from the server.
+ */
+static bool
+setup_oauth_parameters(PGconn *conn)
+{
+	/*---
+	 * To talk to a server, we require the user to provide issuer and client
+	 * identifiers.
+	 *
+	 * While it's possible for an OAuth client to support multiple issuers, it
+	 * requires additional effort to make sure the flows in use are safe -- to
+	 * quote RFC 9207,
+	 *
+	 *     OAuth clients that interact with only one authorization server are
+	 *     not vulnerable to mix-up attacks. However, when such clients decide
+	 *     to add support for a second authorization server in the future, they
+	 *     become vulnerable and need to apply countermeasures to mix-up
+	 *     attacks.
+	 *
+	 * For now, we allow only one.
+	 */
+	if (!conn->oauth_issuer || !conn->oauth_client_id)
+	{
+		libpq_append_conn_error(conn,
+								"server requires OAuth authentication, but oauth_issuer and oauth_client_id are not both set");
+		return false;
+	}
+
+	/*
+	 * oauth_issuer is interpreted differently if it's a well-known discovery
+	 * URI rather than just an issuer identifier.
+	 */
+	if (strstr(conn->oauth_issuer, WK_PREFIX) != NULL)
+	{
+		/*
+		 * Convert the URI back to an issuer identifier. (This also performs
+		 * validation of the URI format.)
+		 */
+		conn->oauth_issuer_id = issuer_from_well_known_uri(conn,
+														   conn->oauth_issuer);
+		if (!conn->oauth_issuer_id)
+			return false;		/* error message already set */
+
+		conn->oauth_discovery_uri = strdup(conn->oauth_issuer);
+		if (!conn->oauth_discovery_uri)
+		{
+			libpq_append_conn_error(conn, "out of memory");
+			return false;
+		}
+	}
+	else
+	{
+		/*
+		 * Treat oauth_issuer as an issuer identifier. We'll ask the server
+		 * for the discovery URI.
+		 */
+		conn->oauth_issuer_id = strdup(conn->oauth_issuer);
+		if (!conn->oauth_issuer_id)
+		{
+			libpq_append_conn_error(conn, "out of memory");
+			return false;
+		}
+	}
+
+	return true;
+}
+
+/*
+ * Implements the OAUTHBEARER SASL exchange (RFC 7628, Sec. 3.2).
+ *
+ * If the necessary OAuth parameters are set up on the connection, this will run
+ * the client flow asynchronously and present the resulting token to the server.
+ * Otherwise, an empty discovery response will be sent and any parameters sent
+ * back by the server will be stored for a second attempt.
+ *
+ * For a full description of the API, see libpq/sasl.h.
+ */
+static SASLStatus
+oauth_exchange(void *opaq, bool final,
+			   char *input, int inputlen,
+			   char **output, int *outputlen)
+{
+	fe_oauth_state *state = opaq;
+	PGconn	   *conn = state->conn;
+	bool		discover = false;
+
+	*output = NULL;
+	*outputlen = 0;
+
+	switch (state->step)
+	{
+		case FE_OAUTH_INIT:
+			/* We begin in the initial response phase. */
+			Assert(inputlen == -1);
+
+			if (!setup_oauth_parameters(conn))
+				return SASL_FAILED;
+
+			if (conn->oauth_token)
+			{
+				/*
+				 * A previous connection already fetched the token; we'll use
+				 * it below.
+				 */
+			}
+			else if (conn->oauth_discovery_uri)
+			{
+				/*
+				 * We don't have a token, but we have a discovery URI already
+				 * stored. Decide whether we're using a user-provided OAuth
+				 * flow or the one we have built in.
+				 */
+				if (!setup_token_request(conn, state))
+					return SASL_FAILED;
+
+				if (conn->oauth_token)
+				{
+					/*
+					 * A really smart user implementation may have already
+					 * given us the token (e.g. if there was an unexpired copy
+					 * already cached), and we can use it immediately.
+					 */
+				}
+				else
+				{
+					/*
+					 * Otherwise, we'll have to hand the connection over to
+					 * our OAuth implementation.
+					 *
+					 * This could take a while, since it generally involves a
+					 * user in the loop. To avoid consuming the server's
+					 * authentication timeout, we'll continue this handshake
+					 * to the end, so that the server can close its side of
+					 * the connection. We'll open a second connection later
+					 * once we've retrieved a token.
+					 */
+					discover = true;
+				}
+			}
+			else
+			{
+				/*
+				 * If we don't have a token, and we don't have a discovery URI
+				 * to be able to request a token, we ask the server for one
+				 * explicitly.
+				 */
+				discover = true;
+			}
+
+			/*
+			 * Generate an initial response. This either contains a token, if
+			 * we have one, or an empty discovery response which is doomed to
+			 * fail.
+			 */
+			*output = client_initial_response(conn, discover);
+			if (!*output)
+				return SASL_FAILED;
+
+			*outputlen = strlen(*output);
+			state->step = FE_OAUTH_BEARER_SENT;
+
+			if (conn->oauth_token)
+			{
+				/*
+				 * For the purposes of require_auth, our side of
+				 * authentication is done at this point; the server will
+				 * either accept the connection or send an error. Unlike
+				 * SCRAM, there is no additional server data to check upon
+				 * success.
+				 */
+				conn->client_finished_auth = true;
+			}
+
+			return SASL_CONTINUE;
+
+		case FE_OAUTH_BEARER_SENT:
+			if (final)
+			{
+				/*
+				 * OAUTHBEARER does not make use of additional data with a
+				 * successful SASL exchange, so we shouldn't get an
+				 * AuthenticationSASLFinal message.
+				 */
+				libpq_append_conn_error(conn,
+										"server sent unexpected additional OAuth data");
+				return SASL_FAILED;
+			}
+
+			/*
+			 * An error message was sent by the server. Respond with the
+			 * required dummy message (RFC 7628, sec. 3.2.3).
+			 */
+			*output = strdup(kvsep);
+			if (unlikely(!*output))
+			{
+				libpq_append_conn_error(conn, "out of memory");
+				return SASL_FAILED;
+			}
+			*outputlen = strlen(*output);	/* == 1 */
+
+			/* Grab the settings from discovery. */
+			if (!handle_oauth_sasl_error(conn, input, inputlen))
+				return SASL_FAILED;
+
+			if (conn->oauth_token)
+			{
+				/*
+				 * The server rejected our token. Continue onwards towards the
+				 * expected FATAL message, but mark our state to catch any
+				 * unexpected "success" from the server.
+				 */
+				state->step = FE_OAUTH_SERVER_ERROR;
+				return SASL_CONTINUE;
+			}
+
+			if (!conn->async_auth)
+			{
+				/*
+				 * No OAuth flow is set up yet. Did we get enough information
+				 * from the server to create one?
+				 */
+				if (!conn->oauth_discovery_uri)
+				{
+					libpq_append_conn_error(conn,
+											"server requires OAuth authentication, but no discovery metadata was provided");
+					return SASL_FAILED;
+				}
+
+				/* Yes. Set up the flow now. */
+				if (!setup_token_request(conn, state))
+					return SASL_FAILED;
+
+				if (conn->oauth_token)
+				{
+					/*
+					 * A token was available in a custom flow's cache. Skip
+					 * the asynchronous processing.
+					 */
+					goto reconnect;
+				}
+			}
+
+			/*
+			 * Time to retrieve a token. This involves a number of HTTP
+			 * connections and timed waits, so we escape the synchronous auth
+			 * processing and tell PQconnectPoll to transfer control to our
+			 * async implementation.
+			 */
+			Assert(conn->async_auth);	/* should have been set already */
+			state->step = FE_OAUTH_REQUESTING_TOKEN;
+			return SASL_ASYNC;
+
+		case FE_OAUTH_REQUESTING_TOKEN:
+
+			/*
+			 * We've returned successfully from token retrieval. Double-check
+			 * that we have what we need for the next connection.
+			 */
+			if (!conn->oauth_token)
+			{
+				Assert(false);	/* should have failed before this point! */
+				libpq_append_conn_error(conn,
+										"internal error: OAuth flow did not set a token");
+				return SASL_FAILED;
+			}
+
+			goto reconnect;
+
+		case FE_OAUTH_SERVER_ERROR:
+
+			/*
+			 * After an error, the server should send an error response to
+			 * fail the SASL handshake, which is handled in higher layers.
+			 *
+			 * If we get here, the server either sent *another* challenge
+			 * which isn't defined in the RFC, or completed the handshake
+			 * successfully after telling us it was going to fail. Neither is
+			 * acceptable.
+			 */
+			libpq_append_conn_error(conn,
+									"server sent additional OAuth data after error");
+			return SASL_FAILED;
+
+		default:
+			libpq_append_conn_error(conn, "invalid OAuth exchange state");
+			break;
+	}
+
+	Assert(false);				/* should never get here */
+	return SASL_FAILED;
+
+reconnect:
+
+	/*
+	 * Despite being a failure from the point of view of SASL, we have enough
+	 * information to restart with a new connection.
+	 */
+	libpq_append_conn_error(conn, "retrying connection with new bearer token");
+	conn->oauth_want_retry = true;
+	return SASL_FAILED;
+}
+
+static bool
+oauth_channel_bound(void *opaq)
+{
+	/* This mechanism does not support channel binding. */
+	return false;
+}
+
+/*
+ * Fully clears out any stored OAuth token. This is done proactively upon
+ * successful connection as well as during pqClosePGconn().
+ */
+void
+pqClearOAuthToken(PGconn *conn)
+{
+	if (!conn->oauth_token)
+		return;
+
+	explicit_bzero(conn->oauth_token, strlen(conn->oauth_token));
+	free(conn->oauth_token);
+	conn->oauth_token = NULL;
+}
+
+/*
+ * Returns true if the PGOAUTHDEBUG=UNSAFE flag is set in the environment.
+ */
+bool
+oauth_unsafe_debugging_enabled(void)
+{
+	const char *env = getenv("PGOAUTHDEBUG");
+
+	return (env && strcmp(env, "UNSAFE") == 0);
+}
diff --git a/src/interfaces/libpq/fe-auth-oauth.h b/src/interfaces/libpq/fe-auth-oauth.h
new file mode 100644
index 00000000000..32598721686
--- /dev/null
+++ b/src/interfaces/libpq/fe-auth-oauth.h
@@ -0,0 +1,45 @@
+/*-------------------------------------------------------------------------
+ *
+ * fe-auth-oauth.h
+ *
+ *	  Definitions for OAuth authentication implementations
+ *
+ * Portions Copyright (c) 2024, PostgreSQL Global Development Group
+ *
+ * src/interfaces/libpq/fe-auth-oauth.h
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#ifndef FE_AUTH_OAUTH_H
+#define FE_AUTH_OAUTH_H
+
+#include "libpq-fe.h"
+#include "libpq-int.h"
+
+
+enum fe_oauth_step
+{
+	FE_OAUTH_INIT,
+	FE_OAUTH_BEARER_SENT,
+	FE_OAUTH_REQUESTING_TOKEN,
+	FE_OAUTH_SERVER_ERROR,
+};
+
+typedef struct
+{
+	enum fe_oauth_step step;
+
+	PGconn	   *conn;
+	void	   *async_ctx;
+} fe_oauth_state;
+
+extern PostgresPollingStatusType pg_fe_run_oauth_flow(PGconn *conn);
+extern void pg_fe_cleanup_oauth_flow(PGconn *conn);
+extern void pqClearOAuthToken(PGconn *conn);
+extern bool oauth_unsafe_debugging_enabled(void);
+
+/* Mechanisms in fe-auth-oauth.c */
+extern const pg_fe_sasl_mech pg_oauth_mech;
+
+#endif							/* FE_AUTH_OAUTH_H */
diff --git a/src/interfaces/libpq/fe-auth.c b/src/interfaces/libpq/fe-auth.c
index 761ee8f88f7..ec7a9236044 100644
--- a/src/interfaces/libpq/fe-auth.c
+++ b/src/interfaces/libpq/fe-auth.c
@@ -40,9 +40,11 @@
 #endif
 
 #include "common/md5.h"
+#include "common/oauth-common.h"
 #include "common/scram-common.h"
 #include "fe-auth.h"
 #include "fe-auth-sasl.h"
+#include "fe-auth-oauth.h"
 #include "libpq-fe.h"
 
 #ifdef ENABLE_GSS
@@ -535,6 +537,13 @@ pg_SASL_init(PGconn *conn, int payloadlen, bool *async)
 			conn->sasl = &pg_scram_mech;
 			conn->password_needed = true;
 		}
+		else if (strcmp(mechanism_buf.data, OAUTHBEARER_NAME) == 0 &&
+				 !selected_mechanism)
+		{
+			selected_mechanism = OAUTHBEARER_NAME;
+			conn->sasl = &pg_oauth_mech;
+			conn->password_needed = false;
+		}
 	}
 
 	if (!selected_mechanism)
@@ -559,13 +568,6 @@ pg_SASL_init(PGconn *conn, int payloadlen, bool *async)
 
 		if (!allowed)
 		{
-			/*
-			 * TODO: this is dead code until a second SASL mechanism is added;
-			 * the connection can't have proceeded past check_expected_areq()
-			 * if no SASL methods are allowed.
-			 */
-			Assert(false);
-
 			libpq_append_conn_error(conn, "authentication method requirement \"%s\" failed: server requested %s authentication",
 									conn->require_auth, selected_mechanism);
 			goto error;
@@ -1580,3 +1582,23 @@ PQchangePassword(PGconn *conn, const char *user, const char *passwd)
 		}
 	}
 }
+
+PQauthDataHook_type PQauthDataHook = PQdefaultAuthDataHook;
+
+PQauthDataHook_type
+PQgetAuthDataHook(void)
+{
+	return PQauthDataHook;
+}
+
+void
+PQsetAuthDataHook(PQauthDataHook_type hook)
+{
+	PQauthDataHook = hook ? hook : PQdefaultAuthDataHook;
+}
+
+int
+PQdefaultAuthDataHook(PGauthData type, PGconn *conn, void *data)
+{
+	return 0;					/* handle nothing */
+}
diff --git a/src/interfaces/libpq/fe-auth.h b/src/interfaces/libpq/fe-auth.h
index 1d4991f8996..de98e0d20c4 100644
--- a/src/interfaces/libpq/fe-auth.h
+++ b/src/interfaces/libpq/fe-auth.h
@@ -18,6 +18,9 @@
 #include "libpq-int.h"
 
 
+extern PQauthDataHook_type PQauthDataHook;
+
+
 /* Prototypes for functions in fe-auth.c */
 extern int	pg_fe_sendauth(AuthRequest areq, int payloadlen, PGconn *conn,
 						   bool *async);
diff --git a/src/interfaces/libpq/fe-connect.c b/src/interfaces/libpq/fe-connect.c
index 85d1ca2864f..d5051f5e820 100644
--- a/src/interfaces/libpq/fe-connect.c
+++ b/src/interfaces/libpq/fe-connect.c
@@ -28,6 +28,7 @@
 #include "common/scram-common.h"
 #include "common/string.h"
 #include "fe-auth.h"
+#include "fe-auth-oauth.h"
 #include "libpq-fe.h"
 #include "libpq-int.h"
 #include "mb/pg_wchar.h"
@@ -373,6 +374,23 @@ static const internalPQconninfoOption PQconninfoOptions[] = {
 	{"scram_server_key", NULL, NULL, NULL, "SCRAM-Server-Key", "D", SCRAM_MAX_KEY_LEN * 2,
 	offsetof(struct pg_conn, scram_server_key)},
 
+	/* OAuth v2 */
+	{"oauth_issuer", NULL, NULL, NULL,
+		"OAuth-Issuer", "", 40,
+	offsetof(struct pg_conn, oauth_issuer)},
+
+	{"oauth_client_id", NULL, NULL, NULL,
+		"OAuth-Client-ID", "", 40,
+	offsetof(struct pg_conn, oauth_client_id)},
+
+	{"oauth_client_secret", NULL, NULL, NULL,
+		"OAuth-Client-Secret", "", 40,
+	offsetof(struct pg_conn, oauth_client_secret)},
+
+	{"oauth_scope", NULL, NULL, NULL,
+		"OAuth-Scope", "", 15,
+	offsetof(struct pg_conn, oauth_scope)},
+
 	/* Terminating entry --- MUST BE LAST */
 	{NULL, NULL, NULL, NULL,
 	NULL, NULL, 0}
@@ -399,6 +417,7 @@ static const PQEnvironmentOption EnvironmentOptions[] =
 static const pg_fe_sasl_mech *supported_sasl_mechs[] =
 {
 	&pg_scram_mech,
+	&pg_oauth_mech,
 };
 #define SASL_MECHANISM_COUNT lengthof(supported_sasl_mechs)
 
@@ -655,6 +674,7 @@ pqDropServerData(PGconn *conn)
 	conn->write_failed = false;
 	free(conn->write_err_msg);
 	conn->write_err_msg = NULL;
+	conn->oauth_want_retry = false;
 
 	/*
 	 * Cancel connections need to retain their be_pid and be_key across
@@ -1144,7 +1164,7 @@ static inline void
 fill_allowed_sasl_mechs(PGconn *conn)
 {
 	/*---
-	 * We only support one mechanism at the moment, so rather than deal with a
+	 * We only support two mechanisms at the moment, so rather than deal with a
 	 * linked list, conn->allowed_sasl_mechs is an array of static length. We
 	 * rely on the compile-time assertion here to keep us honest.
 	 *
@@ -1519,6 +1539,10 @@ pqConnectOptions2(PGconn *conn)
 			{
 				mech = &pg_scram_mech;
 			}
+			else if (strcmp(method, "oauth") == 0)
+			{
+				mech = &pg_oauth_mech;
+			}
 
 			/*
 			 * Final group: meta-options.
@@ -4111,7 +4135,19 @@ keep_going:						/* We will come back to here until there is
 				conn->inStart = conn->inCursor;
 
 				if (res != STATUS_OK)
+				{
+					/*
+					 * OAuth connections may perform two-step discovery, where
+					 * the first connection is a dummy.
+					 */
+					if (conn->sasl == &pg_oauth_mech && conn->oauth_want_retry)
+					{
+						need_new_connection = true;
+						goto keep_going;
+					}
+
 					goto error_return;
+				}
 
 				/*
 				 * Just make sure that any data sent by pg_fe_sendauth is
@@ -4390,6 +4426,9 @@ keep_going:						/* We will come back to here until there is
 					}
 				}
 
+				/* Don't hold onto any OAuth tokens longer than necessary. */
+				pqClearOAuthToken(conn);
+
 				/*
 				 * For non cancel requests we can release the address list
 				 * now. For cancel requests we never actually resolve
@@ -5002,6 +5041,12 @@ freePGconn(PGconn *conn)
 	free(conn->load_balance_hosts);
 	free(conn->scram_client_key);
 	free(conn->scram_server_key);
+	free(conn->oauth_issuer);
+	free(conn->oauth_issuer_id);
+	free(conn->oauth_discovery_uri);
+	free(conn->oauth_client_id);
+	free(conn->oauth_client_secret);
+	free(conn->oauth_scope);
 	termPQExpBuffer(&conn->errorMessage);
 	termPQExpBuffer(&conn->workBuffer);
 
@@ -5155,6 +5200,7 @@ pqClosePGconn(PGconn *conn)
 	conn->asyncStatus = PGASYNC_IDLE;
 	conn->xactStatus = PQTRANS_IDLE;
 	conn->pipelineStatus = PQ_PIPELINE_OFF;
+	pqClearOAuthToken(conn);
 	pqClearAsyncResult(conn);	/* deallocate result */
 	pqClearConnErrorState(conn);
 
diff --git a/src/interfaces/libpq/libpq-fe.h b/src/interfaces/libpq/libpq-fe.h
index a3491faf0c3..b7399dee58e 100644
--- a/src/interfaces/libpq/libpq-fe.h
+++ b/src/interfaces/libpq/libpq-fe.h
@@ -59,6 +59,8 @@ extern "C"
 /* Features added in PostgreSQL v18: */
 /* Indicates presence of PQfullProtocolVersion */
 #define LIBPQ_HAS_FULL_PROTOCOL_VERSION 1
+/* Indicates presence of the PQAUTHDATA_PROMPT_OAUTH_DEVICE authdata hook */
+#define LIBPQ_HAS_PROMPT_OAUTH_DEVICE 1
 
 /*
  * Option flags for PQcopyResult
@@ -186,6 +188,13 @@ typedef enum
 	PQ_PIPELINE_ABORTED
 } PGpipelineStatus;
 
+typedef enum
+{
+	PQAUTHDATA_PROMPT_OAUTH_DEVICE, /* user must visit a device-authorization
+									 * URL */
+	PQAUTHDATA_OAUTH_BEARER_TOKEN,	/* server requests an OAuth Bearer token */
+} PGauthData;
+
 /* PGconn encapsulates a connection to the backend.
  * The contents of this struct are not supposed to be known to applications.
  */
@@ -720,10 +729,86 @@ extern int	PQenv2encoding(void);
 
 /* === in fe-auth.c === */
 
+typedef struct _PGpromptOAuthDevice
+{
+	const char *verification_uri;	/* verification URI to visit */
+	const char *user_code;		/* user code to enter */
+	const char *verification_uri_complete;	/* optional combination of URI and
+											 * code, or NULL */
+	int			expires_in;		/* seconds until user code expires */
+} PGpromptOAuthDevice;
+
+/* for PGoauthBearerRequest.async() */
+#ifdef _WIN32
+#define SOCKTYPE uintptr_t		/* avoids depending on winsock2.h for SOCKET */
+#else
+#define SOCKTYPE int
+#endif
+
+typedef struct _PGoauthBearerRequest
+{
+	/* Hook inputs (constant across all calls) */
+	const char *const openid_configuration; /* OIDC discovery URI */
+	const char *const scope;	/* required scope(s), or NULL */
+
+	/* Hook outputs */
+
+	/*---------
+	 * Callback implementing a custom asynchronous OAuth flow.
+	 *
+	 * The callback may return
+	 * - PGRES_POLLING_READING/WRITING, to indicate that a socket descriptor
+	 *   has been stored in *altsock and libpq should wait until it is
+	 *   readable or writable before calling back;
+	 * - PGRES_POLLING_OK, to indicate that the flow is complete and
+	 *   request->token has been set; or
+	 * - PGRES_POLLING_FAILED, to indicate that token retrieval has failed.
+	 *
+	 * This callback is optional. If the token can be obtained without
+	 * 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.
+	 */
+	PostgresPollingStatusType (*async) (PGconn *conn,
+										struct _PGoauthBearerRequest *request,
+										SOCKTYPE * altsock);
+
+	/*
+	 * Callback to clean up custom allocations. A hook implementation may use
+	 * this to free request->token and any resources in request->user.
+	 *
+	 * This is technically optional, but highly recommended, because there is
+	 * no other indication as to when it is safe to free the token.
+	 */
+	void		(*cleanup) (PGconn *conn, struct _PGoauthBearerRequest *request);
+
+	/*
+	 * The hook should set this to the Bearer token contents for the
+	 * connection, once the flow is completed.  The token contents must remain
+	 * available to libpq until the hook's cleanup callback is called.
+	 */
+	char	   *token;
+
+	/*
+	 * Hook-defined data. libpq will not modify this pointer across calls to
+	 * the async callback, so it can be used to keep track of
+	 * application-specific state. Resources allocated here should be freed by
+	 * the cleanup callback.
+	 */
+	void	   *user;
+} PGoauthBearerRequest;
+
+#undef SOCKTYPE
+
 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);
 
+typedef int (*PQauthDataHook_type) (PGauthData type, PGconn *conn, void *data);
+extern void PQsetAuthDataHook(PQauthDataHook_type hook);
+extern PQauthDataHook_type PQgetAuthDataHook(void);
+extern int	PQdefaultAuthDataHook(PGauthData type, PGconn *conn, void *data);
+
 /* === in encnames.c === */
 
 extern int	pg_char_to_encoding(const char *name);
diff --git a/src/interfaces/libpq/libpq-int.h b/src/interfaces/libpq/libpq-int.h
index 2546f9f8a50..f36f7f19d58 100644
--- a/src/interfaces/libpq/libpq-int.h
+++ b/src/interfaces/libpq/libpq-int.h
@@ -437,6 +437,17 @@ struct pg_conn
 								 * cancel request, instead of being a normal
 								 * connection that's used for queries */
 
+	/* OAuth v2 */
+	char	   *oauth_issuer;	/* token issuer/URL */
+	char	   *oauth_issuer_id;	/* token issuer identifier */
+	char	   *oauth_discovery_uri;	/* URI of the issuer's discovery
+										 * document */
+	char	   *oauth_client_id;	/* client identifier */
+	char	   *oauth_client_secret;	/* client secret */
+	char	   *oauth_scope;	/* access token scope */
+	char	   *oauth_token;	/* access token */
+	bool		oauth_want_retry;	/* should we retry on failure? */
+
 	/* Optional file to write trace info to */
 	FILE	   *Pfdebug;
 	int			traceFlags;
@@ -505,7 +516,7 @@ struct pg_conn
 								 * the server? */
 	uint32		allowed_auth_methods;	/* bitmask of acceptable AuthRequest
 										 * codes */
-	const pg_fe_sasl_mech *allowed_sasl_mechs[1];	/* and acceptable SASL
+	const pg_fe_sasl_mech *allowed_sasl_mechs[2];	/* and acceptable SASL
 													 * mechanisms */
 	bool		client_finished_auth;	/* have we finished our half of the
 										 * authentication exchange? */
diff --git a/src/interfaces/libpq/meson.build b/src/interfaces/libpq/meson.build
index dd64d291b3e..19f4a52a97a 100644
--- a/src/interfaces/libpq/meson.build
+++ b/src/interfaces/libpq/meson.build
@@ -1,6 +1,7 @@
 # Copyright (c) 2022-2025, PostgreSQL Global Development Group
 
 libpq_sources = files(
+  'fe-auth-oauth.c',
   'fe-auth-scram.c',
   'fe-auth.c',
   'fe-cancel.c',
@@ -37,6 +38,10 @@ if gssapi.found()
   )
 endif
 
+if libcurl.found()
+  libpq_sources += files('fe-auth-oauth-curl.c')
+endif
+
 export_file = custom_target('libpq.exports',
   kwargs: gen_export_kwargs,
 )
diff --git a/src/makefiles/meson.build b/src/makefiles/meson.build
index d49b2079a44..60e13d50235 100644
--- a/src/makefiles/meson.build
+++ b/src/makefiles/meson.build
@@ -229,6 +229,7 @@ pgxs_deps = {
   'gssapi': gssapi,
   'icu': icu,
   'ldap': ldap,
+  'libcurl': libcurl,
   'libxml': libxml,
   'libxslt': libxslt,
   'llvm': llvm,
diff --git a/src/test/authentication/t/001_password.pl b/src/test/authentication/t/001_password.pl
index 1357f806b6f..4ce22ccbdf2 100644
--- a/src/test/authentication/t/001_password.pl
+++ b/src/test/authentication/t/001_password.pl
@@ -404,11 +404,11 @@ $node->connect_fails(
 $node->connect_fails(
 	"user=scram_role require_auth=!scram-sha-256",
 	"SCRAM authentication forbidden, fails with SCRAM auth",
-	expected_stderr => qr/server requested SASL authentication/);
+	expected_stderr => qr/server requested SCRAM-SHA-256 authentication/);
 $node->connect_fails(
 	"user=scram_role require_auth=!password,!md5,!scram-sha-256",
 	"multiple authentication types forbidden, fails with SCRAM auth",
-	expected_stderr => qr/server requested SASL authentication/);
+	expected_stderr => qr/server requested SCRAM-SHA-256 authentication/);
 
 # Test that bad passwords are rejected.
 $ENV{"PGPASSWORD"} = 'badpass';
@@ -465,13 +465,13 @@ $node->connect_fails(
 	"user=scram_role require_auth=!scram-sha-256",
 	"password authentication forbidden, fails with SCRAM auth",
 	expected_stderr =>
-	  qr/authentication method requirement "!scram-sha-256" failed: server requested SASL authentication/
+	  qr/authentication method requirement "!scram-sha-256" failed: server requested SCRAM-SHA-256 authentication/
 );
 $node->connect_fails(
 	"user=scram_role require_auth=!password,!md5,!scram-sha-256",
 	"multiple authentication types forbidden, fails with SCRAM auth",
 	expected_stderr =>
-	  qr/authentication method requirement "!password,!md5,!scram-sha-256" failed: server requested SASL authentication/
+	  qr/authentication method requirement "!password,!md5,!scram-sha-256" failed: server requested SCRAM-SHA-256 authentication/
 );
 
 # Test SYSTEM_USER <> NULL with parallel workers.
diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile
index 89e78b7d114..4e4be3fa511 100644
--- a/src/test/modules/Makefile
+++ b/src/test/modules/Makefile
@@ -11,6 +11,7 @@ SUBDIRS = \
 		  dummy_index_am \
 		  dummy_seclabel \
 		  libpq_pipeline \
+		  oauth_validator \
 		  plsample \
 		  spgist_name_ops \
 		  test_bloomfilter \
diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build
index a57077b682e..2b057451473 100644
--- a/src/test/modules/meson.build
+++ b/src/test/modules/meson.build
@@ -9,6 +9,7 @@ subdir('gin')
 subdir('injection_points')
 subdir('ldap_password_func')
 subdir('libpq_pipeline')
+subdir('oauth_validator')
 subdir('plsample')
 subdir('spgist_name_ops')
 subdir('ssl_passphrase_callback')
diff --git a/src/test/modules/oauth_validator/.gitignore b/src/test/modules/oauth_validator/.gitignore
new file mode 100644
index 00000000000..5dcb3ff9723
--- /dev/null
+++ b/src/test/modules/oauth_validator/.gitignore
@@ -0,0 +1,4 @@
+# Generated subdirectories
+/log/
+/results/
+/tmp_check/
diff --git a/src/test/modules/oauth_validator/Makefile b/src/test/modules/oauth_validator/Makefile
new file mode 100644
index 00000000000..bbd2a98023b
--- /dev/null
+++ b/src/test/modules/oauth_validator/Makefile
@@ -0,0 +1,40 @@
+#-------------------------------------------------------------------------
+#
+# Makefile for src/test/modules/oauth_validator
+#
+# Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/test/modules/oauth_validator/Makefile
+#
+#-------------------------------------------------------------------------
+
+MODULES = validator fail_validator
+PGFILEDESC = "validator - test OAuth validator module"
+
+PROGRAM = oauth_hook_client
+PGAPPICON = win32
+OBJS = $(WIN32RES) oauth_hook_client.o
+
+PG_CPPFLAGS = -I$(libpq_srcdir)
+PG_LIBS_INTERNAL += $(libpq_pgport)
+
+NO_INSTALLCHECK = 1
+
+TAP_TESTS = 1
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = src/test/modules/oauth_validator
+top_builddir = ../../../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+
+export PYTHON
+export with_libcurl
+export with_python
+
+endif
diff --git a/src/test/modules/oauth_validator/README b/src/test/modules/oauth_validator/README
new file mode 100644
index 00000000000..54eac5b117e
--- /dev/null
+++ b/src/test/modules/oauth_validator/README
@@ -0,0 +1,13 @@
+Test programs and libraries for OAuth
+-------------------------------------
+
+This folder contains tests for the client- and server-side OAuth
+implementations. Most tests are run end-to-end to test both simultaneously. The
+tests in t/001_server use a mock OAuth authorization server, implemented jointly
+by t/OAuth/Server.pm and t/oauth_server.py, to run the libpq Device
+Authorization flow. The tests in t/002_client exercise custom OAuth flows and
+don't need an authorization server.
+
+Tests in this folder require 'oauth' to be present in PG_TEST_EXTRA, since
+HTTPS servers listening on localhost with TCP/IP sockets will be started. A
+Python installation is required to run the mock authorization server.
diff --git a/src/test/modules/oauth_validator/fail_validator.c b/src/test/modules/oauth_validator/fail_validator.c
new file mode 100644
index 00000000000..a4c7a4451d3
--- /dev/null
+++ b/src/test/modules/oauth_validator/fail_validator.c
@@ -0,0 +1,47 @@
+/*-------------------------------------------------------------------------
+ *
+ * fail_validator.c
+ *	  Test module for serverside OAuth token validation callbacks, which is
+ *	  guaranteed to always fail in the validation callback
+ *
+ * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/test/modules/oauth_validator/fail_validator.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "fmgr.h"
+#include "libpq/oauth.h"
+
+PG_MODULE_MAGIC;
+
+static bool fail_token(const ValidatorModuleState *state,
+					   const char *token,
+					   const char *role,
+					   ValidatorModuleResult *result);
+
+/* Callback implementations (we only need the main one) */
+static const OAuthValidatorCallbacks validator_callbacks = {
+	PG_OAUTH_VALIDATOR_MAGIC,
+
+	.validate_cb = fail_token,
+};
+
+const OAuthValidatorCallbacks *
+_PG_oauth_validator_module_init(void)
+{
+	return &validator_callbacks;
+}
+
+static bool
+fail_token(const ValidatorModuleState *state,
+		   const char *token, const char *role,
+		   ValidatorModuleResult *res)
+{
+	elog(FATAL, "fail_validator: sentinel error");
+	pg_unreachable();
+}
diff --git a/src/test/modules/oauth_validator/magic_validator.c b/src/test/modules/oauth_validator/magic_validator.c
new file mode 100644
index 00000000000..5ce68cdf405
--- /dev/null
+++ b/src/test/modules/oauth_validator/magic_validator.c
@@ -0,0 +1,48 @@
+/*-------------------------------------------------------------------------
+ *
+ * magic_validator.c
+ *	  Test module for serverside OAuth token validation callbacks, which
+ *	  should fail due to using the wrong PG_OAUTH_VALIDATOR_MAGIC marker
+ *	  and thus the wrong ABI version
+ *
+ * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/test/modules/oauth_validator/fail_validator.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "fmgr.h"
+#include "libpq/oauth.h"
+
+PG_MODULE_MAGIC;
+
+static bool validate_token(const ValidatorModuleState *state,
+						   const char *token,
+						   const char *role,
+						   ValidatorModuleResult *result);
+
+/* Callback implementations (we only need the main one) */
+static const OAuthValidatorCallbacks validator_callbacks = {
+	0xdeadbeef,
+
+	.validate_cb = validate_token,
+};
+
+const OAuthValidatorCallbacks *
+_PG_oauth_validator_module_init(void)
+{
+	return &validator_callbacks;
+}
+
+static bool
+validate_token(const ValidatorModuleState *state,
+			   const char *token, const char *role,
+			   ValidatorModuleResult *res)
+{
+	elog(FATAL, "magic_validator: this should be unreachable");
+	pg_unreachable();
+}
diff --git a/src/test/modules/oauth_validator/meson.build b/src/test/modules/oauth_validator/meson.build
new file mode 100644
index 00000000000..36d1b26369f
--- /dev/null
+++ b/src/test/modules/oauth_validator/meson.build
@@ -0,0 +1,85 @@
+# Copyright (c) 2025, PostgreSQL Global Development Group
+
+validator_sources = files(
+  'validator.c',
+)
+
+if host_system == 'windows'
+  validator_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'validator',
+    '--FILEDESC', 'validator - test OAuth validator module',])
+endif
+
+validator = shared_module('validator',
+  validator_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += validator
+
+fail_validator_sources = files(
+  'fail_validator.c',
+)
+
+if host_system == 'windows'
+  fail_validator_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'fail_validator',
+    '--FILEDESC', 'fail_validator - failing OAuth validator module',])
+endif
+
+fail_validator = shared_module('fail_validator',
+  fail_validator_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += fail_validator
+
+magic_validator_sources = files(
+  'magic_validator.c',
+)
+
+if host_system == 'windows'
+  magic_validator_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'magic_validator',
+    '--FILEDESC', 'magic_validator - ABI incompatible OAuth validator module',])
+endif
+
+magic_validator = shared_module('magic_validator',
+  magic_validator_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += magic_validator
+
+oauth_hook_client_sources = files(
+  'oauth_hook_client.c',
+)
+
+if host_system == 'windows'
+  oauth_hook_client_sources += rc_bin_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'oauth_hook_client',
+    '--FILEDESC', 'oauth_hook_client - test program for libpq OAuth hooks',])
+endif
+
+oauth_hook_client = executable('oauth_hook_client',
+  oauth_hook_client_sources,
+  dependencies: [frontend_code, libpq],
+  kwargs: default_bin_args + {
+    'install': false,
+  },
+)
+testprep_targets += oauth_hook_client
+
+tests += {
+  'name': 'oauth_validator',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'tap': {
+    'tests': [
+      't/001_server.pl',
+      't/002_client.pl',
+    ],
+    'env': {
+      'PYTHON': python.path(),
+      'with_libcurl': libcurl.found() ? 'yes' : 'no',
+      'with_python': 'yes',
+    },
+  },
+}
diff --git a/src/test/modules/oauth_validator/oauth_hook_client.c b/src/test/modules/oauth_validator/oauth_hook_client.c
new file mode 100644
index 00000000000..9f553792c05
--- /dev/null
+++ b/src/test/modules/oauth_validator/oauth_hook_client.c
@@ -0,0 +1,293 @@
+/*-------------------------------------------------------------------------
+ *
+ * oauth_hook_client.c
+ *		Test driver for t/002_client.pl, which verifies OAuth hook
+ *		functionality in libpq.
+ *
+ * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ *
+ * IDENTIFICATION
+ *		src/test/modules/oauth_validator/oauth_hook_client.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres_fe.h"
+
+#include <sys/socket.h>
+
+#include "getopt_long.h"
+#include "libpq-fe.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(" --expected-scope SCOPE	fail if received scopes do not match SCOPE\n");
+	printf(" --expected-uri URI		fail if received configuration link does not match URI\n");
+	printf(" --misbehave=MODE		have the hook fail required postconditions\n"
+		   "						(MODEs: no-hook, fail-async, no-token, no-socket)\n");
+	printf(" --no-hook				don't install OAuth hooks\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(" --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_scope = NULL;
+static const char *misbehave_mode = NULL;
+static char *token = NULL;
+
+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},
+		{0}
+	};
+
+	const char *conninfo;
+	PGconn	   *conn;
+	int			c;
+
+	while ((c = getopt_long(argc, argv, "h", long_options, NULL)) != -1)
+	{
+		switch (c)
+		{
+			case 'h':
+				usage(argv);
+				return 0;
+
+			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;
+
+			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);
+
+	/* Connect. (All the actual work is in the hook.) */
+	if (stress_async)
+	{
+		/*
+		 * Perform an asynchronous connection, busy-looping on PQconnectPoll()
+		 * without actually waiting on socket events. This stresses code paths
+		 * that rely on asynchronous work to be done before continuing with
+		 * the next step in the flow.
+		 */
+		PostgresPollingStatusType res;
+
+		conn = PQconnectStart(conninfo);
+
+		do
+		{
+			res = PQconnectPoll(conn);
+		} while (res != PGRES_POLLING_FAILED && res != PGRES_POLLING_OK);
+	}
+	else
+	{
+		/* Perform a standard synchronous connection. */
+		conn = PQconnectdb(conninfo);
+	}
+
+	if (PQstatus(conn) != CONNECTION_OK)
+	{
+		fprintf(stderr, "connection to database failed: %s\n",
+				PQerrorMessage(conn));
+		PQfinish(conn);
+		return 1;
+	}
+
+	printf("connection succeeded\n");
+	PQfinish(conn);
+	return 0;
+}
+
+/*
+ * PQauthDataHook implementation. Replaces the default client flow by handling
+ * PQAUTHDATA_OAUTH_BEARER_TOKEN.
+ */
+static int
+handle_auth_data(PGauthData type, PGconn *conn, void *data)
+{
+	PGoauthBearerRequest *req = data;
+
+	if (no_hook || (type != PQAUTHDATA_OAUTH_BEARER_TOKEN))
+		return 0;
+
+	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;
+		}
+	}
+
+	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". */
+		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/001_server.pl b/src/test/modules/oauth_validator/t/001_server.pl
new file mode 100644
index 00000000000..dada89e95cc
--- /dev/null
+++ b/src/test/modules/oauth_validator/t/001_server.pl
@@ -0,0 +1,592 @@
+
+#
+# Tests the libpq builtin OAuth flow, as well as server-side HBA and validator
+# setup.
+#
+# Copyright (c) 2021-2025, PostgreSQL Global Development Group
+#
+
+use strict;
+use warnings FATAL => 'all';
+
+use JSON::PP     qw(encode_json);
+use MIME::Base64 qw(encode_base64);
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+use FindBin;
+use lib $FindBin::RealBin;
+
+use OAuth::Server;
+
+if (!$ENV{PG_TEST_EXTRA} || $ENV{PG_TEST_EXTRA} !~ /\boauth\b/)
+{
+	plan skip_all =>
+	  'Potentially unsafe test oauth not enabled in PG_TEST_EXTRA';
+}
+
+if ($windows_os)
+{
+	plan skip_all => 'OAuth server-side tests are not supported on Windows';
+}
+
+if ($ENV{with_libcurl} ne 'yes')
+{
+	plan skip_all => 'client-side OAuth not supported by this build';
+}
+
+if ($ENV{with_python} ne 'yes')
+{
+	plan skip_all => 'OAuth tests require --with-python to run';
+}
+
+my $node = PostgreSQL::Test::Cluster->new('primary');
+$node->init;
+$node->append_conf('postgresql.conf', "log_connections = on\n");
+$node->append_conf('postgresql.conf',
+	"oauth_validator_libraries = 'validator'\n");
+$node->start;
+
+$node->safe_psql('postgres', 'CREATE USER test;');
+$node->safe_psql('postgres', 'CREATE USER testalt;');
+$node->safe_psql('postgres', 'CREATE USER testparam;');
+
+# Save a background connection for later configuration changes.
+my $bgconn = $node->background_psql('postgres');
+
+my $webserver = OAuth::Server->new();
+$webserver->run();
+
+END
+{
+	my $exit_code = $?;
+
+	$webserver->stop() if defined $webserver;    # might have been SKIP'd
+
+	$? = $exit_code;
+}
+
+my $port = $webserver->port();
+my $issuer = "http://localhost:$port";
+
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test      oauth issuer="$issuer"       scope="openid postgres"
+local all testalt   oauth issuer="$issuer/.well-known/oauth-authorization-server/alternate" scope="openid postgres alt"
+local all testparam oauth issuer="$issuer/param" scope="openid postgres"
+});
+$node->reload;
+
+my $log_start = $node->wait_for_log(qr/reloading configuration files/);
+
+# Check pg_hba_file_rules() support.
+my $contents = $bgconn->query_safe(
+	qq(SELECT rule_number, auth_method, options
+		 FROM pg_hba_file_rules
+		 ORDER BY rule_number;));
+is( $contents,
+	qq{1|oauth|\{issuer=$issuer,"scope=openid postgres",validator=validator\}
+2|oauth|\{issuer=$issuer/.well-known/oauth-authorization-server/alternate,"scope=openid postgres alt",validator=validator\}
+3|oauth|\{issuer=$issuer/param,"scope=openid postgres",validator=validator\}},
+	"pg_hba_file_rules recreates OAuth HBA settings");
+
+# To test against HTTP rather than HTTPS, we need to enable PGOAUTHDEBUG. But
+# first, check to make sure the client refuses such connections by default.
+$node->connect_fails(
+	"user=test dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635",
+	"HTTPS is required without debug mode",
+	expected_stderr =>
+	  qr@OAuth discovery URI "\Q$issuer\E/.well-known/openid-configuration" must use HTTPS@
+);
+
+$ENV{PGOAUTHDEBUG} = "UNSAFE";
+
+my $user = "test";
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635",
+	"connect as test",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@,
+	log_like => [
+		qr/oauth_validator: token="9243959234", role="$user"/,
+		qr/oauth_validator: issuer="\Q$issuer\E", scope="openid postgres"/,
+		qr/connection authenticated: identity="test" method=oauth/,
+		qr/connection authorized/,
+	]);
+
+# The /alternate issuer uses slightly different parameters, along with an
+# OAuth-style discovery document.
+$user = "testalt";
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer/alternate oauth_client_id=f02c6361-0636",
+	"connect as testalt",
+	expected_stderr =>
+	  qr@Visit https://example\.org/ and enter the code: postgresuser@,
+	log_like => [
+		qr/oauth_validator: token="9243959234-alt", role="$user"/,
+		qr|oauth_validator: issuer="\Q$issuer/.well-known/oauth-authorization-server/alternate\E", scope="openid postgres alt"|,
+		qr/connection authenticated: identity="testalt" method=oauth/,
+		qr/connection authorized/,
+	]);
+
+# The issuer linked by the server must match the client's oauth_issuer setting.
+$node->connect_fails(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0636",
+	"oauth_issuer must match discovery",
+	expected_stderr =>
+	  qr@server's discovery document at \Q$issuer/.well-known/oauth-authorization-server/alternate\E \(issuer "\Q$issuer/alternate\E"\) is incompatible with oauth_issuer \(\Q$issuer\E\)@
+);
+
+# Test require_auth settings against OAUTHBEARER.
+my @cases = (
+	{ require_auth => "oauth" },
+	{ require_auth => "oauth,scram-sha-256" },
+	{ require_auth => "password,oauth" },
+	{ require_auth => "none,oauth" },
+	{ require_auth => "!scram-sha-256" },
+	{ require_auth => "!none" },
+
+	{
+		require_auth => "!oauth",
+		failure => qr/server requested OAUTHBEARER authentication/
+	},
+	{
+		require_auth => "scram-sha-256",
+		failure => qr/server requested OAUTHBEARER authentication/
+	},
+	{
+		require_auth => "!password,!oauth",
+		failure => qr/server requested OAUTHBEARER authentication/
+	},
+	{
+		require_auth => "none",
+		failure => qr/server requested SASL authentication/
+	},
+	{
+		require_auth => "!oauth,!scram-sha-256",
+		failure => qr/server requested SASL authentication/
+	});
+
+$user = "test";
+foreach my $c (@cases)
+{
+	my $connstr =
+	  "user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635 require_auth=$c->{'require_auth'}";
+
+	if (defined $c->{'failure'})
+	{
+		$node->connect_fails(
+			$connstr,
+			"require_auth=$c->{'require_auth'} fails",
+			expected_stderr => $c->{'failure'});
+	}
+	else
+	{
+		$node->connect_ok(
+			$connstr,
+			"require_auth=$c->{'require_auth'} succeeds",
+			expected_stderr =>
+			  qr@Visit https://example\.com/ and enter the code: postgresuser@
+		);
+	}
+}
+
+# Make sure the client_id and secret are correctly encoded. $vschars contains
+# every allowed character for a client_id/_secret (the "VSCHAR" class).
+# $vschars_esc is additionally backslash-escaped for inclusion in a
+# single-quoted connection string.
+my $vschars =
+  " !\"#\$%&'()*+,-./0123456789:;<=>?\@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~";
+my $vschars_esc =
+  " !\"#\$%&\\'()*+,-./0123456789:;<=>?\@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\\\]^_`abcdefghijklmnopqrstuvwxyz{|}~";
+
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id='$vschars_esc'",
+	"escapable characters: client_id",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id='$vschars_esc' oauth_client_secret='$vschars_esc'",
+	"escapable characters: client_id and secret",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+#
+# Further tests rely on support for specific behaviors in oauth_server.py. To
+# trigger these behaviors, we ask for the special issuer .../param (which is set
+# up in HBA for the testparam user) and encode magic instructions into the
+# oauth_client_id.
+#
+
+my $common_connstr =
+  "user=testparam dbname=postgres oauth_issuer=$issuer/param ";
+my $base_connstr = $common_connstr;
+
+sub connstr
+{
+	my (%params) = @_;
+
+	my $json = encode_json(\%params);
+	my $encoded = encode_base64($json, "");
+
+	return "$base_connstr oauth_client_id=$encoded";
+}
+
+# Make sure the param system works end-to-end first.
+$node->connect_ok(
+	connstr(),
+	"connect to /param",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$node->connect_ok(
+	connstr(stage => 'token', retries => 1),
+	"token retry",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(stage => 'token', retries => 2),
+	"token retry (twice)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(stage => 'all', retries => 1, interval => 2),
+	"token retry (two second interval)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(stage => 'all', retries => 1, interval => JSON::PP::null),
+	"token retry (default interval)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$node->connect_ok(
+	connstr(stage => 'all', content_type => 'application/json;charset=utf-8'),
+	"content type with charset",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(
+		stage => 'all',
+		content_type => "application/json \t;\t charset=utf-8"),
+	"content type with charset (whitespace)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(stage => 'device', uri_spelling => "verification_url"),
+	"alternative spelling of verification_uri",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$node->connect_fails(
+	connstr(stage => 'device', huge_response => JSON::PP::true),
+	"bad device authz response: overlarge JSON",
+	expected_stderr =>
+	  qr/failed to obtain device authorization: response is too large/);
+$node->connect_fails(
+	connstr(stage => 'token', huge_response => JSON::PP::true),
+	"bad token response: overlarge JSON",
+	expected_stderr =>
+	  qr/failed to obtain access token: response is too large/);
+
+$node->connect_fails(
+	connstr(stage => 'device', content_type => 'text/plain'),
+	"bad device authz response: wrong content type",
+	expected_stderr =>
+	  qr/failed to parse device authorization: unexpected content type/);
+$node->connect_fails(
+	connstr(stage => 'token', content_type => 'text/plain'),
+	"bad token response: wrong content type",
+	expected_stderr =>
+	  qr/failed to parse access token response: unexpected content type/);
+$node->connect_fails(
+	connstr(stage => 'token', content_type => 'application/jsonx'),
+	"bad token response: wrong content type (correct prefix)",
+	expected_stderr =>
+	  qr/failed to parse access token response: unexpected content type/);
+
+$node->connect_fails(
+	connstr(
+		stage => 'all',
+		interval => ~0,
+		retries => 1,
+		retry_code => "slow_down"),
+	"bad token response: server overflows the device authz interval",
+	expected_stderr =>
+	  qr/failed to obtain access token: slow_down interval overflow/);
+
+$node->connect_fails(
+	connstr(stage => 'token', error_code => "invalid_grant"),
+	"bad token response: invalid_grant, no description",
+	expected_stderr => qr/failed to obtain access token: \(invalid_grant\)/);
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_grant",
+		error_desc => "grant expired"),
+	"bad token response: expired grant",
+	expected_stderr =>
+	  qr/failed to obtain access token: grant expired \(invalid_grant\)/);
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_client",
+		error_status => 401),
+	"bad token response: client authentication failure, default description",
+	expected_stderr =>
+	  qr/failed to obtain access token: provider requires client authentication, and no oauth_client_secret is set \(invalid_client\)/
+);
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_client",
+		error_status => 401,
+		error_desc => "authn failure"),
+	"bad token response: client authentication failure, provided description",
+	expected_stderr =>
+	  qr/failed to obtain access token: authn failure \(invalid_client\)/);
+
+$node->connect_fails(
+	connstr(stage => 'token', token => ""),
+	"server rejects access: empty token",
+	expected_stderr => qr/bearer authentication failed/);
+$node->connect_fails(
+	connstr(stage => 'token', token => "****"),
+	"server rejects access: invalid token contents",
+	expected_stderr => qr/bearer authentication failed/);
+
+# Test behavior of the oauth_client_secret.
+$base_connstr = "$common_connstr oauth_client_secret=''";
+
+$node->connect_ok(
+	connstr(stage => 'all', expected_secret => ''),
+	"empty oauth_client_secret",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$base_connstr = "$common_connstr oauth_client_secret='$vschars_esc'";
+
+$node->connect_ok(
+	connstr(stage => 'all', expected_secret => $vschars),
+	"nonempty oauth_client_secret",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_client",
+		error_status => 401),
+	"bad token response: client authentication failure, default description with oauth_client_secret",
+	expected_stderr =>
+	  qr/failed to obtain access token: provider rejected the oauth_client_secret \(invalid_client\)/
+);
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_client",
+		error_status => 401,
+		error_desc => "mutual TLS required for client"),
+	"bad token response: client authentication failure, provided description with oauth_client_secret",
+	expected_stderr =>
+	  qr/failed to obtain access token: mutual TLS required for client \(invalid_client\)/
+);
+
+# Stress test: make sure our builtin flow operates correctly even if the client
+# application isn't respecting PGRES_POLLING_READING/WRITING signals returned
+# from PQconnectPoll().
+$base_connstr =
+  "$common_connstr port=" . $node->port . " host=" . $node->host;
+my @cmd = (
+	"oauth_hook_client", "--no-hook", "--stress-async",
+	connstr(stage => 'all', retries => 1, interval => 1));
+
+note "running '" . join("' '", @cmd) . "'";
+my ($stdout, $stderr) = run_command(\@cmd);
+
+like($stdout, qr/connection succeeded/, "stress-async: stdout matches");
+unlike(
+	$stderr,
+	qr/connection to database failed/,
+	"stress-async: stderr matches");
+
+#
+# This section of tests reconfigures the validator module itself, rather than
+# the OAuth server.
+#
+
+# Searching the logs is easier if OAuth parameter discovery isn't cluttering
+# things up; hardcode the discovery URI. (Scope is hardcoded to empty to cover
+# that case as well.)
+$common_connstr =
+  "dbname=postgres oauth_issuer=$issuer/.well-known/openid-configuration oauth_scope='' oauth_client_id=f02c6361-0635";
+
+# Misbehaving validators must fail shut.
+$bgconn->query_safe("ALTER SYSTEM SET oauth_validator.authn_id TO ''");
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+$node->connect_fails(
+	"$common_connstr user=test",
+	"validator must set authn_id",
+	expected_stderr => qr/OAuth bearer authentication failed/,
+	log_like => [
+		qr/connection authenticated: identity=""/,
+		qr/DETAIL:\s+Validator provided no identity/,
+		qr/FATAL:\s+OAuth bearer authentication failed/,
+	]);
+
+# Even if a validator authenticates the user, if the token isn't considered
+# valid, the connection fails.
+$bgconn->query_safe(
+	"ALTER SYSTEM SET oauth_validator.authn_id TO 'test\@example.org'");
+$bgconn->query_safe(
+	"ALTER SYSTEM SET oauth_validator.authorize_tokens TO false");
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+$node->connect_fails(
+	"$common_connstr user=test",
+	"validator must authorize token explicitly",
+	expected_stderr => qr/OAuth bearer authentication failed/,
+	log_like => [
+		qr/connection authenticated: identity="test\@example\.org"/,
+		qr/DETAIL:\s+Validator failed to authorize the provided token/,
+		qr/FATAL:\s+OAuth bearer authentication failed/,
+	]);
+
+#
+# Test user mapping.
+#
+
+# Allow "[email protected]" to log in under the test role.
+unlink($node->data_dir . '/pg_ident.conf');
+$node->append_conf(
+	'pg_ident.conf', qq{
+oauthmap	user\@example.com	test
+});
+
+# test and testalt use the map; testparam uses ident delegation.
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test      oauth issuer="$issuer" scope="" map=oauthmap
+local all testalt   oauth issuer="$issuer" scope="" map=oauthmap
+local all testparam oauth issuer="$issuer" scope="" delegate_ident_mapping=1
+});
+
+# To start, have the validator use the role names as authn IDs.
+$bgconn->query_safe("ALTER SYSTEM RESET oauth_validator.authn_id");
+$bgconn->query_safe("ALTER SYSTEM RESET oauth_validator.authorize_tokens");
+
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+# The test and testalt roles should no longer map correctly.
+$node->connect_fails(
+	"$common_connstr user=test",
+	"mismatched username map (test)",
+	expected_stderr => qr/OAuth bearer authentication failed/);
+$node->connect_fails(
+	"$common_connstr user=testalt",
+	"mismatched username map (testalt)",
+	expected_stderr => qr/OAuth bearer authentication failed/);
+
+# Have the validator identify the end user as [email protected].
+$bgconn->query_safe(
+	"ALTER SYSTEM SET oauth_validator.authn_id TO 'user\@example.com'");
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+# Now the test role can be logged into. (testalt still can't be mapped.)
+$node->connect_ok(
+	"$common_connstr user=test",
+	"matched username map (test)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_fails(
+	"$common_connstr user=testalt",
+	"mismatched username map (testalt)",
+	expected_stderr => qr/OAuth bearer authentication failed/);
+
+# testparam ignores the map entirely.
+$node->connect_ok(
+	"$common_connstr user=testparam",
+	"delegated ident (testparam)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$bgconn->query_safe("ALTER SYSTEM RESET oauth_validator.authn_id");
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+#
+# Test multiple validators.
+#
+
+$node->append_conf('postgresql.conf',
+	"oauth_validator_libraries = 'validator, fail_validator'\n");
+
+# With multiple validators, every HBA line must explicitly declare one.
+my $result = $node->restart(fail_ok => 1);
+is($result, 0,
+	'restart fails without explicit validators in oauth HBA entries');
+
+$log_start = $node->wait_for_log(
+	qr/authentication method "oauth" requires argument "validator" to be set/,
+	$log_start);
+
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test    oauth validator=validator      issuer="$issuer"           scope="openid postgres"
+local all testalt oauth validator=fail_validator issuer="$issuer/.well-known/oauth-authorization-server/alternate" scope="openid postgres alt"
+});
+$node->restart;
+
+$log_start = $node->wait_for_log(qr/ready to accept connections/, $log_start);
+
+# The test user should work as before.
+$user = "test";
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635",
+	"validator is used for $user",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@,
+	log_like => [qr/connection authorized/]);
+
+# testalt should be routed through the fail_validator.
+$user = "testalt";
+$node->connect_fails(
+	"user=$user dbname=postgres oauth_issuer=$issuer/.well-known/oauth-authorization-server/alternate oauth_client_id=f02c6361-0636",
+	"fail_validator is used for $user",
+	expected_stderr => qr/FATAL:\s+fail_validator: sentinel error/);
+
+#
+# Test ABI compatability magic marker
+#
+$node->append_conf('postgresql.conf',
+	"oauth_validator_libraries = 'magic_validator'\n");
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test    oauth validator=magic_validator      issuer="$issuer"           scope="openid postgres"
+});
+$node->restart;
+
+$log_start = $node->wait_for_log(qr/ready to accept connections/, $log_start);
+
+$node->connect_fails(
+	"user=test dbname=postgres oauth_issuer=$issuer/.well-known/oauth-authorization-server/alternate oauth_client_id=f02c6361-0636",
+	"magic_validator is used for $user",
+	expected_stderr => qr/FATAL:\s+OAuth validator module "magic_validator": magic number mismatch/);
+$node->stop;
+
+done_testing();
diff --git a/src/test/modules/oauth_validator/t/002_client.pl b/src/test/modules/oauth_validator/t/002_client.pl
new file mode 100644
index 00000000000..ab83258d736
--- /dev/null
+++ b/src/test/modules/oauth_validator/t/002_client.pl
@@ -0,0 +1,154 @@
+#
+# Exercises the API for custom OAuth client flows, using the oauth_hook_client
+# test driver.
+#
+# Copyright (c) 2021-2025, PostgreSQL Global Development Group
+#
+
+use strict;
+use warnings FATAL => 'all';
+
+use JSON::PP     qw(encode_json);
+use MIME::Base64 qw(encode_base64);
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+if (!$ENV{PG_TEST_EXTRA} || $ENV{PG_TEST_EXTRA} !~ /\boauth\b/)
+{
+	plan skip_all =>
+	  'Potentially unsafe test oauth not enabled in PG_TEST_EXTRA';
+}
+
+#
+# Cluster Setup
+#
+
+my $node = PostgreSQL::Test::Cluster->new('primary');
+$node->init;
+$node->append_conf('postgresql.conf', "log_connections = on\n");
+$node->append_conf('postgresql.conf',
+	"oauth_validator_libraries = 'validator'\n");
+$node->start;
+
+$node->safe_psql('postgres', 'CREATE USER test;');
+
+# These tests don't use the builtin flow, and we don't have an authorization
+# server running, so the address used here shouldn't matter. Use an invalid IP
+# address, so if there's some cascade of errors that causes the client to
+# attempt a connection, we'll fail noisily.
+my $issuer = "https://256.256.256.256";
+my $scope = "openid postgres";
+
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test oauth issuer="$issuer" scope="$scope"
+});
+$node->reload;
+
+my ($log_start, $log_end);
+$log_start = $node->wait_for_log(qr/reloading configuration files/);
+
+$ENV{PGOAUTHDEBUG} = "UNSAFE";
+
+#
+# Tests
+#
+
+my $user = "test";
+my $base_connstr = $node->connstr() . " user=$user";
+my $common_connstr =
+  "$base_connstr oauth_issuer=$issuer oauth_client_id=myID";
+
+sub test
+{
+	my ($test_name, %params) = @_;
+
+	my $flags = [];
+	if (defined($params{flags}))
+	{
+		$flags = $params{flags};
+	}
+
+	my @cmd = ("oauth_hook_client", @{$flags}, $common_connstr);
+	note "running '" . join("' '", @cmd) . "'";
+
+	my ($stdout, $stderr) = run_command(\@cmd);
+
+	if (defined($params{expected_stdout}))
+	{
+		like($stdout, $params{expected_stdout}, "$test_name: stdout matches");
+	}
+
+	if (defined($params{expected_stderr}))
+	{
+		like($stderr, $params{expected_stderr}, "$test_name: stderr matches");
+	}
+	else
+	{
+		is($stderr, "", "$test_name: no stderr");
+	}
+}
+
+test(
+	"basic synchronous hook can provide a token",
+	flags => [
+		"--token", "my-token",
+		"--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"/, ]);
+
+if ($ENV{with_libcurl} ne 'yes')
+{
+	# libpq should help users out if no OAuth support is built in.
+	test(
+		"fails without custom hook installed",
+		flags => ["--no-hook"],
+		expected_stderr =>
+		  qr/no custom OAuth flows are available, and libpq was not built with libcurl support/
+	);
+}
+
+# connect_timeout should work if the flow doesn't respond.
+$common_connstr = "$common_connstr connect_timeout=1";
+test(
+	"connect_timeout interrupts hung client flow",
+	flags => ["--hang-forever"],
+	expected_stderr => qr/failed: timeout expired/);
+
+# Test various misbehaviors of the client hook.
+my @cases = (
+	{
+		flag => "--misbehave=no-hook",
+		expected_error =>
+		  qr/user-defined OAuth flow provided neither a token nor an async callback/,
+	},
+	{
+		flag => "--misbehave=fail-async",
+		expected_error => qr/user-defined OAuth flow failed/,
+	},
+	{
+		flag => "--misbehave=no-token",
+		expected_error => qr/user-defined OAuth flow did not provide a token/,
+	},
+	{
+		flag => "--misbehave=no-socket",
+		expected_error =>
+		  qr/user-defined OAuth flow did not provide a socket for polling/,
+	});
+
+foreach my $c (@cases)
+{
+	test(
+		"hook misbehavior: $c->{'flag'}",
+		flags => [ $c->{'flag'} ],
+		expected_stderr => $c->{'expected_error'});
+}
+
+done_testing();
diff --git a/src/test/modules/oauth_validator/t/OAuth/Server.pm b/src/test/modules/oauth_validator/t/OAuth/Server.pm
new file mode 100644
index 00000000000..655b2870b0b
--- /dev/null
+++ b/src/test/modules/oauth_validator/t/OAuth/Server.pm
@@ -0,0 +1,140 @@
+
+# Copyright (c) 2025, PostgreSQL Global Development Group
+
+=pod
+
+=head1 NAME
+
+OAuth::Server - runs a mock OAuth authorization server for testing
+
+=head1 SYNOPSIS
+
+  use OAuth::Server;
+
+  my $server = OAuth::Server->new();
+  $server->run;
+
+  my $port = $server->port;
+  my $issuer = "http://localhost:$port";
+
+  # test against $issuer...
+
+  $server->stop;
+
+=head1 DESCRIPTION
+
+This is glue API between the Perl tests and the Python authorization server
+daemon implemented in t/oauth_server.py. (Python has a fairly usable HTTP server
+in its standard library, so the implementation was ported from Perl.)
+
+This authorization server does not use TLS (it implements a nonstandard, unsafe
+issuer at "http://localhost:<port>"), so libpq in particular will need to set
+PGOAUTHDEBUG=UNSAFE to be able to talk to it.
+
+=cut
+
+package OAuth::Server;
+
+use warnings;
+use strict;
+use Scalar::Util;
+use Test::More;
+
+=pod
+
+=head1 METHODS
+
+=over
+
+=item OAuth::Server->new()
+
+Create a new OAuth Server object.
+
+=cut
+
+sub new
+{
+	my $class = shift;
+
+	my $self = {};
+	bless($self, $class);
+
+	return $self;
+}
+
+=pod
+
+=item $server->port()
+
+Returns the port in use by the server.
+
+=cut
+
+sub port
+{
+	my $self = shift;
+
+	return $self->{'port'};
+}
+
+=pod
+
+=item $server->run()
+
+Runs the authorization server daemon in t/oauth_server.py.
+
+=cut
+
+sub run
+{
+	my $self = shift;
+	my $port;
+
+	my $pid = open(my $read_fh, "-|", $ENV{PYTHON}, "t/oauth_server.py")
+	  or die "failed to start OAuth server: $!";
+
+	# Get the port number from the daemon. It closes stdout afterwards; that way
+	# we can slurp in the entire contents here rather than worrying about the
+	# number of bytes to read.
+	$port = do { local $/ = undef; <$read_fh> }
+	  // die "failed to read port number: $!";
+	chomp $port;
+	die "server did not advertise a valid port"
+	  unless Scalar::Util::looks_like_number($port);
+
+	$self->{'pid'} = $pid;
+	$self->{'port'} = $port;
+	$self->{'child'} = $read_fh;
+
+	note("OAuth provider (PID $pid) is listening on port $port\n");
+}
+
+=pod
+
+=item $server->stop()
+
+Sends SIGTERM to the authorization server and waits for it to exit.
+
+=cut
+
+sub stop
+{
+	my $self = shift;
+
+	note("Sending SIGTERM to OAuth provider PID: $self->{'pid'}\n");
+
+	kill(15, $self->{'pid'});
+	$self->{'pid'} = undef;
+
+	# Closing the popen() handle waits for the process to exit.
+	close($self->{'child'});
+	$self->{'child'} = undef;
+}
+
+=pod
+
+=back
+
+=cut
+
+1;
diff --git a/src/test/modules/oauth_validator/t/oauth_server.py b/src/test/modules/oauth_validator/t/oauth_server.py
new file mode 100755
index 00000000000..4faf3323d38
--- /dev/null
+++ b/src/test/modules/oauth_validator/t/oauth_server.py
@@ -0,0 +1,391 @@
+#! /usr/bin/env python3
+#
+# A mock OAuth authorization server, designed to be invoked from
+# OAuth/Server.pm. This listens on an ephemeral port number (printed to stdout
+# so that the Perl tests can contact it) and runs as a daemon until it is
+# signaled.
+#
+
+import base64
+import http.server
+import json
+import os
+import sys
+import time
+import urllib.parse
+from collections import defaultdict
+
+
+class OAuthHandler(http.server.BaseHTTPRequestHandler):
+    """
+    Core implementation of the authorization server. The API is
+    inheritance-based, with entry points at do_GET() and do_POST(). See the
+    documentation for BaseHTTPRequestHandler.
+    """
+
+    JsonObject = dict[str, object]  # TypeAlias is not available until 3.10
+
+    def _check_issuer(self):
+        """
+        Switches the behavior of the provider depending on the issuer URI.
+        """
+        self._alt_issuer = (
+            self.path.startswith("/alternate/")
+            or self.path == "/.well-known/oauth-authorization-server/alternate"
+        )
+        self._parameterized = self.path.startswith("/param/")
+
+        if self._alt_issuer:
+            # The /alternate issuer uses IETF-style .well-known URIs.
+            if self.path.startswith("/.well-known/"):
+                self.path = self.path.removesuffix("/alternate")
+            else:
+                self.path = self.path.removeprefix("/alternate")
+        elif self._parameterized:
+            self.path = self.path.removeprefix("/param")
+
+    def _check_authn(self):
+        """
+        Checks the expected value of the Authorization header, if any.
+        """
+        secret = self._get_param("expected_secret", None)
+        if secret is None:
+            return
+
+        assert "Authorization" in self.headers
+        method, creds = self.headers["Authorization"].split()
+
+        if method != "Basic":
+            raise RuntimeError(f"client used {method} auth; expected Basic")
+
+        username = urllib.parse.quote_plus(self.client_id)
+        password = urllib.parse.quote_plus(secret)
+        expected_creds = f"{username}:{password}"
+
+        if creds.encode() != base64.b64encode(expected_creds.encode()):
+            raise RuntimeError(
+                f"client sent '{creds}'; expected b64encode('{expected_creds}')"
+            )
+
+    def do_GET(self):
+        self._response_code = 200
+        self._check_issuer()
+
+        config_path = "/.well-known/openid-configuration"
+        if self._alt_issuer:
+            config_path = "/.well-known/oauth-authorization-server"
+
+        if self.path == config_path:
+            resp = self.config()
+        else:
+            self.send_error(404, "Not Found")
+            return
+
+        self._send_json(resp)
+
+    def _parse_params(self) -> dict[str, str]:
+        """
+        Parses apart the form-urlencoded request body and returns the resulting
+        dict. For use by do_POST().
+        """
+        size = int(self.headers["Content-Length"])
+        form = self.rfile.read(size)
+
+        assert self.headers["Content-Type"] == "application/x-www-form-urlencoded"
+        return urllib.parse.parse_qs(
+            form.decode("utf-8"),
+            strict_parsing=True,
+            keep_blank_values=True,
+            encoding="utf-8",
+            errors="strict",
+        )
+
+    @property
+    def client_id(self) -> str:
+        """
+        Returns the client_id sent in the POST body or the Authorization header.
+        self._parse_params() must have been called first.
+        """
+        if "client_id" in self._params:
+            return self._params["client_id"][0]
+
+        if "Authorization" not in self.headers:
+            raise RuntimeError("client did not send any client_id")
+
+        _, creds = self.headers["Authorization"].split()
+
+        decoded = base64.b64decode(creds).decode("utf-8")
+        username, _ = decoded.split(":", 1)
+
+        return urllib.parse.unquote_plus(username)
+
+    def do_POST(self):
+        self._response_code = 200
+        self._check_issuer()
+
+        self._params = self._parse_params()
+        if self._parameterized:
+            # Pull encoded test parameters out of the peer's client_id field.
+            # This is expected to be Base64-encoded JSON.
+            js = base64.b64decode(self.client_id)
+            self._test_params = json.loads(js)
+
+        self._check_authn()
+
+        if self.path == "/authorize":
+            resp = self.authorization()
+        elif self.path == "/token":
+            resp = self.token()
+        else:
+            self.send_error(404)
+            return
+
+        self._send_json(resp)
+
+    def _should_modify(self) -> bool:
+        """
+        Returns True if the client has requested a modification to this stage of
+        the exchange.
+        """
+        if not hasattr(self, "_test_params"):
+            return False
+
+        stage = self._test_params.get("stage")
+
+        return (
+            stage == "all"
+            or (
+                stage == "discovery"
+                and self.path == "/.well-known/openid-configuration"
+            )
+            or (stage == "device" and self.path == "/authorize")
+            or (stage == "token" and self.path == "/token")
+        )
+
+    def _get_param(self, name, default):
+        """
+        If the client has requested a modification to this stage (see
+        _should_modify()), this method searches the provided test parameters for
+        a key of the given name, and returns it if found. Otherwise the provided
+        default is returned.
+        """
+        if self._should_modify() and name in self._test_params:
+            return self._test_params[name]
+
+        return default
+
+    @property
+    def _content_type(self) -> str:
+        """
+        Returns "application/json" unless the test has requested something
+        different.
+        """
+        return self._get_param("content_type", "application/json")
+
+    @property
+    def _interval(self) -> int:
+        """
+        Returns 0 unless the test has requested something different.
+        """
+        return self._get_param("interval", 0)
+
+    @property
+    def _retry_code(self) -> str:
+        """
+        Returns "authorization_pending" unless the test has requested something
+        different.
+        """
+        return self._get_param("retry_code", "authorization_pending")
+
+    @property
+    def _uri_spelling(self) -> str:
+        """
+        Returns "verification_uri" unless the test has requested something
+        different.
+        """
+        return self._get_param("uri_spelling", "verification_uri")
+
+    @property
+    def _response_padding(self):
+        """
+        If the huge_response test parameter is set to True, returns a dict
+        containing a gigantic string value, which can then be folded into a JSON
+        response.
+        """
+        if not self._get_param("huge_response", False):
+            return dict()
+
+        return {"_pad_": "x" * 1024 * 1024}
+
+    @property
+    def _access_token(self):
+        """
+        The actual Bearer token sent back to the client on success. Tests may
+        override this with the "token" test parameter.
+        """
+        token = self._get_param("token", None)
+        if token is not None:
+            return token
+
+        token = "9243959234"
+        if self._alt_issuer:
+            token += "-alt"
+
+        return token
+
+    def _send_json(self, js: JsonObject) -> None:
+        """
+        Sends the provided JSON dict as an application/json response.
+        self._response_code can be modified to send JSON error responses.
+        """
+        resp = json.dumps(js).encode("ascii")
+        self.log_message("sending JSON response: %s", resp)
+
+        self.send_response(self._response_code)
+        self.send_header("Content-Type", self._content_type)
+        self.send_header("Content-Length", str(len(resp)))
+        self.end_headers()
+
+        self.wfile.write(resp)
+
+    def config(self) -> JsonObject:
+        port = self.server.socket.getsockname()[1]
+
+        issuer = f"http://localhost:{port}"
+        if self._alt_issuer:
+            issuer += "/alternate"
+        elif self._parameterized:
+            issuer += "/param"
+
+        return {
+            "issuer": issuer,
+            "token_endpoint": issuer + "/token",
+            "device_authorization_endpoint": issuer + "/authorize",
+            "response_types_supported": ["token"],
+            "subject_types_supported": ["public"],
+            "id_token_signing_alg_values_supported": ["RS256"],
+            "grant_types_supported": [
+                "authorization_code",
+                "urn:ietf:params:oauth:grant-type:device_code",
+            ],
+        }
+
+    @property
+    def _token_state(self):
+        """
+        A cached _TokenState object for the connected client (as determined by
+        the request's client_id), or a new one if it doesn't already exist.
+
+        This relies on the existence of a defaultdict attached to the server;
+        see main() below.
+        """
+        return self.server.token_state[self.client_id]
+
+    def _remove_token_state(self):
+        """
+        Removes any cached _TokenState for the current client_id. Call this
+        after the token exchange ends to get rid of unnecessary state.
+        """
+        if self.client_id in self.server.token_state:
+            del self.server.token_state[self.client_id]
+
+    def authorization(self) -> JsonObject:
+        uri = "https://example.com/"
+        if self._alt_issuer:
+            uri = "https://example.org/"
+
+        resp = {
+            "device_code": "postgres",
+            "user_code": "postgresuser",
+            self._uri_spelling: uri,
+            "expires_in": 5,
+            **self._response_padding,
+        }
+
+        interval = self._interval
+        if interval is not None:
+            resp["interval"] = interval
+            self._token_state.min_delay = interval
+        else:
+            self._token_state.min_delay = 5  # default
+
+        # Check the scope.
+        if "scope" in self._params:
+            assert self._params["scope"][0], "empty scopes should be omitted"
+
+        return resp
+
+    def token(self) -> JsonObject:
+        if err := self._get_param("error_code", None):
+            self._response_code = self._get_param("error_status", 400)
+
+            resp = {"error": err}
+            if desc := self._get_param("error_desc", ""):
+                resp["error_description"] = desc
+
+            return resp
+
+        if self._should_modify() and "retries" in self._test_params:
+            retries = self._test_params["retries"]
+
+            # Check to make sure the token interval is being respected.
+            now = time.monotonic()
+            if self._token_state.last_try is not None:
+                delay = now - self._token_state.last_try
+                assert (
+                    delay > self._token_state.min_delay
+                ), f"client waited only {delay} seconds between token requests (expected {self._token_state.min_delay})"
+
+            self._token_state.last_try = now
+
+            # If we haven't reached the required number of retries yet, return a
+            # "pending" response.
+            if self._token_state.retries < retries:
+                self._token_state.retries += 1
+
+                self._response_code = 400
+                return {"error": self._retry_code}
+
+        # Clean up any retry tracking state now that the exchange is ending.
+        self._remove_token_state()
+
+        return {
+            "access_token": self._access_token,
+            "token_type": "bearer",
+            **self._response_padding,
+        }
+
+
+def main():
+    """
+    Starts the authorization server on localhost. The ephemeral port in use will
+    be printed to stdout.
+    """
+
+    s = http.server.HTTPServer(("127.0.0.1", 0), OAuthHandler)
+
+    # Attach a "cache" dictionary to the server to allow the OAuthHandlers to
+    # track state across token requests. The use of defaultdict ensures that new
+    # entries will be created automatically.
+    class _TokenState:
+        retries = 0
+        min_delay = None
+        last_try = None
+
+    s.token_state = defaultdict(_TokenState)
+
+    # Give the parent the port number to contact (this is also the signal that
+    # we're ready to receive requests).
+    port = s.socket.getsockname()[1]
+    print(port)
+
+    # stdout is closed to allow the parent to just "read to the end".
+    stdout = sys.stdout.fileno()
+    sys.stdout.close()
+    os.close(stdout)
+
+    s.serve_forever()  # we expect our parent to send a termination signal
+
+
+if __name__ == "__main__":
+    main()
diff --git a/src/test/modules/oauth_validator/validator.c b/src/test/modules/oauth_validator/validator.c
new file mode 100644
index 00000000000..b2e5d182e1b
--- /dev/null
+++ b/src/test/modules/oauth_validator/validator.c
@@ -0,0 +1,143 @@
+/*-------------------------------------------------------------------------
+ *
+ * validator.c
+ *	  Test module for serverside OAuth token validation callbacks
+ *
+ * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/test/modules/oauth_validator/validator.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "fmgr.h"
+#include "libpq/oauth.h"
+#include "miscadmin.h"
+#include "utils/guc.h"
+#include "utils/memutils.h"
+
+PG_MODULE_MAGIC;
+
+static void validator_startup(ValidatorModuleState *state);
+static void validator_shutdown(ValidatorModuleState *state);
+static bool validate_token(const ValidatorModuleState *state,
+						   const char *token,
+						   const char *role,
+						   ValidatorModuleResult *result);
+
+/* Callback implementations (exercise all three) */
+static const OAuthValidatorCallbacks validator_callbacks = {
+	PG_OAUTH_VALIDATOR_MAGIC,
+
+	.startup_cb = validator_startup,
+	.shutdown_cb = validator_shutdown,
+	.validate_cb = validate_token
+};
+
+/* GUCs */
+static char *authn_id = NULL;
+static bool authorize_tokens = true;
+
+/*---
+ * Extension entry point. Sets up GUCs for use by tests:
+ *
+ * - oauth_validator.authn_id	Sets the user identifier to return during token
+ *								validation. Defaults to the username in the
+ *								startup packet.
+ *
+ * - oauth_validator.authorize_tokens
+ *								Sets whether to successfully validate incoming
+ *								tokens. Defaults to true.
+ */
+void
+_PG_init(void)
+{
+	DefineCustomStringVariable("oauth_validator.authn_id",
+							   "Authenticated identity to use for future connections",
+							   NULL,
+							   &authn_id,
+							   NULL,
+							   PGC_SIGHUP,
+							   0,
+							   NULL, NULL, NULL);
+	DefineCustomBoolVariable("oauth_validator.authorize_tokens",
+							 "Should tokens be marked valid?",
+							 NULL,
+							 &authorize_tokens,
+							 true,
+							 PGC_SIGHUP,
+							 0,
+							 NULL, NULL, NULL);
+
+	MarkGUCPrefixReserved("oauth_validator");
+}
+
+/*
+ * Validator module entry point.
+ */
+const OAuthValidatorCallbacks *
+_PG_oauth_validator_module_init(void)
+{
+	return &validator_callbacks;
+}
+
+#define PRIVATE_COOKIE ((void *) 13579)
+
+/*
+ * Startup callback, to set up private data for the validator.
+ */
+static void
+validator_startup(ValidatorModuleState *state)
+{
+	/*
+	 * Make sure the server is correctly setting sversion. (Real modules
+	 * should not do this; it would defeat upgrade compatibility.)
+	 */
+	if (state->sversion != PG_VERSION_NUM)
+		elog(ERROR, "oauth_validator: sversion set to %d", state->sversion);
+
+	state->private_data = PRIVATE_COOKIE;
+}
+
+/*
+ * Shutdown callback, to tear down the validator.
+ */
+static void
+validator_shutdown(ValidatorModuleState *state)
+{
+	/* Check to make sure our private state still exists. */
+	if (state->private_data != PRIVATE_COOKIE)
+		elog(PANIC, "oauth_validator: private state cookie changed to %p in shutdown",
+			 state->private_data);
+}
+
+/*
+ * Validator implementation. Logs the incoming data and authorizes the token by
+ * default; the behavior can be modified via the module's GUC settings.
+ */
+static bool
+validate_token(const ValidatorModuleState *state,
+			   const char *token, const char *role,
+			   ValidatorModuleResult *res)
+{
+	/* Check to make sure our private state still exists. */
+	if (state->private_data != PRIVATE_COOKIE)
+		elog(ERROR, "oauth_validator: private state cookie changed to %p in validate",
+			 state->private_data);
+
+	elog(LOG, "oauth_validator: token=\"%s\", role=\"%s\"", token, role);
+	elog(LOG, "oauth_validator: issuer=\"%s\", scope=\"%s\"",
+		 MyProcPort->hba->oauth_issuer,
+		 MyProcPort->hba->oauth_scope);
+
+	res->authorized = authorize_tokens;
+	if (authn_id)
+		res->authn_id = pstrdup(authn_id);
+	else
+		res->authn_id = pstrdup(role);
+
+	return true;
+}
diff --git a/src/test/perl/PostgreSQL/Test/Cluster.pm b/src/test/perl/PostgreSQL/Test/Cluster.pm
index f521ad0b12f..ab7d7452ede 100644
--- a/src/test/perl/PostgreSQL/Test/Cluster.pm
+++ b/src/test/perl/PostgreSQL/Test/Cluster.pm
@@ -2515,6 +2515,11 @@ instead of the default.
 
 If this regular expression is set, matches it with the output generated.
 
+=item expected_stderr => B<value>
+
+If this regular expression is set, matches it against the standard error
+stream; otherwise the stderr must be empty.
+
 =item log_like => [ qr/required message/ ]
 
 =item log_unlike => [ qr/prohibited message/ ]
@@ -2558,7 +2563,20 @@ sub connect_ok
 		like($stdout, $params{expected_stdout}, "$test_name: stdout matches");
 	}
 
-	is($stderr, "", "$test_name: no stderr");
+	if (defined($params{expected_stderr}))
+	{
+		if (like($stderr, $params{expected_stderr}, "$test_name: stderr matches")
+			&& ($ret != 0))
+		{
+			# In this case (failing test but matching stderr) we'll have
+			# swallowed the output needed to debug. Put it back into the logs.
+			diag("$test_name: full stderr:\n" . $stderr);
+		}
+	}
+	else
+	{
+		is($stderr, "", "$test_name: no stderr");
+	}
 
 	$self->log_check($test_name, $log_location, %params);
 }
diff --git a/src/tools/pgindent/pgindent b/src/tools/pgindent/pgindent
index d8acce7e929..7dccf4614aa 100755
--- a/src/tools/pgindent/pgindent
+++ b/src/tools/pgindent/pgindent
@@ -242,6 +242,14 @@ sub pre_indent
 	# Protect wrapping in CATALOG()
 	$source =~ s!^(CATALOG\(.*)$!/*$1*/!gm;
 
+	# Treat a CURL_IGNORE_DEPRECATION() as braces for the purposes of
+	# indentation. (The recursive regex comes from the perlre documentation; it
+	# matches balanced parentheses as group $1 and the contents as group $2.)
+	my $curlopen = '{ /* CURL_IGNORE_DEPRECATION */';
+	my $curlclose = '} /* CURL_IGNORE_DEPRECATION */';
+	$source =~
+	  s!^[ \t]+CURL_IGNORE_DEPRECATION(\(((?:(?>[^()]+)|(?1))*)\))!$curlopen$2$curlclose!gms;
+
 	return $source;
 }
 
@@ -256,6 +264,12 @@ sub post_indent
 	$source =~ s!^/\* Open extern "C" \*/$!{!gm;
 	$source =~ s!^/\* Close extern "C" \*/$!}!gm;
 
+	# Restore the CURL_IGNORE_DEPRECATION() macro, keeping in mind that our
+	# markers may have been re-indented.
+	$source =~
+	  s!{[ \t]+/\* CURL_IGNORE_DEPRECATION \*/!CURL_IGNORE_DEPRECATION(!gm;
+	$source =~ s!}[ \t]+/\* CURL_IGNORE_DEPRECATION \*/!)!gm;
+
 	## Comments
 
 	# Undo change of dash-protected block comments
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index b6c170ac249..ed8ef8ddc89 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -371,6 +371,9 @@ CState
 CTECycleClause
 CTEMaterialize
 CTESearchClause
+CURL
+CURLM
+CURLoption
 CV
 CachedExpression
 CachedPlan
@@ -1724,6 +1727,7 @@ NumericDigit
 NumericSortSupport
 NumericSumAccum
 NumericVar
+OAuthValidatorCallbacks
 OM_uint32
 OP
 OSAPerGroupState
@@ -1832,6 +1836,7 @@ PGVerbosity
 PG_Locale_Strategy
 PG_Lock_Status
 PG_init_t
+PGauthData
 PGcancel
 PGcancelConn
 PGcmdQueueEntry
@@ -1839,7 +1844,9 @@ PGconn
 PGdataValue
 PGlobjfuncs
 PGnotify
+PGoauthBearerRequest
 PGpipelineStatus
+PGpromptOAuthDevice
 PGresAttDesc
 PGresAttValue
 PGresParamDesc
@@ -1952,6 +1959,7 @@ PQArgBlock
 PQEnvironmentOption
 PQExpBuffer
 PQExpBufferData
+PQauthDataHook_type
 PQcommMethods
 PQconninfoOption
 PQnoticeProcessor
@@ -3091,6 +3099,8 @@ VacuumRelation
 VacuumStmt
 ValidIOData
 ValidateIndexState
+ValidatorModuleState
+ValidatorModuleResult
 ValuesScan
 ValuesScanState
 Var
@@ -3488,6 +3498,7 @@ explain_get_index_name_hook_type
 f_smgr
 fasthash_state
 fd_set
+fe_oauth_state
 fe_scram_state
 fe_scram_state_enum
 fetch_range_request
-- 
2.34.1



  [application/octet-stream] v50-0002-fixup-Add-OAUTHBEARER-SASL-mechanism.patch (9.1K, ../../CAOYmi+=zgJinCoqdQ70fvKmvyrLekd9-supkuj57Poa1FmX8bw@mail.gmail.com/3-v50-0002-fixup-Add-OAUTHBEARER-SASL-mechanism.patch)
  download | inline diff:
From eee0ee6d369e6baba327cd73fb7238eb0e1cb881 Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Fri, 14 Feb 2025 16:50:45 -0800
Subject: [PATCH v50 2/4] fixup! Add OAUTHBEARER SASL mechanism

---
 doc/src/sgml/client-auth.sgml                 |  2 +-
 doc/src/sgml/libpq.sgml                       |  2 +-
 doc/src/sgml/oauth-validators.sgml            |  4 ++--
 src/backend/libpq/auth-oauth.c                |  8 ++++----
 src/interfaces/libpq/fe-auth-oauth-curl.c     | 19 +++++++++++++------
 src/test/modules/oauth_validator/Makefile     |  2 +-
 .../modules/oauth_validator/magic_validator.c |  2 +-
 .../modules/oauth_validator/t/001_server.pl   |  6 ++++--
 8 files changed, 27 insertions(+), 18 deletions(-)

diff --git a/doc/src/sgml/client-auth.sgml b/doc/src/sgml/client-auth.sgml
index 6fc0da57f1b..832b616a7bb 100644
--- a/doc/src/sgml/client-auth.sgml
+++ b/doc/src/sgml/client-auth.sgml
@@ -2409,7 +2409,7 @@ host ... radius radiusservers="server1,server2" radiussecrets="""secret one"",""
       <listitem>
        <para>
         The organization, product vendor, or other entity which develops and/or
-        administers the OAuth resource servers and clients for a given application.
+        administers the OAuth authorization servers and clients for a given application.
         Different providers typically choose different implementation details
         for their OAuth systems; a client of one provider is not generally
         guaranteed to have access to the servers of another.
diff --git a/doc/src/sgml/libpq.sgml b/doc/src/sgml/libpq.sgml
index ca84226755d..ddb3596df83 100644
--- a/doc/src/sgml/libpq.sgml
+++ b/doc/src/sgml/libpq.sgml
@@ -10527,7 +10527,7 @@ int PQisthreadsafe();
   </para>
 
   <para>
-   Similarly, if you are using <productname></productname>Curl</productname> inside your application,
+   Similarly, if you are using <productname>Curl</productname> inside your application,
    <emphasis>and</emphasis> you do not already
    <ulink url="https://curl.se/libcurl/c/curl_global_init.html">initialize
    libcurl globally</ulink> before starting new threads, you will need to
diff --git a/doc/src/sgml/oauth-validators.sgml b/doc/src/sgml/oauth-validators.sgml
index e9d28d3daea..356f11d3bd8 100644
--- a/doc/src/sgml/oauth-validators.sgml
+++ b/doc/src/sgml/oauth-validators.sgml
@@ -21,7 +21,7 @@
  <warning>
   <para>
    Since a misbehaving validator might let unauthorized users into the database,
-   validating the correctness of the implementation is critical. See
+   correct implementation is crucial for server safety. See
    <xref linkend="oauth-validator-design"/> for design considerations.
   </para>
  </warning>
@@ -357,7 +357,7 @@ typedef bool (*ValidatorValidateCB) (const ValidatorModuleState *state,
 </programlisting>
 
     <replaceable>token</replaceable> will contain the bearer token to validate.
-    <application>libpq</application> has ensured that the token is well-formed syntactically, but no
+    <application>PostgreSQL</application> has ensured that the token is well-formed syntactically, but no
     other validation has been performed.  <replaceable>role</replaceable> will
     contain the role the user has requested to log in as.  The callback must
     set output parameters in the <literal>result</literal> struct, which is
diff --git a/src/backend/libpq/auth-oauth.c b/src/backend/libpq/auth-oauth.c
index 830f2002683..27f7af7be00 100644
--- a/src/backend/libpq/auth-oauth.c
+++ b/src/backend/libpq/auth-oauth.c
@@ -741,9 +741,9 @@ load_validator_library(const char *libname)
 	MemoryContextCallback *mcb;
 
 	/*
-	 * Thre presence, and validity, of libname has already been established by
-	 * check_oauth_validator so we don't need to perform more than Assert level
-	 * checking here.
+	 * The presence, and validity, of libname has already been established by
+	 * check_oauth_validator so we don't need to perform more than Assert
+	 * level checking here.
 	 */
 	Assert(libname && *libname);
 
@@ -781,7 +781,7 @@ load_validator_library(const char *libname)
 	 */
 	if (ValidatorCallbacks->validate_cb == NULL)
 		ereport(ERROR,
-				errmsg("%s module \"%s\" must define the symbol %s",
+				errmsg("%s module \"%s\" must provide a %s callback",
 					   "OAuth validator", libname, "validate_cb"));
 
 	/* Allocate memory for validator library private state data */
diff --git a/src/interfaces/libpq/fe-auth-oauth-curl.c b/src/interfaces/libpq/fe-auth-oauth-curl.c
index c9aa51b1007..a80e2047bb7 100644
--- a/src/interfaces/libpq/fe-auth-oauth-curl.c
+++ b/src/interfaces/libpq/fe-auth-oauth-curl.c
@@ -211,7 +211,7 @@ struct async_ctx
 	 * something like the following, with errctx and/or curl_err omitted when
 	 * absent:
 	 *
-	 *     connection to server ... failed: errctx: errbuf (curl_err)
+	 *     connection to server ... failed: errctx: errbuf (libcurl: curl_err)
 	 */
 	const char *errctx;			/* not freed; must point to static allocation */
 	PQExpBufferData errbuf;
@@ -930,7 +930,7 @@ parse_interval(struct async_ctx *actx, const char *interval_str)
  * Similar to parse_interval, but we have even fewer requirements for reasonable
  * values since we don't use the expiration time directly (it's passed to the
  * PQAUTHDATA_PROMPT_OAUTH_DEVICE hook, in case the application wants to do
- * something with it). We simply round and clamp to int range.
+ * something with it). We simply round down and clamp to int range.
  */
 static int
 parse_expires_in(struct async_ctx *actx, const char *expires_in_str)
@@ -938,7 +938,7 @@ parse_expires_in(struct async_ctx *actx, const char *expires_in_str)
 	double		parsed;
 
 	parsed = parse_json_number(expires_in_str);
-	parsed = round(parsed);
+	parsed = floor(parsed);
 
 	if (parsed >= INT_MAX)
 		return INT_MAX;
@@ -968,7 +968,8 @@ parse_device_authz(struct async_ctx *actx, struct device_authz *authz)
 
 		/*
 		 * There is no evidence of verification_uri_complete being spelled
-		 * with "url" instead with any service provider, so only support "uri".
+		 * with "url" instead with any service provider, so only support
+		 * "uri".
 		 */
 		{"verification_uri_complete", JSON_TOKEN_STRING, {&authz->verification_uri_complete}, OPTIONAL},
 		{"interval", JSON_TOKEN_NUMBER, {&authz->interval_str}, OPTIONAL},
@@ -1226,6 +1227,8 @@ register_socket(CURL *curl, curl_socket_t socket, int what, void *ctx,
 
 		return -1;
 	}
+
+	return 0;
 #endif
 #ifdef HAVE_SYS_EVENT_H
 	struct async_ctx *actx = ctx;
@@ -1307,9 +1310,12 @@ register_socket(CURL *curl, curl_socket_t socket, int what, void *ctx,
 			return -1;
 		}
 	}
-#endif
 
 	return 0;
+#endif
+
+	actx_error(actx, "libpq does not support multiplexer sockets on this platform");
+	return -1;
 }
 
 /*
@@ -2808,7 +2814,8 @@ error_return:
 	{
 		size_t		len;
 
-		appendPQExpBuffer(&conn->errorMessage, " (%s)", actx->curl_err);
+		appendPQExpBuffer(&conn->errorMessage,
+						  " (libcurl: %s)", actx->curl_err);
 
 		/* Sometimes libcurl adds a newline to the error buffer. :( */
 		len = conn->errorMessage.len;
diff --git a/src/test/modules/oauth_validator/Makefile b/src/test/modules/oauth_validator/Makefile
index bbd2a98023b..05b9f06ed73 100644
--- a/src/test/modules/oauth_validator/Makefile
+++ b/src/test/modules/oauth_validator/Makefile
@@ -9,7 +9,7 @@
 #
 #-------------------------------------------------------------------------
 
-MODULES = validator fail_validator
+MODULES = validator fail_validator magic_validator
 PGFILEDESC = "validator - test OAuth validator module"
 
 PROGRAM = oauth_hook_client
diff --git a/src/test/modules/oauth_validator/magic_validator.c b/src/test/modules/oauth_validator/magic_validator.c
index 5ce68cdf405..9dc55b602e3 100644
--- a/src/test/modules/oauth_validator/magic_validator.c
+++ b/src/test/modules/oauth_validator/magic_validator.c
@@ -8,7 +8,7 @@
  * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
  * Portions Copyright (c) 1994, Regents of the University of California
  *
- * src/test/modules/oauth_validator/fail_validator.c
+ * src/test/modules/oauth_validator/magic_validator.c
  *
  *-------------------------------------------------------------------------
  */
diff --git a/src/test/modules/oauth_validator/t/001_server.pl b/src/test/modules/oauth_validator/t/001_server.pl
index dada89e95cc..6fa59fbeb25 100644
--- a/src/test/modules/oauth_validator/t/001_server.pl
+++ b/src/test/modules/oauth_validator/t/001_server.pl
@@ -570,7 +570,7 @@ $node->connect_fails(
 	expected_stderr => qr/FATAL:\s+fail_validator: sentinel error/);
 
 #
-# Test ABI compatability magic marker
+# Test ABI compatibility magic marker
 #
 $node->append_conf('postgresql.conf',
 	"oauth_validator_libraries = 'magic_validator'\n");
@@ -586,7 +586,9 @@ $log_start = $node->wait_for_log(qr/ready to accept connections/, $log_start);
 $node->connect_fails(
 	"user=test dbname=postgres oauth_issuer=$issuer/.well-known/oauth-authorization-server/alternate oauth_client_id=f02c6361-0636",
 	"magic_validator is used for $user",
-	expected_stderr => qr/FATAL:\s+OAuth validator module "magic_validator": magic number mismatch/);
+	expected_stderr =>
+	  qr/FATAL:\s+OAuth validator module "magic_validator": magic number mismatch/
+);
 $node->stop;
 
 done_testing();
-- 
2.34.1



  [application/octet-stream] v50-0003-fixup-changes-to-sanitize_char-et-al.patch (4.2K, ../../CAOYmi+=zgJinCoqdQ70fvKmvyrLekd9-supkuj57Poa1FmX8bw@mail.gmail.com/4-v50-0003-fixup-changes-to-sanitize_char-et-al.patch)
  download | inline diff:
From bcfb09a15e3b46a7b2f3a19324be9b5f8d81740c Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Fri, 14 Feb 2025 14:36:54 -0800
Subject: [PATCH v50 3/4] fixup! changes to sanitize_char et al

---
 src/backend/libpq/auth-oauth.c | 55 +++++++++++++++++++++++-----------
 1 file changed, 37 insertions(+), 18 deletions(-)

diff --git a/src/backend/libpq/auth-oauth.c b/src/backend/libpq/auth-oauth.c
index 27f7af7be00..e4aa6f357b5 100644
--- a/src/backend/libpq/auth-oauth.c
+++ b/src/backend/libpq/auth-oauth.c
@@ -70,7 +70,7 @@ struct oauth_ctx
 	const char *scope;
 };
 
-static char *sanitize_char(char c);
+static void sanitize_char(char c, char *buf, size_t buflen);
 static char *parse_kvpairs_for_auth(char **input);
 static void generate_error_response(struct oauth_ctx *ctx, char **output, int *outputlen);
 static bool validate(Port *port, const char *auth);
@@ -139,6 +139,7 @@ oauth_exchange(void *opaq, const char *input, int inputlen,
 	char		cbind_flag;
 	char	   *auth;
 	int			status;
+	char		errmsgbuf[5];
 
 	struct oauth_ctx *ctx = opaq;
 
@@ -162,6 +163,7 @@ oauth_exchange(void *opaq, const char *input, int inputlen,
 
 	/*
 	 * Check that the input length agrees with the string length of the input.
+	 * Possible reasons for discrepancies include embedded nulls in the string.
 	 */
 	if (inputlen == 0)
 		ereport(ERROR,
@@ -223,22 +225,29 @@ oauth_exchange(void *opaq, const char *input, int inputlen,
 
 		case 'y':				/* fall through */
 		case 'n':
-			p++;
+			if (!*(++p))
+				goto endofmessage;
+
 			if (*p != ',')
+			{
+				sanitize_char(*p, errmsgbuf, sizeof(errmsgbuf));
 				ereport(ERROR,
 						errcode(ERRCODE_PROTOCOL_VIOLATION),
 						errmsg("malformed OAUTHBEARER message"),
 						errdetail("Comma expected, but found character \"%s\".",
-								  sanitize_char(*p)));
-			p++;
+								  errmsgbuf));
+			}
+			if (!*(++p))
+				goto endofmessage;
 			break;
 
 		default:
+			sanitize_char(*p, errmsgbuf, sizeof(errmsgbuf));
 			ereport(ERROR,
 					errcode(ERRCODE_PROTOCOL_VIOLATION),
 					errmsg("malformed OAUTHBEARER message"),
 					errdetail("Unexpected channel-binding flag \"%s\".",
-							  sanitize_char(cbind_flag)));
+							  errmsgbuf));
 	}
 
 	/*
@@ -249,21 +258,29 @@ oauth_exchange(void *opaq, const char *input, int inputlen,
 				errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
 				errmsg("client uses authorization identity, but it is not supported"));
 	if (*p != ',')
+	{
+		sanitize_char(*p, errmsgbuf, sizeof(errmsgbuf));
 		ereport(ERROR,
 				errcode(ERRCODE_PROTOCOL_VIOLATION),
 				errmsg("malformed OAUTHBEARER message"),
 				errdetail("Unexpected attribute \"%s\" in client-first-message.",
-						  sanitize_char(*p)));
-	p++;
+						  errmsgbuf));
+	}
+	if (!*(++p))
+		goto endofmessage;
 
 	/* All remaining fields are separated by the RFC's kvsep (\x01). */
 	if (*p != KVSEP)
+	{
+		sanitize_char(*p, errmsgbuf, sizeof(errmsgbuf));
 		ereport(ERROR,
 				errcode(ERRCODE_PROTOCOL_VIOLATION),
 				errmsg("malformed OAUTHBEARER message"),
 				errdetail("Key-value separator expected, but found character \"%s\".",
-						  sanitize_char(*p)));
-	p++;
+						  errmsgbuf));
+	}
+	if (!*(++p))
+		goto endofmessage;
 
 	auth = parse_kvpairs_for_auth(&p);
 	if (!auth)
@@ -296,6 +313,13 @@ oauth_exchange(void *opaq, const char *input, int inputlen,
 	explicit_bzero(input_copy, inputlen);
 
 	return status;
+
+endofmessage:
+	explicit_bzero(input_copy, inputlen);
+	ereport(ERROR,
+			errcode(ERRCODE_PROTOCOL_VIOLATION),
+			errmsg("malformed OAUTHBEARER message"));
+	pg_unreachable();
 }
 
 /*
@@ -303,19 +327,14 @@ oauth_exchange(void *opaq, const char *input, int inputlen,
  *
  * If it's a printable ASCII character, print it as a single character.
  * otherwise, print it in hex.
- *
- * The returned pointer points to a static buffer.
  */
-static char *
-sanitize_char(char c)
+static void
+sanitize_char(char c, char *buf, size_t buflen)
 {
-	static char buf[5];
-
 	if (c >= 0x21 && c <= 0x7E)
-		snprintf(buf, sizeof(buf), "'%c'", c);
+		snprintf(buf, buflen, "'%c'", c);
 	else
-		snprintf(buf, sizeof(buf), "0x%02x", (unsigned char) c);
-	return buf;
+		snprintf(buf, buflen, "0x%02x", (unsigned char) c);
 }
 
 /*
-- 
2.34.1



  [application/octet-stream] v50-0004-XXX-fix-libcurl-link-error.patch (1.1K, ../../CAOYmi+=zgJinCoqdQ70fvKmvyrLekd9-supkuj57Poa1FmX8bw@mail.gmail.com/5-v50-0004-XXX-fix-libcurl-link-error.patch)
  download | inline diff:
From 99424d85a46bcfc8f3b7ac8cb463c27eb3d7e653 Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Mon, 13 Jan 2025 12:31:59 -0800
Subject: [PATCH v50 4/4] XXX fix libcurl link error

The ftp/curl port appears to be missing a minimum version dependency on
libssh2, so the following starts showing up after upgrading to curl
8.11.1_1:

    libcurl.so.4: Undefined symbol "libssh2_session_callback_set2"

But 13.3 is EOL, so it's not clear if anyone would be interested in a
bug report, and a FreeBSD 14 Cirrus image is in progress. Hack past it
for now.
---
 .cirrus.tasks.yml | 1 +
 1 file changed, 1 insertion(+)

diff --git a/.cirrus.tasks.yml b/.cirrus.tasks.yml
index 2f5f5ef21a8..91b51142d2e 100644
--- a/.cirrus.tasks.yml
+++ b/.cirrus.tasks.yml
@@ -168,6 +168,7 @@ task:
     sysctl kern.corefile='/tmp/cores/%N.%P.core'
   setup_additional_packages_script: |
     pkg install -y curl
+    pkg upgrade -y libssh2 # XXX shouldn't be necessary. revisit w/ FreeBSD 14
 
   # NB: Intentionally build without -Dllvm. The freebsd image size is already
   # large enough to make VM startup slow, and even without llvm freebsd
-- 
2.34.1



^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-02-17 12:03  Daniel Gustafsson <[email protected]>
  parent: Jacob Champion <[email protected]>
  0 siblings, 1 reply; 101+ messages in thread

From: Daniel Gustafsson @ 2025-02-17 12:03 UTC (permalink / raw)
  To: Jacob Champion <[email protected]>; +Cc: Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

> On 15 Feb 2025, at 02:14, Jacob Champion <[email protected]> wrote:

>> +   Since a misbehaving validator might let unauthorized users into the database,
>> +   correct implementation is critical. See
>> "Don't make any bugs" isn't very helpful advice =) Expanded on it slightly.
> 
> Hmm, I think the overloading of "validate" in the replacement text
> could be confusing. I guess my point is less "don't write bugs" and
> more "a bug here has extreme impact"? I've taken another shot at it;
> see what you think.

I'm not sure we're at the right wording still.  I have a feeling this topic is
worth a longer paragraph describing the potential severity of various error
conditions, but I don't think that needs to go in now, we can iterate on that
over time as well.

>> +    The server has ensured that the token is well-formed syntactically, but no
>> "server" is an overloaded nomenclature here, perhaps using libpq instead to
>> clearly indicate that it's postgres and not an OAuth server.
> 
> I've replaced this with "PostgreSQL" to match up with Peter's earlier
> feedback (we were using "libpq" to describe the backend and he wanted
> to avoid that).

Ah yes, much better.

>> +sanitize_char(char c)
>> +{
>> +       static char buf[5];
>> With the multithreading work on the horizon we should probably avoid static
>> variables like these to not create work for our future selves?  The code isn't
>> as neat when passing in a buffer/length but it avoids the need for a static or
>> threadlocal variable. Or am I overthinking this?
> 
> This is the only part of the feedback patch that I'm not a fan of,
> mostly because it begins to diverge heavily from the SCRAM code it
> copied from. I don't disagree with the goal of getting rid of the
> static buffers, but I would like to see them modified at the same time
> so that we can refactor easily if/when a third SASL mechanism shows
> up. (Maybe with a psprintf() rather than buffers?)

Fair enough, I can get behind that.

>> +                       p++;
>> +                       if (*p != ',')
>> If the SASL exchange, are we certain that a rogue client cannot inject a
>> message which trips us past the end of string?  Should we be doublecheck when
>> advancing p across the message?
> 
> The existing != checks will bail out if they get to the end of the
> string. It relies on byte-at-a-time advancement for safety, as well as
> the SASL code higher in the stack that ensures that the input buffer
> is always null terminated. (SCRAM relies on that too.) If we ever
> jumped farther than a byte, we'd need stronger checks, but at the
> moment I don't think this change helps us.

Thanks for clarifying.

>> In load_validator_library we don't explicitly verify that the required callback
>> is defined in the returned structure, which seems like a cheap enough belts and
>> suspenders level check.
> 
> Yeah, there's a later check at time of use, but it's not as
> user-friendly. I've adjusted the new error message to make it a bit
> closer to the logical plugin wording.

-    errmsg("%s module \"%s\" must define the symbol %s",
+    errmsg("%s module \"%s\" must provide a %s callback",

My rationale for picking the former message was that it's same as we have
earlier in the file, so it didn't add more translator work for (ideally) rarely
used errors.

That being said, I agree that should probably align these messages with the
counterparts for archive modules and logical plugins, which currently use the
following:

    errmsg("archive modules have to define the symbol %s", "_PG_archive_module_init")
    errmsg("archive modules must register an archive callback")
    elog(ERROR, "output plugins have to declare the _PG_output_plugin_init symbol");
    elog(ERROR, "output plugins have to register a begin callback");

It's a bit surprising to me that we use elog() for output plugins, while these
errors should be rare they can be triggered by third-party code so it seems
more appropriate to use ereport() IMHO.  Given that these are so similar we
should be able to reduce translator burden by providing more or less just two
messages.

Since this will be reaching into other parts of the code, it should be its own
patch though, so for now let's go with what you proposed and we can revisit this.

>> +       if (parsed < 1)
>> +               return actx->debugging ? 0 : 1;
>> Is 1 second a sane lower bound on interval for all situations?  I'm starting to
>> wonder if we should be more conservative here, or even make it configurable in
>> some way? The default if not set of 5 seconds is quite a lot higher than 1.
> 
> Mmm, maybe it should be made configurable, but one second seems like a
> long time from a CPU perspective. Maybe it would be applicable to
> embedded clients? But only if some provider out there actually starts
> using smaller intervals than their clients can stand... Should we wait
> to hear from someone who is interested in configuring it?

I indeed think we should await feedback, making it configurable isn't exactly
free so I hesitate to do it if nobody wants it.

The attached v51 squashes your commits together, discarding the changes
discussed here, and takes a stab at a commit message for these as this is
getting very close to be able to go in.  There are no additional changes.

--
Daniel Gustafsson



Attachments:

  [application/octet-stream] v51-0001-Add-support-for-OAUTHBEARER-SASL-mechanism.patch (324.1K, ../../[email protected]/2-v51-0001-Add-support-for-OAUTHBEARER-SASL-mechanism.patch)
  download | inline diff:
From 7cf50dabc6a33462cb9fe28841af8d02f93b2692 Mon Sep 17 00:00:00 2001
From: Daniel Gustafsson <[email protected]>
Date: Mon, 17 Feb 2025 12:57:34 +0100
Subject: [PATCH v51 1/2] Add support for OAUTHBEARER SASL mechanism

This commit implements OAUTHBEARER, RFC 7628, and OAuth 2.0 Device
Authorization Grants, RFC 8628.  In order to use this there is a
new pg_hba auth method called oauth.  When speaking to a OAuth-
enabled server, it looks a bit like this:

  $ psql 'host=example.org oauth_issuer=... oauth_client_id=...'
  Visit https://oauth.example.org/login and enter the code: FPQ2-M4BG

Device authorization is currently the only supported flow so the
OAuth issuer must support that in order for users to authenticate.
Third-party clients may however extend this and provide their own
flows.

In order for validation to happen server side a new framework for
plugging in OAuth validation modules is added.  As validation is
implementation specific, with no default specified in the standard,
postgres cannot ship with one built-in.  Each pg_hba entry can
specify one, or more, validators or be left blank for the validator
installed as default.

This adds a requirement on libucurl for the client side support,
which is optional to build, but the server side has no additional
build requirements.  In order to run the tests, Python is required
as this adds a https server written in Python.  Tests are gated
behind PG_TEST_EXTRA as they open ports.

This patch has been a multi-year project with many contributors
involved on review and in-depth discussion: Michael Paquier,
Heikki Linnakangas, Zhihong Yu, Mahendrakar s, Andrey Chudnovsky
and Stephen Frost to name a few.  While Jacob Champion is the main
author there have been some levels of hacking by others. Daniel
Gustafsson contributed the validation module and various bits and
pieces; Thomas Munro wrote the client side support for kqueue.

Author: Jacob Champion <[email protected]>
Co-authored-by: Daniel Gustafsson <[email protected]>
Co-authored-by: Thomas Munro <[email protected]>
Reviewed-by: Daniel Gustafsson <[email protected]>
Reviewed-by: Peter Eisentraut <[email protected]>
Reviewed-by: Antonin Houska <[email protected]>
Reviewed-by: Kashif Zeeshan <[email protected]>
Discussion: https://postgr.es/m/[email protected]
---
 .cirrus.tasks.yml                             |   15 +-
 config/programs.m4                            |   65 +
 configure                                     |  332 ++
 configure.ac                                  |   41 +
 doc/src/sgml/client-auth.sgml                 |  252 ++
 doc/src/sgml/config.sgml                      |   26 +
 doc/src/sgml/filelist.sgml                    |    1 +
 doc/src/sgml/installation.sgml                |   27 +
 doc/src/sgml/libpq.sgml                       |  445 +++
 doc/src/sgml/oauth-validators.sgml            |  414 +++
 doc/src/sgml/postgres.sgml                    |    1 +
 doc/src/sgml/protocol.sgml                    |  133 +-
 doc/src/sgml/regress.sgml                     |   10 +
 meson.build                                   |  100 +
 meson_options.txt                             |    3 +
 src/Makefile.global.in                        |    1 +
 src/backend/libpq/Makefile                    |    1 +
 src/backend/libpq/auth-oauth.c                |  894 +++++
 src/backend/libpq/auth.c                      |   10 +-
 src/backend/libpq/hba.c                       |   64 +-
 src/backend/libpq/meson.build                 |    1 +
 src/backend/libpq/pg_hba.conf.sample          |    4 +-
 src/backend/utils/adt/hbafuncs.c              |   19 +
 src/backend/utils/misc/guc_tables.c           |   12 +
 src/backend/utils/misc/postgresql.conf.sample |    3 +
 src/include/common/oauth-common.h             |   19 +
 src/include/libpq/auth.h                      |    1 +
 src/include/libpq/hba.h                       |    7 +-
 src/include/libpq/oauth.h                     |  101 +
 src/include/pg_config.h.in                    |    9 +
 src/interfaces/libpq/Makefile                 |   11 +-
 src/interfaces/libpq/exports.txt              |    3 +
 src/interfaces/libpq/fe-auth-oauth-curl.c     | 2883 +++++++++++++++++
 src/interfaces/libpq/fe-auth-oauth.c          | 1153 +++++++
 src/interfaces/libpq/fe-auth-oauth.h          |   45 +
 src/interfaces/libpq/fe-auth.c                |   36 +-
 src/interfaces/libpq/fe-auth.h                |    3 +
 src/interfaces/libpq/fe-connect.c             |   48 +-
 src/interfaces/libpq/libpq-fe.h               |   85 +
 src/interfaces/libpq/libpq-int.h              |   13 +-
 src/interfaces/libpq/meson.build              |    5 +
 src/makefiles/meson.build                     |    1 +
 src/test/authentication/t/001_password.pl     |    8 +-
 src/test/modules/Makefile                     |    1 +
 src/test/modules/meson.build                  |    1 +
 src/test/modules/oauth_validator/.gitignore   |    4 +
 src/test/modules/oauth_validator/Makefile     |   40 +
 src/test/modules/oauth_validator/README       |   13 +
 .../modules/oauth_validator/fail_validator.c  |   47 +
 .../modules/oauth_validator/magic_validator.c |   48 +
 src/test/modules/oauth_validator/meson.build  |   85 +
 .../oauth_validator/oauth_hook_client.c       |  293 ++
 .../modules/oauth_validator/t/001_server.pl   |  594 ++++
 .../modules/oauth_validator/t/002_client.pl   |  154 +
 .../modules/oauth_validator/t/OAuth/Server.pm |  140 +
 .../modules/oauth_validator/t/oauth_server.py |  391 +++
 src/test/modules/oauth_validator/validator.c  |  143 +
 src/test/perl/PostgreSQL/Test/Cluster.pm      |   20 +-
 src/tools/pgindent/pgindent                   |   14 +
 src/tools/pgindent/typedefs.list              |   11 +
 60 files changed, 9265 insertions(+), 39 deletions(-)
 create mode 100644 doc/src/sgml/oauth-validators.sgml
 create mode 100644 src/backend/libpq/auth-oauth.c
 create mode 100644 src/include/common/oauth-common.h
 create mode 100644 src/include/libpq/oauth.h
 create mode 100644 src/interfaces/libpq/fe-auth-oauth-curl.c
 create mode 100644 src/interfaces/libpq/fe-auth-oauth.c
 create mode 100644 src/interfaces/libpq/fe-auth-oauth.h
 create mode 100644 src/test/modules/oauth_validator/.gitignore
 create mode 100644 src/test/modules/oauth_validator/Makefile
 create mode 100644 src/test/modules/oauth_validator/README
 create mode 100644 src/test/modules/oauth_validator/fail_validator.c
 create mode 100644 src/test/modules/oauth_validator/magic_validator.c
 create mode 100644 src/test/modules/oauth_validator/meson.build
 create mode 100644 src/test/modules/oauth_validator/oauth_hook_client.c
 create mode 100644 src/test/modules/oauth_validator/t/001_server.pl
 create mode 100644 src/test/modules/oauth_validator/t/002_client.pl
 create mode 100644 src/test/modules/oauth_validator/t/OAuth/Server.pm
 create mode 100755 src/test/modules/oauth_validator/t/oauth_server.py
 create mode 100644 src/test/modules/oauth_validator/validator.c

diff --git a/.cirrus.tasks.yml b/.cirrus.tasks.yml
index fffa438cec1..2f5f5ef21a8 100644
--- a/.cirrus.tasks.yml
+++ b/.cirrus.tasks.yml
@@ -23,7 +23,7 @@ env:
   MTEST_ARGS: --print-errorlogs --no-rebuild -C build
   PGCTLTIMEOUT: 120 # avoids spurious failures during parallel tests
   TEMP_CONFIG: ${CIRRUS_WORKING_DIR}/src/tools/ci/pg_ci_base.conf
-  PG_TEST_EXTRA: kerberos ldap ssl libpq_encryption load_balance
+  PG_TEST_EXTRA: kerberos ldap ssl libpq_encryption load_balance oauth
 
 
 # What files to preserve in case tests fail
@@ -167,7 +167,7 @@ task:
     chown root:postgres /tmp/cores
     sysctl kern.corefile='/tmp/cores/%N.%P.core'
   setup_additional_packages_script: |
-    #pkg install -y ...
+    pkg install -y curl
 
   # NB: Intentionally build without -Dllvm. The freebsd image size is already
   # large enough to make VM startup slow, and even without llvm freebsd
@@ -329,6 +329,7 @@ LINUX_CONFIGURE_FEATURES: &LINUX_CONFIGURE_FEATURES >-
   --with-gssapi
   --with-icu
   --with-ldap
+  --with-libcurl
   --with-libxml
   --with-libxslt
   --with-llvm
@@ -422,8 +423,10 @@ task:
     EOF
 
   setup_additional_packages_script: |
-    #apt-get update
-    #DEBIAN_FRONTEND=noninteractive apt-get -y install ...
+    apt-get update
+    DEBIAN_FRONTEND=noninteractive apt-get -y install \
+      libcurl4-openssl-dev \
+      libcurl4-openssl-dev:i386 \
 
   matrix:
     - name: Linux - Debian Bookworm - Autoconf
@@ -799,8 +802,8 @@ task:
     folder: $CCACHE_DIR
 
   setup_additional_packages_script: |
-    #apt-get update
-    #DEBIAN_FRONTEND=noninteractive apt-get -y install ...
+    apt-get update
+    DEBIAN_FRONTEND=noninteractive apt-get -y install libcurl4-openssl-dev
 
   ###
   # Test that code can be built with gcc/clang without warnings
diff --git a/config/programs.m4 b/config/programs.m4
index 7b55c2664a6..061b13376ac 100644
--- a/config/programs.m4
+++ b/config/programs.m4
@@ -274,3 +274,68 @@ AC_DEFUN([PGAC_CHECK_STRIP],
   AC_SUBST(STRIP_STATIC_LIB)
   AC_SUBST(STRIP_SHARED_LIB)
 ])# PGAC_CHECK_STRIP
+
+
+
+# PGAC_CHECK_LIBCURL
+# ------------------
+# Check for required libraries and headers, and test to see whether the current
+# installation of libcurl is thread-safe.
+
+AC_DEFUN([PGAC_CHECK_LIBCURL],
+[
+  AC_CHECK_HEADER(curl/curl.h, [],
+				  [AC_MSG_ERROR([header file <curl/curl.h> is required for --with-libcurl])])
+  AC_CHECK_LIB(curl, curl_multi_init, [],
+			   [AC_MSG_ERROR([library 'curl' does not provide curl_multi_init])])
+
+  # Check to see whether the current platform supports threadsafe Curl
+  # initialization.
+  AC_CACHE_CHECK([for curl_global_init thread safety], [pgac_cv__libcurl_threadsafe_init],
+  [AC_RUN_IFELSE([AC_LANG_PROGRAM([
+#include <curl/curl.h>
+],[
+    curl_version_info_data *info;
+
+    if (curl_global_init(CURL_GLOBAL_ALL))
+        return -1;
+
+    info = curl_version_info(CURLVERSION_NOW);
+#ifdef CURL_VERSION_THREADSAFE
+    if (info->features & CURL_VERSION_THREADSAFE)
+        return 0;
+#endif
+
+    return 1;
+])],
+  [pgac_cv__libcurl_threadsafe_init=yes],
+  [pgac_cv__libcurl_threadsafe_init=no],
+  [pgac_cv__libcurl_threadsafe_init=unknown])])
+  if test x"$pgac_cv__libcurl_threadsafe_init" = xyes ; then
+    AC_DEFINE(HAVE_THREADSAFE_CURL_GLOBAL_INIT, 1,
+              [Define to 1 if curl_global_init() is guaranteed to be thread-safe.])
+  fi
+
+  # Warn if a thread-friendly DNS resolver isn't built.
+  AC_CACHE_CHECK([for curl support for asynchronous DNS], [pgac_cv__libcurl_async_dns],
+  [AC_RUN_IFELSE([AC_LANG_PROGRAM([
+#include <curl/curl.h>
+],[
+    curl_version_info_data *info;
+
+    if (curl_global_init(CURL_GLOBAL_ALL))
+        return -1;
+
+    info = curl_version_info(CURLVERSION_NOW);
+    return (info->features & CURL_VERSION_ASYNCHDNS) ? 0 : 1;
+])],
+  [pgac_cv__libcurl_async_dns=yes],
+  [pgac_cv__libcurl_async_dns=no],
+  [pgac_cv__libcurl_async_dns=unknown])])
+  if test x"$pgac_cv__libcurl_async_dns" != xyes ; then
+    AC_MSG_WARN([
+*** The installed version of libcurl does not support asynchronous DNS
+*** lookups. Connection timeouts will not be honored during DNS resolution,
+*** which may lead to hangs in client programs.])
+  fi
+])# PGAC_CHECK_LIBCURL
diff --git a/configure b/configure
index 0ffcaeb4367..93fddd69981 100755
--- a/configure
+++ b/configure
@@ -708,6 +708,9 @@ XML2_LIBS
 XML2_CFLAGS
 XML2_CONFIG
 with_libxml
+LIBCURL_LIBS
+LIBCURL_CFLAGS
+with_libcurl
 with_uuid
 with_readline
 with_systemd
@@ -864,6 +867,7 @@ with_readline
 with_libedit_preferred
 with_uuid
 with_ossp_uuid
+with_libcurl
 with_libxml
 with_libxslt
 with_system_tzdata
@@ -894,6 +898,8 @@ PKG_CONFIG_PATH
 PKG_CONFIG_LIBDIR
 ICU_CFLAGS
 ICU_LIBS
+LIBCURL_CFLAGS
+LIBCURL_LIBS
 XML2_CONFIG
 XML2_CFLAGS
 XML2_LIBS
@@ -1574,6 +1580,7 @@ Optional Packages:
                           prefer BSD Libedit over GNU Readline
   --with-uuid=LIB         build contrib/uuid-ossp using LIB (bsd,e2fs,ossp)
   --with-ossp-uuid        obsolete spelling of --with-uuid=ossp
+  --with-libcurl          build with libcurl support
   --with-libxml           build with XML support
   --with-libxslt          use XSLT support when building contrib/xml2
   --with-system-tzdata=DIR
@@ -1607,6 +1614,10 @@ Some influential environment variables:
               path overriding pkg-config's built-in search path
   ICU_CFLAGS  C compiler flags for ICU, overriding pkg-config
   ICU_LIBS    linker flags for ICU, overriding pkg-config
+  LIBCURL_CFLAGS
+              C compiler flags for LIBCURL, overriding pkg-config
+  LIBCURL_LIBS
+              linker flags for LIBCURL, overriding pkg-config
   XML2_CONFIG path to xml2-config utility
   XML2_CFLAGS C compiler flags for XML2, overriding pkg-config
   XML2_LIBS   linker flags for XML2, overriding pkg-config
@@ -8762,6 +8773,157 @@ fi
 
 
 
+#
+# libcurl
+#
+{ $as_echo "$as_me:${as_lineno-$LINENO}: checking whether to build with libcurl support" >&5
+$as_echo_n "checking whether to build with libcurl support... " >&6; }
+
+
+
+# Check whether --with-libcurl was given.
+if test "${with_libcurl+set}" = set; then :
+  withval=$with_libcurl;
+  case $withval in
+    yes)
+
+$as_echo "#define USE_LIBCURL 1" >>confdefs.h
+
+      ;;
+    no)
+      :
+      ;;
+    *)
+      as_fn_error $? "no argument expected for --with-libcurl option" "$LINENO" 5
+      ;;
+  esac
+
+else
+  with_libcurl=no
+
+fi
+
+
+{ $as_echo "$as_me:${as_lineno-$LINENO}: result: $with_libcurl" >&5
+$as_echo "$with_libcurl" >&6; }
+
+
+if test "$with_libcurl" = yes ; then
+  # Check for libcurl 7.61.0 or higher (corresponding to RHEL8 and the ability
+  # to explicitly set TLS 1.3 ciphersuites).
+
+pkg_failed=no
+{ $as_echo "$as_me:${as_lineno-$LINENO}: checking for libcurl >= 7.61.0" >&5
+$as_echo_n "checking for libcurl >= 7.61.0... " >&6; }
+
+if test -n "$LIBCURL_CFLAGS"; then
+    pkg_cv_LIBCURL_CFLAGS="$LIBCURL_CFLAGS"
+ elif test -n "$PKG_CONFIG"; then
+    if test -n "$PKG_CONFIG" && \
+    { { $as_echo "$as_me:${as_lineno-$LINENO}: \$PKG_CONFIG --exists --print-errors \"libcurl >= 7.61.0\""; } >&5
+  ($PKG_CONFIG --exists --print-errors "libcurl >= 7.61.0") 2>&5
+  ac_status=$?
+  $as_echo "$as_me:${as_lineno-$LINENO}: \$? = $ac_status" >&5
+  test $ac_status = 0; }; then
+  pkg_cv_LIBCURL_CFLAGS=`$PKG_CONFIG --cflags "libcurl >= 7.61.0" 2>/dev/null`
+		      test "x$?" != "x0" && pkg_failed=yes
+else
+  pkg_failed=yes
+fi
+ else
+    pkg_failed=untried
+fi
+if test -n "$LIBCURL_LIBS"; then
+    pkg_cv_LIBCURL_LIBS="$LIBCURL_LIBS"
+ elif test -n "$PKG_CONFIG"; then
+    if test -n "$PKG_CONFIG" && \
+    { { $as_echo "$as_me:${as_lineno-$LINENO}: \$PKG_CONFIG --exists --print-errors \"libcurl >= 7.61.0\""; } >&5
+  ($PKG_CONFIG --exists --print-errors "libcurl >= 7.61.0") 2>&5
+  ac_status=$?
+  $as_echo "$as_me:${as_lineno-$LINENO}: \$? = $ac_status" >&5
+  test $ac_status = 0; }; then
+  pkg_cv_LIBCURL_LIBS=`$PKG_CONFIG --libs "libcurl >= 7.61.0" 2>/dev/null`
+		      test "x$?" != "x0" && pkg_failed=yes
+else
+  pkg_failed=yes
+fi
+ else
+    pkg_failed=untried
+fi
+
+
+
+if test $pkg_failed = yes; then
+        { $as_echo "$as_me:${as_lineno-$LINENO}: result: no" >&5
+$as_echo "no" >&6; }
+
+if $PKG_CONFIG --atleast-pkgconfig-version 0.20; then
+        _pkg_short_errors_supported=yes
+else
+        _pkg_short_errors_supported=no
+fi
+        if test $_pkg_short_errors_supported = yes; then
+	        LIBCURL_PKG_ERRORS=`$PKG_CONFIG --short-errors --print-errors --cflags --libs "libcurl >= 7.61.0" 2>&1`
+        else
+	        LIBCURL_PKG_ERRORS=`$PKG_CONFIG --print-errors --cflags --libs "libcurl >= 7.61.0" 2>&1`
+        fi
+	# Put the nasty error message in config.log where it belongs
+	echo "$LIBCURL_PKG_ERRORS" >&5
+
+	as_fn_error $? "Package requirements (libcurl >= 7.61.0) were not met:
+
+$LIBCURL_PKG_ERRORS
+
+Consider adjusting the PKG_CONFIG_PATH environment variable if you
+installed software in a non-standard prefix.
+
+Alternatively, you may set the environment variables LIBCURL_CFLAGS
+and LIBCURL_LIBS to avoid the need to call pkg-config.
+See the pkg-config man page for more details." "$LINENO" 5
+elif test $pkg_failed = untried; then
+        { $as_echo "$as_me:${as_lineno-$LINENO}: result: no" >&5
+$as_echo "no" >&6; }
+	{ { $as_echo "$as_me:${as_lineno-$LINENO}: error: in \`$ac_pwd':" >&5
+$as_echo "$as_me: error: in \`$ac_pwd':" >&2;}
+as_fn_error $? "The pkg-config script could not be found or is too old.  Make sure it
+is in your PATH or set the PKG_CONFIG environment variable to the full
+path to pkg-config.
+
+Alternatively, you may set the environment variables LIBCURL_CFLAGS
+and LIBCURL_LIBS to avoid the need to call pkg-config.
+See the pkg-config man page for more details.
+
+To get pkg-config, see <http://pkg-config.freedesktop.org/>.
+See \`config.log' for more details" "$LINENO" 5; }
+else
+	LIBCURL_CFLAGS=$pkg_cv_LIBCURL_CFLAGS
+	LIBCURL_LIBS=$pkg_cv_LIBCURL_LIBS
+        { $as_echo "$as_me:${as_lineno-$LINENO}: result: yes" >&5
+$as_echo "yes" >&6; }
+
+fi
+
+  # We only care about -I, -D, and -L switches;
+  # note that -lcurl will be added by PGAC_CHECK_LIBCURL below.
+  for pgac_option in $LIBCURL_CFLAGS; do
+    case $pgac_option in
+      -I*|-D*) CPPFLAGS="$CPPFLAGS $pgac_option";;
+    esac
+  done
+  for pgac_option in $LIBCURL_LIBS; do
+    case $pgac_option in
+      -L*) LDFLAGS="$LDFLAGS $pgac_option";;
+    esac
+  done
+
+  # OAuth requires python for testing
+  if test "$with_python" != yes; then
+    { $as_echo "$as_me:${as_lineno-$LINENO}: WARNING: *** OAuth support tests require --with-python to run" >&5
+$as_echo "$as_me: WARNING: *** OAuth support tests require --with-python to run" >&2;}
+  fi
+fi
+
+
 #
 # XML
 #
@@ -12216,6 +12378,176 @@ fi
 
 fi
 
+# XXX libcurl must link after libgssapi_krb5 on FreeBSD to avoid segfaults
+# during gss_acquire_cred(). This is possibly related to Curl's Heimdal
+# dependency on that platform?
+if test "$with_libcurl" = yes ; then
+
+  ac_fn_c_check_header_mongrel "$LINENO" "curl/curl.h" "ac_cv_header_curl_curl_h" "$ac_includes_default"
+if test "x$ac_cv_header_curl_curl_h" = xyes; then :
+
+else
+  as_fn_error $? "header file <curl/curl.h> is required for --with-libcurl" "$LINENO" 5
+fi
+
+
+  { $as_echo "$as_me:${as_lineno-$LINENO}: checking for curl_multi_init in -lcurl" >&5
+$as_echo_n "checking for curl_multi_init in -lcurl... " >&6; }
+if ${ac_cv_lib_curl_curl_multi_init+:} false; then :
+  $as_echo_n "(cached) " >&6
+else
+  ac_check_lib_save_LIBS=$LIBS
+LIBS="-lcurl  $LIBS"
+cat confdefs.h - <<_ACEOF >conftest.$ac_ext
+/* end confdefs.h.  */
+
+/* Override any GCC internal prototype to avoid an error.
+   Use char because int might match the return type of a GCC
+   builtin and then its argument prototype would still apply.  */
+#ifdef __cplusplus
+extern "C"
+#endif
+char curl_multi_init ();
+int
+main ()
+{
+return curl_multi_init ();
+  ;
+  return 0;
+}
+_ACEOF
+if ac_fn_c_try_link "$LINENO"; then :
+  ac_cv_lib_curl_curl_multi_init=yes
+else
+  ac_cv_lib_curl_curl_multi_init=no
+fi
+rm -f core conftest.err conftest.$ac_objext \
+    conftest$ac_exeext conftest.$ac_ext
+LIBS=$ac_check_lib_save_LIBS
+fi
+{ $as_echo "$as_me:${as_lineno-$LINENO}: result: $ac_cv_lib_curl_curl_multi_init" >&5
+$as_echo "$ac_cv_lib_curl_curl_multi_init" >&6; }
+if test "x$ac_cv_lib_curl_curl_multi_init" = xyes; then :
+  cat >>confdefs.h <<_ACEOF
+#define HAVE_LIBCURL 1
+_ACEOF
+
+  LIBS="-lcurl $LIBS"
+
+else
+  as_fn_error $? "library 'curl' does not provide curl_multi_init" "$LINENO" 5
+fi
+
+
+  # Check to see whether the current platform supports threadsafe Curl
+  # initialization.
+  { $as_echo "$as_me:${as_lineno-$LINENO}: checking for curl_global_init thread safety" >&5
+$as_echo_n "checking for curl_global_init thread safety... " >&6; }
+if ${pgac_cv__libcurl_threadsafe_init+:} false; then :
+  $as_echo_n "(cached) " >&6
+else
+  if test "$cross_compiling" = yes; then :
+  pgac_cv__libcurl_threadsafe_init=unknown
+else
+  cat confdefs.h - <<_ACEOF >conftest.$ac_ext
+/* end confdefs.h.  */
+
+#include <curl/curl.h>
+
+int
+main ()
+{
+
+    curl_version_info_data *info;
+
+    if (curl_global_init(CURL_GLOBAL_ALL))
+        return -1;
+
+    info = curl_version_info(CURLVERSION_NOW);
+#ifdef CURL_VERSION_THREADSAFE
+    if (info->features & CURL_VERSION_THREADSAFE)
+        return 0;
+#endif
+
+    return 1;
+
+  ;
+  return 0;
+}
+_ACEOF
+if ac_fn_c_try_run "$LINENO"; then :
+  pgac_cv__libcurl_threadsafe_init=yes
+else
+  pgac_cv__libcurl_threadsafe_init=no
+fi
+rm -f core *.core core.conftest.* gmon.out bb.out conftest$ac_exeext \
+  conftest.$ac_objext conftest.beam conftest.$ac_ext
+fi
+
+fi
+{ $as_echo "$as_me:${as_lineno-$LINENO}: result: $pgac_cv__libcurl_threadsafe_init" >&5
+$as_echo "$pgac_cv__libcurl_threadsafe_init" >&6; }
+  if test x"$pgac_cv__libcurl_threadsafe_init" = xyes ; then
+
+$as_echo "#define HAVE_THREADSAFE_CURL_GLOBAL_INIT 1" >>confdefs.h
+
+  fi
+
+  # Warn if a thread-friendly DNS resolver isn't built.
+  { $as_echo "$as_me:${as_lineno-$LINENO}: checking for curl support for asynchronous DNS" >&5
+$as_echo_n "checking for curl support for asynchronous DNS... " >&6; }
+if ${pgac_cv__libcurl_async_dns+:} false; then :
+  $as_echo_n "(cached) " >&6
+else
+  if test "$cross_compiling" = yes; then :
+  pgac_cv__libcurl_async_dns=unknown
+else
+  cat confdefs.h - <<_ACEOF >conftest.$ac_ext
+/* end confdefs.h.  */
+
+#include <curl/curl.h>
+
+int
+main ()
+{
+
+    curl_version_info_data *info;
+
+    if (curl_global_init(CURL_GLOBAL_ALL))
+        return -1;
+
+    info = curl_version_info(CURLVERSION_NOW);
+    return (info->features & CURL_VERSION_ASYNCHDNS) ? 0 : 1;
+
+  ;
+  return 0;
+}
+_ACEOF
+if ac_fn_c_try_run "$LINENO"; then :
+  pgac_cv__libcurl_async_dns=yes
+else
+  pgac_cv__libcurl_async_dns=no
+fi
+rm -f core *.core core.conftest.* gmon.out bb.out conftest$ac_exeext \
+  conftest.$ac_objext conftest.beam conftest.$ac_ext
+fi
+
+fi
+{ $as_echo "$as_me:${as_lineno-$LINENO}: result: $pgac_cv__libcurl_async_dns" >&5
+$as_echo "$pgac_cv__libcurl_async_dns" >&6; }
+  if test x"$pgac_cv__libcurl_async_dns" != xyes ; then
+    { $as_echo "$as_me:${as_lineno-$LINENO}: WARNING:
+*** The installed version of libcurl does not support asynchronous DNS
+*** lookups. Connection timeouts will not be honored during DNS resolution,
+*** which may lead to hangs in client programs." >&5
+$as_echo "$as_me: WARNING:
+*** The installed version of libcurl does not support asynchronous DNS
+*** lookups. Connection timeouts will not be honored during DNS resolution,
+*** which may lead to hangs in client programs." >&2;}
+  fi
+
+fi
+
 if test "$with_gssapi" = yes ; then
   if test "$PORTNAME" != "win32"; then
     { $as_echo "$as_me:${as_lineno-$LINENO}: checking for library containing gss_store_cred_into" >&5
diff --git a/configure.ac b/configure.ac
index f56681e0d91..b6d02f5ecc7 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1007,6 +1007,40 @@ fi
 AC_SUBST(with_uuid)
 
 
+#
+# libcurl
+#
+AC_MSG_CHECKING([whether to build with libcurl support])
+PGAC_ARG_BOOL(with, libcurl, no, [build with libcurl support],
+              [AC_DEFINE([USE_LIBCURL], 1, [Define to 1 to build with libcurl support. (--with-libcurl)])])
+AC_MSG_RESULT([$with_libcurl])
+AC_SUBST(with_libcurl)
+
+if test "$with_libcurl" = yes ; then
+  # Check for libcurl 7.61.0 or higher (corresponding to RHEL8 and the ability
+  # to explicitly set TLS 1.3 ciphersuites).
+  PKG_CHECK_MODULES(LIBCURL, [libcurl >= 7.61.0])
+
+  # We only care about -I, -D, and -L switches;
+  # note that -lcurl will be added by PGAC_CHECK_LIBCURL below.
+  for pgac_option in $LIBCURL_CFLAGS; do
+    case $pgac_option in
+      -I*|-D*) CPPFLAGS="$CPPFLAGS $pgac_option";;
+    esac
+  done
+  for pgac_option in $LIBCURL_LIBS; do
+    case $pgac_option in
+      -L*) LDFLAGS="$LDFLAGS $pgac_option";;
+    esac
+  done
+
+  # OAuth requires python for testing
+  if test "$with_python" != yes; then
+    AC_MSG_WARN([*** OAuth support tests require --with-python to run])
+  fi
+fi
+
+
 #
 # XML
 #
@@ -1294,6 +1328,13 @@ failure.  It is possible the compiler isn't looking in the proper directory.
 Use --without-zlib to disable zlib support.])])
 fi
 
+# XXX libcurl must link after libgssapi_krb5 on FreeBSD to avoid segfaults
+# during gss_acquire_cred(). This is possibly related to Curl's Heimdal
+# dependency on that platform?
+if test "$with_libcurl" = yes ; then
+  PGAC_CHECK_LIBCURL
+fi
+
 if test "$with_gssapi" = yes ; then
   if test "$PORTNAME" != "win32"; then
     AC_SEARCH_LIBS(gss_store_cred_into, [gssapi_krb5 gss 'gssapi -lkrb5 -lcrypto'], [],
diff --git a/doc/src/sgml/client-auth.sgml b/doc/src/sgml/client-auth.sgml
index 782b49c85ac..832b616a7bb 100644
--- a/doc/src/sgml/client-auth.sgml
+++ b/doc/src/sgml/client-auth.sgml
@@ -656,6 +656,16 @@ include_dir         <replaceable>directory</replaceable>
          </para>
         </listitem>
        </varlistentry>
+
+       <varlistentry>
+        <term><literal>oauth</literal></term>
+        <listitem>
+         <para>
+          Authorize and optionally authenticate using a third-party OAuth 2.0
+          identity provider. See <xref linkend="auth-oauth"/> for details.
+         </para>
+        </listitem>
+       </varlistentry>
       </variablelist>
 
       </para>
@@ -1143,6 +1153,12 @@ omicron         bryanh                  guest1
       only on OpenBSD).
      </para>
     </listitem>
+    <listitem>
+     <para>
+      <link linkend="auth-oauth">OAuth authorization/authentication</link>,
+      which relies on an external OAuth 2.0 identity provider.
+     </para>
+    </listitem>
    </itemizedlist>
   </para>
 
@@ -2329,6 +2345,242 @@ host ... radius radiusservers="server1,server2" radiussecrets="""secret one"",""
    </note>
   </sect1>
 
+  <sect1 id="auth-oauth">
+   <title>OAuth Authorization/Authentication</title>
+
+   <indexterm zone="auth-oauth">
+    <primary>OAuth Authorization/Authentication</primary>
+   </indexterm>
+
+   <para>
+    OAuth 2.0 is an industry-standard framework, defined in
+    <ulink url="https://datatracker.ietf.org/doc/html/rfc6749">RFC 6749</ulink>,
+    to enable third-party applications to obtain limited access to a protected
+    resource.
+
+    OAuth client support has to be enabled when <productname>PostgreSQL</productname>
+    is built, see <xref linkend="installation"/> for more information.
+   </para>
+
+   <para>
+    This documentation uses the following terminology when discussing the OAuth
+    ecosystem:
+
+    <variablelist>
+
+     <varlistentry>
+      <term>Resource Owner (or End User)</term>
+      <listitem>
+       <para>
+        The user or system who owns protected resources and can grant access to
+        them. This documentation also uses the term <emphasis>end user</emphasis>
+        when the resource owner is a person. When you use
+        <application>psql</application> to connect to the database using OAuth,
+        you are the resource owner/end user.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term>Client</term>
+      <listitem>
+       <para>
+        The system which accesses the protected resources using access
+        tokens. Applications using libpq, such as <application>psql</application>,
+        are the OAuth clients when connecting to a
+        <productname>PostgreSQL</productname> cluster.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term>Resource Server</term>
+      <listitem>
+       <para>
+        The system hosting the protected resources which are
+        accessed by the client. The <productname>PostgreSQL</productname>
+        cluster being connected to is the resource server.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term>Provider</term>
+      <listitem>
+       <para>
+        The organization, product vendor, or other entity which develops and/or
+        administers the OAuth authorization servers and clients for a given application.
+        Different providers typically choose different implementation details
+        for their OAuth systems; a client of one provider is not generally
+        guaranteed to have access to the servers of another.
+       </para>
+       <para>
+        This use of the term "provider" is not standard, but it seems to be in
+        wide use colloquially. (It should not be confused with OpenID's similar
+        term "Identity Provider". While the implementation of OAuth in
+        <productname>PostgreSQL</productname> is intended to be interoperable
+        and compatible with OpenID Connect/OIDC, it is not itself an OIDC client
+        and does not require its use.)
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term>Authorization Server</term>
+      <listitem>
+       <para>
+        The system which receives requests from, and issues access tokens to,
+        the client after the authenticated resource owner has given approval.
+        <productname>PostgreSQL</productname> does not provide an authorization
+        server; it is the responsibility of the OAuth provider.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term id="auth-oauth-issuer">Issuer</term>
+      <listitem>
+       <para>
+        An identifier for an authorization server, printed as an
+        <literal>https://</literal> URL, which provides a trusted "namespace"
+        for OAuth clients and applications. The issuer identifier allows a
+        single authorization server to talk to the clients of mutually
+        untrusting entities, as long as they maintain separate issuers.
+       </para>
+      </listitem>
+     </varlistentry>
+
+    </variablelist>
+
+    <note>
+     <para>
+      For small deployments, there may not be a meaningful distinction between
+      the "provider", "authorization server", and "issuer". However, for more
+      complicated setups, there may be a one-to-many (or many-to-many)
+      relationship: a provider may rent out multiple issuer identifiers to
+      separate tenants, then provide multiple authorization servers, possibly
+      with different supported feature sets, to interact with their clients.
+     </para>
+    </note>
+   </para>
+
+   <para>
+    <productname>PostgreSQL</productname> supports bearer tokens, defined in
+    <ulink url="https://datatracker.ietf.org/doc/html/rfc6750">RFC 6750</ulink>,
+    which are a type of access token used with OAuth 2.0 where the token is an
+    opaque string.  The format of the access token is implementation specific
+    and is chosen by each authorization server.
+   </para>
+
+   <para>
+    The following configuration options are supported for OAuth:
+    <variablelist>
+     <varlistentry>
+      <term><literal>issuer</literal></term>
+      <listitem>
+       <para>
+        An HTTPS URL which is either the exact
+        <link linkend="auth-oauth-issuer">issuer identifier</link> of the
+        authorization server, as defined by its discovery document, or a
+        well-known URI that points directly to that discovery document. This
+        parameter is required.
+       </para>
+       <para>
+        When an OAuth client connects to the server, a URL for the discovery
+        document will be constructed using the issuer identifier. By default,
+        this URL uses the conventions of OpenID Connect Discovery: the path
+        <literal>/.well-known/openid-configuration</literal> will be appended
+        to the end of the issuer identifier. Alternatively, if the
+        <literal>issuer</literal> contains a <literal>/.well-known/</literal>
+        path segment, that URL will be provided to the client as-is.
+       </para>
+       <warning>
+        <para>
+         The OAuth client in libpq requires the server's issuer setting to
+         exactly match the issuer identifier which is provided in the discovery
+         document, which must in turn match the client's
+         <xref linkend="libpq-connect-oauth-issuer"/> setting. No variations in
+         case or formatting are permitted.
+        </para>
+       </warning>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term><literal>scope</literal></term>
+      <listitem>
+       <para>
+        A space-separated list of the OAuth scopes needed for the server to
+        both authorize the client and authenticate the user.  Appropriate values
+        are determined by the authorization server and the OAuth validation
+        module used (see <xref linkend="oauth-validators" /> for more
+        information on validators).  This parameter is required.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term><literal>validator</literal></term>
+      <listitem>
+       <para>
+        The library to use for validating bearer tokens. If given, the name must
+        exactly match one of the libraries listed in
+        <xref linkend="guc-oauth-validator-libraries" />.  This parameter is
+        optional unless <literal>oauth_validator_libraries</literal> contains
+        more than one library, in which case it is required.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term><literal>map</literal></term>
+      <listitem>
+       <para>
+        Allows for mapping between OAuth identity provider and database user
+        names.  See <xref linkend="auth-username-maps"/> for details.  If a
+        map is not specified, the user name associated with the token (as
+        determined by the OAuth validator) must exactly match the role name
+        being requested.  This parameter is optional.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term id="auth-oauth-delegate-ident-mapping" xreflabel="delegate_ident_mapping">
+       <literal>delegate_ident_mapping</literal>
+      </term>
+      <listitem>
+       <para>
+        An advanced option which is not intended for common use.
+       </para>
+       <para>
+        When set to <literal>1</literal>, standard user mapping with
+        <filename>pg_ident.conf</filename> is skipped, and the OAuth validator
+        takes full responsibility for mapping end user identities to database
+        roles.  If the validator authorizes the token, the server trusts that
+        the user is allowed to connect under the requested role, and the
+        connection is allowed to proceed regardless of the authentication
+        status of the user.
+       </para>
+       <para>
+        This parameter is incompatible with <literal>map</literal>.
+       </para>
+       <warning>
+        <para>
+         <literal>delegate_ident_mapping</literal> provides additional
+         flexibility in the design of the authentication system, but it also
+         requires careful implementation of the OAuth validator, which must
+         determine whether the provided token carries sufficient end-user
+         privileges in addition to the <link linkend="oauth-validators">standard
+         checks</link> required of all validators.  Use with caution.
+        </para>
+       </warning>
+      </listitem>
+     </varlistentry>
+    </variablelist>
+   </para>
+  </sect1>
+
   <sect1 id="client-authentication-problems">
    <title>Authentication Problems</title>
 
diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml
index 336630ce417..c4dfa8ba039 100644
--- a/doc/src/sgml/config.sgml
+++ b/doc/src/sgml/config.sgml
@@ -1209,6 +1209,32 @@ include_dir 'conf.d'
        </para>
       </listitem>
      </varlistentry>
+
+     <varlistentry id="guc-oauth-validator-libraries" xreflabel="oauth_validator_libraries">
+      <term><varname>oauth_validator_libraries</varname> (<type>string</type>)
+      <indexterm>
+       <primary><varname>oauth_validator_libraries</varname> configuration parameter</primary>
+      </indexterm>
+      </term>
+      <listitem>
+       <para>
+        The library/libraries to use for validating OAuth connection tokens. If
+        only one validator library is provided, it will be used by default for
+        any OAuth connections; otherwise, all
+        <link linkend="auth-oauth"><literal>oauth</literal> HBA entries</link>
+        must explicitly set a <literal>validator</literal> chosen from this
+        list. If set to an empty string (the default), OAuth connections will be
+        refused. This parameter can only be set in the
+        <filename>postgresql.conf</filename> file.
+       </para>
+       <para>
+        Validator modules must be implemented/obtained separately;
+        <productname>PostgreSQL</productname> does not ship with any default
+        implementations. For more information on implementing OAuth validators,
+        see <xref linkend="oauth-validators" />.
+       </para>
+      </listitem>
+     </varlistentry>
      </variablelist>
      </sect2>
 
diff --git a/doc/src/sgml/filelist.sgml b/doc/src/sgml/filelist.sgml
index 66e6dccd4c9..25fb99cee69 100644
--- a/doc/src/sgml/filelist.sgml
+++ b/doc/src/sgml/filelist.sgml
@@ -111,6 +111,7 @@
 <!ENTITY generic-wal SYSTEM "generic-wal.sgml">
 <!ENTITY custom-rmgr SYSTEM "custom-rmgr.sgml">
 <!ENTITY backup-manifest SYSTEM "backup-manifest.sgml">
+<!ENTITY oauth-validators SYSTEM "oauth-validators.sgml">
 
 <!-- contrib information -->
 <!ENTITY contrib         SYSTEM "contrib.sgml">
diff --git a/doc/src/sgml/installation.sgml b/doc/src/sgml/installation.sgml
index 3f0a7e9c069..3c95c15a1e4 100644
--- a/doc/src/sgml/installation.sgml
+++ b/doc/src/sgml/installation.sgml
@@ -1143,6 +1143,19 @@ build-postgresql:
        </listitem>
       </varlistentry>
 
+      <varlistentry id="configure-option-with-libcurl">
+       <term><option>--with-libcurl</option></term>
+       <listitem>
+        <para>
+         Build with libcurl support for OAuth 2.0 client flows.
+         Libcurl version 7.61.0 or later is required for this feature.
+         Building with this will check for the required header files
+         and libraries to make sure that your <productname>curl</productname>
+         installation is sufficient before proceeding.
+        </para>
+       </listitem>
+      </varlistentry>
+
       <varlistentry id="configure-option-with-libxml">
        <term><option>--with-libxml</option></term>
        <listitem>
@@ -2584,6 +2597,20 @@ ninja install
       </listitem>
      </varlistentry>
 
+     <varlistentry id="configure-with-libcurl-meson">
+      <term><option>-Dlibcurl={ auto | enabled | disabled }</option></term>
+      <listitem>
+       <para>
+        Build with libcurl support for OAuth 2.0 client flows.
+        Libcurl version 7.61.0 or later is required for this feature.
+        Building with this will check for the required header files
+        and libraries to make sure that your <productname>Curl</productname>
+        installation is sufficient before proceeding. The default for this
+        option is auto.
+       </para>
+      </listitem>
+     </varlistentry>
+
      <varlistentry id="configure-with-libxml-meson">
       <term><option>-Dlibxml={ auto | enabled | disabled }</option></term>
       <listitem>
diff --git a/doc/src/sgml/libpq.sgml b/doc/src/sgml/libpq.sgml
index c49e975b082..ddb3596df83 100644
--- a/doc/src/sgml/libpq.sgml
+++ b/doc/src/sgml/libpq.sgml
@@ -1385,6 +1385,15 @@ postgresql://%2Fvar%2Flib%2Fpostgresql/dbname
           </listitem>
          </varlistentry>
 
+         <varlistentry>
+          <term><literal>oauth</literal></term>
+          <listitem>
+           <para>
+            The server must request an OAuth bearer token from the client.
+           </para>
+          </listitem>
+         </varlistentry>
+
          <varlistentry>
           <term><literal>none</literal></term>
           <listitem>
@@ -2373,6 +2382,107 @@ postgresql://%2Fvar%2Flib%2Fpostgresql/dbname
        </para>
       </listitem>
      </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-issuer" xreflabel="oauth_issuer">
+      <term><literal>oauth_issuer</literal></term>
+      <listitem>
+       <para>
+        The HTTPS URL of a trusted issuer to contact if the server requests an
+        OAuth token for the connection. This parameter is required for all OAuth
+        connections; it should exactly match the <literal>issuer</literal>
+        setting in <link linkend="auth-oauth">the server's HBA configuration</link>.
+       </para>
+       <para>
+        As part of the standard authentication handshake, <application>libpq</application>
+        will ask the server for a <emphasis>discovery document:</emphasis> a URL
+        providing a set of OAuth configuration parameters. The server must
+        provide a URL that is directly constructed from the components of the
+        <literal>oauth_issuer</literal>, and this value must exactly match the
+        issuer identifier that is declared in the discovery document itself, or
+        the connection will fail. This is required to prevent a class of
+        <ulink url="https://mailarchive.ietf.org/arch/msg/oauth/JIVxFBGsJBVtm7ljwJhPUm3Fr-w/">
+        "mix-up attacks"</ulink> on OAuth clients.
+       </para>
+       <para>
+        You may also explicitly set <literal>oauth_issuer</literal> to the
+        <literal>/.well-known/</literal> URI used for OAuth discovery. In this
+        case, if the server asks for a different URL, the connection will fail,
+        but a <link linkend="libpq-oauth-authdata-hooks">custom OAuth flow</link>
+        may be able to speed up the standard handshake by using previously
+        cached tokens. (In this case, it is recommended that
+        <xref linkend="libpq-connect-oauth-scope"/> be set as well, since the
+        client will not have a chance to ask the server for a correct scope
+        setting, and the default scopes for a token may not be sufficient to
+        connect.) <application>libpq</application> currently supports the
+        following well-known endpoints:
+        <itemizedlist spacing="compact">
+         <listitem><para><literal>/.well-known/openid-configuration</literal></para></listitem>
+         <listitem><para><literal>/.well-known/oauth-authorization-server</literal></para></listitem>
+        </itemizedlist>
+       </para>
+       <warning>
+        <para>
+         Issuers are highly privileged during the OAuth connection handshake. As
+         a rule of thumb, if you would not trust the operator of a URL to handle
+         access to your servers, or to impersonate you directly, that URL should
+         not be trusted as an <literal>oauth_issuer</literal>.
+        </para>
+       </warning>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-client-id" xreflabel="oauth_client_id">
+      <term><literal>oauth_client_id</literal></term>
+      <listitem>
+       <para>
+        An OAuth 2.0 client identifier, as issued by the authorization server.
+        If the <productname>PostgreSQL</productname> server
+        <link linkend="auth-oauth">requests an OAuth token</link> for the
+        connection (and if no <link linkend="libpq-oauth-authdata-hooks">custom
+        OAuth hook</link> is installed to provide one), then this parameter must
+        be set; otherwise, the connection will fail.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-client-secret" xreflabel="oauth_client_secret">
+      <term><literal>oauth_client_secret</literal></term>
+      <listitem>
+       <para>
+        The client password, if any, to use when contacting the OAuth
+        authorization server. Whether this parameter is required or not is
+        determined by the OAuth provider; "public" clients generally do not use
+        a secret, whereas "confidential" clients generally do.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-scope" xreflabel="oauth_scope">
+      <term><literal>oauth_scope</literal></term>
+      <listitem>
+       <para>
+        The scope of the access request sent to the authorization server,
+        specified as a (possibly empty) space-separated list of OAuth scope
+        identifiers. This parameter is optional and intended for advanced usage.
+       </para>
+       <para>
+        Usually the client will obtain appropriate scope settings from the
+        <productname>PostgreSQL</productname> server. If this parameter is used,
+        the server's requested scope list will be ignored. This can prevent a
+        less-trusted server from requesting inappropriate access scopes from the
+        end user. However, if the client's scope setting does not contain the
+        server's required scopes, the server is likely to reject the issued
+        token, and the connection will fail.
+       </para>
+       <para>
+        The meaning of an empty scope list is provider-dependent. An OAuth
+        authorization server may choose to issue a token with "default scope",
+        whatever that happens to be, or it may reject the token request
+        entirely.
+       </para>
+      </listitem>
+     </varlistentry>
+
     </variablelist>
    </para>
   </sect2>
@@ -10020,6 +10130,329 @@ void PQinitSSL(int do_ssl);
 
  </sect1>
 
+ <sect1 id="libpq-oauth">
+  <title>OAuth Support</title>
+
+  <para>
+   libpq implements support for the OAuth v2 Device Authorization client flow,
+   documented in
+   <ulink url="https://datatracker.ietf.org/doc/html/rfc8628">RFC 8628</ulink>,
+   which it will attempt to use by default if the server
+   <link linkend="auth-oauth">requests a bearer token</link> during
+   authentication. This flow can be utilized even if the system running the
+   client application does not have a usable web browser, for example when
+   running a client via <application>SSH</application>. Client applications may implement their own flows
+   instead; see <xref linkend="libpq-oauth-authdata-hooks"/>.
+  </para>
+  <para>
+   The builtin flow will, by default, print a URL to visit and a user code to
+   enter there:
+<programlisting>
+$ psql 'dbname=postgres oauth_issuer=https://example.com oauth_client_id=...'
+Visit https://example.com/device and enter the code: ABCD-EFGH
+</programlisting>
+   (This prompt may be
+   <link linkend="libpq-oauth-authdata-prompt-oauth-device">customized</link>.)
+   The user will then log into their OAuth provider, which will ask whether
+   to allow libpq and the server to perform actions on their behalf. It is always
+   a good idea to carefully review the URL and permissions displayed, to ensure
+   they match expectations, before continuing. Permissions should not be given
+   to untrusted third parties.
+  </para>
+  <para>
+   For an OAuth client flow to be usable, the connection string must at minimum
+   contain <xref linkend="libpq-connect-oauth-issuer"/> and
+   <xref linkend="libpq-connect-oauth-client-id"/>. (These settings are
+   determined by your organization's OAuth provider.) The builtin flow
+   additionally requires the OAuth authorization server to publish a device
+   authorization endpoint.
+  </para>
+
+  <note>
+   <para>
+    The builtin Device Authorization flow is not currently supported on Windows.
+    Custom client flows may still be implemented.
+   </para>
+  </note>
+
+  <sect2 id="libpq-oauth-authdata-hooks">
+   <title>Authdata Hooks</title>
+
+   <para>
+    The behavior of the OAuth flow may be modified or replaced by a client using
+    the following hook API:
+
+    <variablelist>
+     <varlistentry id="libpq-PQsetAuthDataHook">
+      <term><function>PQsetAuthDataHook</function><indexterm><primary>PQsetAuthDataHook</primary></indexterm></term>
+
+      <listitem>
+       <para>
+        Sets the <symbol>PGauthDataHook</symbol>, overriding
+        <application>libpq</application>'s handling of one or more aspects of
+        its OAuth client flow.
+<synopsis>
+void PQsetAuthDataHook(PQauthDataHook_type hook);
+</synopsis>
+        If <replaceable>hook</replaceable> is <literal>NULL</literal>, the
+        default handler will be reinstalled. Otherwise, the application passes
+        a pointer to a callback function with the signature:
+<programlisting>
+int hook_fn(PGauthData type, PGconn *conn, void *data);
+</programlisting>
+        which <application>libpq</application> will call when an action is
+        required of the application. <replaceable>type</replaceable> describes
+        the request being made, <replaceable>conn</replaceable> is the
+        connection handle being authenticated, and <replaceable>data</replaceable>
+        points to request-specific metadata. The contents of this pointer are
+        determined by <replaceable>type</replaceable>; see
+        <xref linkend="libpq-oauth-authdata-hooks-types"/> for the supported
+        list.
+       </para>
+       <para>
+        Hooks can be chained together to allow cooperative and/or fallback
+        behavior. In general, a hook implementation should examine the incoming
+        <replaceable>type</replaceable> (and, potentially, the request metadata
+        and/or the settings for the particular <replaceable>conn</replaceable>
+        in use) to decide whether or not to handle a specific piece of authdata.
+        If not, it should delegate to the previous hook in the chain
+        (retrievable via <function>PQgetAuthDataHook</function>).
+       </para>
+       <para>
+        Success is indicated by returning an integer greater than zero.
+        Returning a negative integer signals an error condition and abandons the
+        connection attempt. (A zero value is reserved for the default
+        implementation.)
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="libpq-PQgetAuthDataHook">
+      <term><function>PQgetAuthDataHook</function><indexterm><primary>PQgetAuthDataHook</primary></indexterm></term>
+
+      <listitem>
+       <para>
+        Retrieves the current value of <symbol>PGauthDataHook</symbol>.
+<synopsis>
+PQauthDataHook_type PQgetAuthDataHook(void);
+</synopsis>
+        At initialization time (before the first call to
+        <function>PQsetAuthDataHook</function>), this function will return
+        <symbol>PQdefaultAuthDataHook</symbol>.
+       </para>
+      </listitem>
+     </varlistentry>
+    </variablelist>
+   </para>
+
+   <sect3 id="libpq-oauth-authdata-hooks-types">
+    <title>Hook Types</title>
+    <para>
+     The following <symbol>PGauthData</symbol> types and their corresponding
+     <replaceable>data</replaceable> structures are defined:
+
+     <variablelist>
+      <varlistentry id="libpq-oauth-authdata-prompt-oauth-device">
+       <term>
+        <symbol>PQAUTHDATA_PROMPT_OAUTH_DEVICE</symbol>
+        <indexterm><primary>PQAUTHDATA_PROMPT_OAUTH_DEVICE</primary></indexterm>
+       </term>
+       <listitem>
+        <para>
+         Replaces the default user prompt during the builtin device
+         authorization client flow. <replaceable>data</replaceable> points to
+         an instance of <symbol>PGpromptOAuthDevice</symbol>:
+<synopsis>
+typedef struct _PGpromptOAuthDevice
+{
+    const char *verification_uri;   /* verification URI to visit */
+    const char *user_code;          /* user code to enter */
+    const char *verification_uri_complete;  /* optional combination of URI and
+                                             * code, or NULL */
+    int         expires_in;         /* seconds until user code expires */
+} PGpromptOAuthDevice;
+</synopsis>
+        </para>
+        <para>
+         The OAuth Device Authorization flow included in <application>libpq</application>
+         requires the end user to visit a URL with a browser, then enter a code
+         which permits <application>libpq</application> to connect to the server
+         on their behalf. The default prompt simply prints the
+         <literal>verification_uri</literal> and <literal>user_code</literal>
+         on standard error. Replacement implementations may display this
+         information using any preferred method, for example with a GUI.
+        </para>
+        <para>
+         This callback is only invoked during the builtin device
+         authorization flow. If the application installs a
+         <link linkend="libpq-oauth-authdata-oauth-bearer-token">custom OAuth
+         flow</link>, this authdata type will not be used.
+        </para>
+        <para>
+         If a non-NULL <structfield>verification_uri_complete</structfield> is
+         provided, it may optionally be used for non-textual verification (for
+         example, by displaying a QR code). The URL and user code should still
+         be displayed to the end user in this case, because the code will be
+         manually confirmed by the provider, and the URL lets users continue
+         even if they can't use the non-textual method. For more information,
+         see section 3.3.1 in
+         <ulink url="https://datatracker.ietf.org/doc/html/rfc8628#section-3.3.1">RFC 8628</ulink>.
+        </para>
+       </listitem>
+      </varlistentry>
+
+      <varlistentry id="libpq-oauth-authdata-oauth-bearer-token">
+       <term>
+        <symbol>PQAUTHDATA_OAUTH_BEARER_TOKEN</symbol>
+        <indexterm><primary>PQAUTHDATA_OAUTH_BEARER_TOKEN</primary></indexterm>
+       </term>
+       <listitem>
+        <para>
+         Replaces the entire OAuth flow with a custom implementation. The hook
+         should either directly return a Bearer token for the current
+         user/issuer/scope combination, if one is available without blocking, or
+         else set up an asynchronous callback to retrieve one.
+        </para>
+        <para>
+         <replaceable>data</replaceable> points to an instance
+         of <symbol>PGoauthBearerRequest</symbol>, which should be filled in
+         by the implementation:
+<synopsis>
+typedef struct _PGoauthBearerRequest
+{
+    /* Hook inputs (constant across all calls) */
+    const char *const openid_configuration; /* OIDC discovery URL */
+    const char *const scope;                /* required scope(s), or NULL */
+
+    /* Hook outputs */
+
+    /* Callback implementing a custom asynchronous OAuth flow. */
+    PostgresPollingStatusType (*async) (PGconn *conn,
+                                        struct _PGoauthBearerRequest *request,
+                                        SOCKTYPE *altsock);
+
+    /* Callback to clean up custom allocations. */
+    void        (*cleanup) (PGconn *conn, struct _PGoauthBearerRequest *request);
+
+    char       *token;   /* acquired Bearer token */
+    void       *user;    /* hook-defined allocated data */
+} PGoauthBearerRequest;
+</synopsis>
+        </para>
+        <para>
+         Two pieces of information are provided to the hook by
+         <application>libpq</application>:
+         <replaceable>openid_configuration</replaceable> contains the URL of an
+         OAuth discovery document describing the authorization server's
+         supported flows, and <replaceable>scope</replaceable> contains a
+         (possibly empty) space-separated list of OAuth scopes which are
+         required to access the server. Either or both may be
+         <literal>NULL</literal> to indicate that the information was not
+         discoverable. (In this case, implementations may be able to establish
+         the requirements using some other preconfigured knowledge, or they may
+         choose to fail.)
+        </para>
+        <para>
+         The final output of the hook is <replaceable>token</replaceable>, which
+         must point to a valid Bearer token for use on the connection. (This
+         token should be issued by the
+         <xref linkend="libpq-connect-oauth-issuer"/> and hold the requested
+         scopes, or the connection will be rejected by the server's validator
+         module.) The allocated token string must remain valid until
+         <application>libpq</application> is finished connecting; the hook
+         should set a <replaceable>cleanup</replaceable> callback which will be
+         called when <application>libpq</application> no longer requires it.
+        </para>
+        <para>
+         If an implementation cannot immediately produce a
+         <replaceable>token</replaceable> during the initial call to the hook,
+         it should set the <replaceable>async</replaceable> callback to handle
+         nonblocking communication with the authorization server.
+         <footnote>
+          <para>
+           Performing blocking operations during the
+           <symbol>PQAUTHDATA_OAUTH_BEARER_TOKEN</symbol> hook callback will
+           interfere with nonblocking connection APIs such as
+           <function>PQconnectPoll</function> and prevent concurrent connections
+           from making progress. Applications which only ever use the
+           synchronous connection primitives, such as
+           <function>PQconnectdb</function>, may synchronously retrieve a token
+           during the hook instead of implementing the
+           <replaceable>async</replaceable> callback, but they will necessarily
+           be limited to one connection at a time.
+          </para>
+         </footnote>
+         This will be called to begin the flow immediately upon return from the
+         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
+         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>
+         after setting <replaceable>token</replaceable> when the flow is
+         complete, or <symbol>PGRES_POLLING_FAILED</symbol> to indicate failure.
+        </para>
+        <para>
+         Implementations may wish to store additional data for bookkeeping
+         across calls to the <replaceable>async</replaceable> and
+         <replaceable>cleanup</replaceable> callbacks. The
+         <replaceable>user</replaceable> pointer is provided for this purpose;
+         <application>libpq</application> will not touch its contents and the
+         application may use it at its convenience. (Remember to free any
+         allocations during token cleanup.)
+        </para>
+       </listitem>
+      </varlistentry>
+     </variablelist>
+    </para>
+   </sect3>
+  </sect2>
+
+  <sect2 id="libpq-oauth-debugging">
+   <title>Debugging and Developer Settings</title>
+
+   <para>
+    A "dangerous debugging mode" may be enabled by setting the environment
+    variable <envar>PGOAUTHDEBUG=UNSAFE</envar>. This functionality is provided
+    for ease of local development and testing only. It does several things that
+    you will not want a production system to do:
+
+    <itemizedlist spacing="compact">
+     <listitem>
+      <para>
+       permits the use of unencrypted HTTP during the OAuth provider exchange
+      </para>
+     </listitem>
+     <listitem>
+      <para>
+       allows the system's trusted CA list to be completely replaced using the
+       <envar>PGOAUTHCAFILE</envar> environment variable
+      </para>
+     </listitem>
+     <listitem>
+      <para>
+       prints HTTP traffic (containing several critical secrets) to standard
+       error during the OAuth flow
+      </para>
+     </listitem>
+     <listitem>
+      <para>
+       permits the use of zero-second retry intervals, which can cause the
+       client to busy-loop and pointlessly consume CPU
+      </para>
+     </listitem>
+    </itemizedlist>
+   </para>
+   <warning>
+    <para>
+     Do not share the output of the OAuth flow traffic with third parties. It
+     contains secrets that can be used to attack your clients and servers.
+    </para>
+   </warning>
+  </sect2>
+ </sect1>
+
 
  <sect1 id="libpq-threading">
   <title>Behavior in Threaded Programs</title>
@@ -10092,6 +10525,18 @@ int PQisthreadsafe();
    <application>libpq</application> source code for a way to do cooperative
    locking between <application>libpq</application> and your application.
   </para>
+
+  <para>
+   Similarly, if you are using <productname>Curl</productname> inside your application,
+   <emphasis>and</emphasis> you do not already
+   <ulink url="https://curl.se/libcurl/c/curl_global_init.html">initialize
+   libcurl globally</ulink> before starting new threads, you will need to
+   cooperatively lock (again via <function>PQregisterThreadLock</function>)
+   around any code that may initialize libcurl. This restriction is lifted for
+   more recent versions of <productname>Curl</productname> that are built to support thread-safe
+   initialization; those builds can be identified by the advertisement of a
+   <literal>threadsafe</literal> feature in their version metadata.
+  </para>
  </sect1>
 
 
diff --git a/doc/src/sgml/oauth-validators.sgml b/doc/src/sgml/oauth-validators.sgml
new file mode 100644
index 00000000000..356f11d3bd8
--- /dev/null
+++ b/doc/src/sgml/oauth-validators.sgml
@@ -0,0 +1,414 @@
+<!-- doc/src/sgml/oauth-validators.sgml -->
+
+<chapter id="oauth-validators">
+ <title>OAuth Validator Modules</title>
+ <indexterm zone="oauth-validators">
+  <primary>OAuth Validators</primary>
+ </indexterm>
+ <para>
+  <productname>PostgreSQL</productname> provides infrastructure for creating
+  custom modules to perform server-side validation of OAuth bearer tokens.
+  Because OAuth implementations vary so wildly, and bearer token validation is
+  heavily dependent on the issuing party, the server cannot check the token
+  itself; validator modules provide the integration layer between the server
+  and the OAuth provider in use.
+ </para>
+ <para>
+  OAuth validator modules must at least consist of an initialization function
+  (see <xref linkend="oauth-validator-init"/>) and the required callback for
+  performing validation (see <xref linkend="oauth-validator-callback-validate"/>).
+ </para>
+ <warning>
+  <para>
+   Since a misbehaving validator might let unauthorized users into the database,
+   correct implementation is crucial for server safety. See
+   <xref linkend="oauth-validator-design"/> for design considerations.
+  </para>
+ </warning>
+
+ <sect1 id="oauth-validator-design">
+  <title>Safely Designing a Validator Module</title>
+  <warning>
+   <para>
+    Read and understand the entirety of this section before implementing a
+    validator module. A malfunctioning validator is potentially worse than no
+    authentication at all, both because of the false sense of security it
+    provides, and because it may contribute to attacks against other pieces of
+    an OAuth ecosystem.
+   </para>
+  </warning>
+
+  <sect2 id="oauth-validator-design-responsibilities">
+   <title>Validator Responsibilities</title>
+   <para>
+    Although different modules may take very different approaches to token
+    validation, implementations generally need to perform three separate
+    actions:
+   </para>
+   <variablelist>
+    <varlistentry>
+     <term>Validate the Token</term>
+     <listitem>
+      <para>
+       The validator must first ensure that the presented token is in fact a
+       valid Bearer token for use in client authentication. The correct way to
+       do this depends on the provider, but it generally involves either
+       cryptographic operations to prove that the token was created by a trusted
+       party (offline validation), or the presentation of the token to that
+       trusted party so that it can perform validation for you (online
+       validation).
+      </para>
+      <para>
+       Online validation, usually implemented via
+       <ulink url="https://datatracker.ietf.org/doc/html/rfc7662">OAuth Token
+       Introspection</ulink>, requires fewer steps of a validator module and
+       allows central revocation of a token in the event that it is stolen
+       or misissued. However, it does require the module to make at least one
+       network call per authentication attempt (all of which must complete
+       within the configured <xref linkend="guc-authentication-timeout"/>).
+       Additionally, your provider may not provide introspection endpoints for
+       use by external resource servers.
+      </para>
+      <para>
+       Offline validation is much more involved, typically requiring a validator
+       to maintain a list of trusted signing keys for a provider and then
+       check the token's cryptographic signature along with its contents.
+       Implementations must follow the provider's instructions to the letter,
+       including any verification of issuer ("where is this token from?"),
+       audience ("who is this token for?"), and validity period ("when can this
+       token be used?"). Since there is no communication between the module and
+       the provider, tokens cannot be centrally revoked using this method;
+       offline validator implementations may wish to place restrictions on the
+       maximum length of a token's validity period.
+      </para>
+      <para>
+       If the token cannot be validated, the module should immediately fail.
+       Further authentication/authorization is pointless if the bearer token
+       wasn't issued by a trusted party.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Authorize the Client</term>
+     <listitem>
+      <para>
+       Next the validator must ensure that the end user has given the client
+       permission to access the server on their behalf. This generally involves
+       checking the scopes that have been assigned to the token, to make sure
+       that they cover database access for the current HBA parameters.
+      </para>
+      <para>
+       The purpose of this step is to prevent an OAuth client from obtaining a
+       token under false pretenses. If the validator requires all tokens to
+       carry scopes that cover database access, the provider should then loudly
+       prompt the user to grant that access during the flow. This gives them the
+       opportunity to reject the request if the client isn't supposed to be
+       using their credentials to connect to databases.
+      </para>
+      <para>
+       While it is possible to establish client authorization without explicit
+       scopes by using out-of-band knowledge of the deployed architecture, doing
+       so removes the user from the loop, which prevents them from catching
+       deployment mistakes and allows any such mistakes to be exploited
+       silently. Access to the database must be tightly restricted to only
+       trusted clients
+       <footnote>
+        <para>
+         That is, "trusted" in the sense that the OAuth client and the
+         <productname>PostgreSQL</productname> server are controlled by the same
+         entity. Notably, the Device Authorization client flow supported by
+         libpq does not usually meet this bar, since it's designed for use by
+         public/untrusted clients.
+        </para>
+       </footnote>
+       if users are not prompted for additional scopes.
+      </para>
+      <para>
+       Even if authorization fails, a module may choose to continue to pull
+       authentication information from the token for use in auditing and
+       debugging.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Authenticate the End User</term>
+     <listitem>
+      <para>
+       Finally, the validator should determine a user identifier for the token,
+       either by asking the provider for this information or by extracting it
+       from the token itself, and return that identifier to the server (which
+       will then make a final authorization decision using the HBA
+       configuration). This identifier will be available within the session via
+       <link linkend="functions-info-session-table"><function>system_user</function></link>
+       and recorded in the server logs if <xref linkend="guc-log-connections"/>
+       is enabled.
+      </para>
+      <para>
+       Different providers may record a variety of different authentication
+       information for an end user, typically referred to as
+       <emphasis>claims</emphasis>. Providers usually document which of these
+       claims are trustworthy enough to use for authorization decisions and
+       which are not. (For instance, it would probably not be wise to use an
+       end user's full name as the identifier for authentication, since many
+       providers allow users to change their display names arbitrarily.)
+       Ultimately, the choice of which claim (or combination of claims) to use
+       comes down to the provider implementation and application requirements.
+      </para>
+      <para>
+       Note that anonymous/pseudonymous login is possible as well, by enabling
+       usermap delegation; see
+       <xref linkend="oauth-validator-design-usermap-delegation"/>.
+      </para>
+     </listitem>
+    </varlistentry>
+   </variablelist>
+  </sect2>
+
+  <sect2 id="oauth-validator-design-guidelines">
+   <title>General Coding Guidelines</title>
+   <para>
+    Developers should keep the following in mind when implementing token
+    validation:
+   </para>
+   <variablelist>
+    <varlistentry>
+     <term>Token Confidentiality</term>
+     <listitem>
+      <para>
+       Modules should not write tokens, or pieces of tokens, into the server
+       log. This is true even if the module considers the token invalid; an
+       attacker who confuses a client into communicating with the wrong provider
+       should not be able to retrieve that (otherwise valid) token from the
+       disk.
+      </para>
+      <para>
+       Implementations that send tokens over the network (for example, to
+       perform online token validation with a provider) must authenticate the
+       peer and ensure that strong transport security is in use.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Logging</term>
+     <listitem>
+      <para>
+       Modules may use the same <link linkend="error-message-reporting">logging
+       facilities</link> as standard extensions; however, the rules for emitting
+       log entries to the client are subtly different during the authentication
+       phase of the connection. Generally speaking, modules should log
+       verification problems at the <symbol>COMMERROR</symbol> level and return
+       normally, instead of using <symbol>ERROR</symbol>/<symbol>FATAL</symbol>
+       to unwind the stack, to avoid leaking information to unauthenticated
+       clients.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Interruptibility</term>
+     <listitem>
+      <para>
+       Modules must remain interruptible by signals so that the server can
+       correctly handle authentication timeouts and shutdown signals from
+       <application>pg_ctl</application>. For example, a module receiving
+       <symbol>EINTR</symbol>/<symbol>EAGAIN</symbol> from a blocking call
+       should call <function>CHECK_FOR_INTERRUPTS()</function> before retrying.
+       The same should be done during any long-running loops. Failure to follow
+       this guidance may result in unresponsive backend sessions.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Testing</term>
+     <listitem>
+      <para>
+       The breadth of testing an OAuth system is well beyond the scope of this
+       documentation, but at minimum, negative testing should be considered
+       mandatory. It's trivial to design a module that lets authorized users in;
+       the whole point of the system is to keep unauthorized users out.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Documentation</term>
+     <listitem>
+      <para>
+       Validator implementations should document the contents and format of the
+       authenticated ID that is reported to the server for each end user, since
+       DBAs may need to use this information to construct pg_ident maps. (For
+       instance, is it an email address? an organizational ID number? a UUID?)
+       They should also document whether or not it is safe to use the module in
+       <symbol>delegate_ident_mapping=1</symbol> mode, and what additional
+       configuration is required in order to do so.
+      </para>
+     </listitem>
+    </varlistentry>
+   </variablelist>
+  </sect2>
+
+  <sect2 id="oauth-validator-design-usermap-delegation">
+   <title>Authorizing Users (Usermap Delegation)</title>
+   <para>
+    The standard deliverable of a validation module is the user identifier,
+    which the server will then compare to any configured
+    <link linkend="auth-username-maps"><filename>pg_ident.conf</filename>
+    mappings</link> and determine whether the end user is authorized to connect.
+    However, OAuth is itself an authorization framework, and tokens may carry
+    information about user privileges. For example, a token may be associated
+    with the organizational groups that a user belongs to, or list the roles
+    that a user may assume, and duplicating that knowledge into local usermaps
+    for every server may not be desirable.
+   </para>
+   <para>
+    To bypass username mapping entirely, and have the validator module assume
+    the additional responsibility of authorizing user connections, the HBA may
+    be configured with <xref linkend="auth-oauth-delegate-ident-mapping"/>.
+    The module may then use token scopes or an equivalent method to decide
+    whether the user is allowed to connect under their desired role. The user
+    identifier will still be recorded by the server, but it plays no part in
+    determining whether to continue the connection.
+   </para>
+   <para>
+    Using this scheme, authentication itself is optional. As long as the module
+    reports that the connection is authorized, login will continue even if there
+    is no recorded user identifier at all. This makes it possible to implement
+    anonymous or pseudonymous access to the database, where the third-party
+    provider performs all necessary authentication but does not provide any
+    user-identifying information to the server. (Some providers may create an
+    anonymized ID number that can be recorded instead, for later auditing.)
+   </para>
+   <para>
+    Usermap delegation provides the most architectural flexibility, but it turns
+    the validator module into a single point of failure for connection
+    authorization. Use with caution.
+   </para>
+  </sect2>
+ </sect1>
+
+ <sect1 id="oauth-validator-init">
+  <title>Initialization Functions</title>
+  <indexterm zone="oauth-validator-init">
+   <primary>_PG_oauth_validator_module_init</primary>
+  </indexterm>
+  <para>
+   OAuth validator modules are dynamically loaded from the shared
+   libraries listed in <xref linkend="guc-oauth-validator-libraries"/>.
+   Modules are loaded on demand when requested from a login in progress.
+   The normal library search path is used to locate the library. To
+   provide the validator callbacks and to indicate that the library is an OAuth
+   validator module a function named
+   <function>_PG_oauth_validator_module_init</function> must be provided. The
+   return value of the function must be a pointer to a struct of type
+   <structname>OAuthValidatorCallbacks</structname>, which contains a magic
+   number and pointers to the module's token validation functions. The returned
+   pointer must be of server lifetime, which is typically achieved by defining
+   it as a <literal>static const</literal> variable in global scope.
+<programlisting>
+typedef struct OAuthValidatorCallbacks
+{
+    uint32        magic;            /* must be set to PG_OAUTH_VALIDATOR_MAGIC */
+
+    ValidatorStartupCB startup_cb;
+    ValidatorShutdownCB shutdown_cb;
+    ValidatorValidateCB validate_cb;
+} OAuthValidatorCallbacks;
+
+typedef const OAuthValidatorCallbacks *(*OAuthValidatorModuleInit) (void);
+</programlisting>
+
+   Only the <function>validate_cb</function> callback is required, the others
+   are optional.
+  </para>
+ </sect1>
+
+ <sect1 id="oauth-validator-callbacks">
+  <title>OAuth Validator Callbacks</title>
+  <para>
+   OAuth validator modules implement their functionality by defining a set of
+   callbacks. The server will call them as required to process the
+   authentication request from the user.
+  </para>
+
+  <sect2 id="oauth-validator-callback-startup">
+   <title>Startup Callback</title>
+   <para>
+    The <function>startup_cb</function> callback is executed directly after
+    loading the module. This callback can be used to set up local state and
+    perform additional initialization if required. If the validator module
+    has state it can use <structfield>state->private_data</structfield> to
+    store it.
+
+<programlisting>
+typedef void (*ValidatorStartupCB) (ValidatorModuleState *state);
+</programlisting>
+   </para>
+  </sect2>
+
+  <sect2 id="oauth-validator-callback-validate">
+   <title>Validate Callback</title>
+   <para>
+    The <function>validate_cb</function> callback is executed during the OAuth
+    exchange when a user attempts to authenticate using OAuth.  Any state set in
+    previous calls will be available in <structfield>state->private_data</structfield>.
+
+<programlisting>
+typedef bool (*ValidatorValidateCB) (const ValidatorModuleState *state,
+                                     const char *token, const char *role,
+                                     ValidatorModuleResult *result);
+</programlisting>
+
+    <replaceable>token</replaceable> will contain the bearer token to validate.
+    <application>PostgreSQL</application> has ensured that the token is well-formed syntactically, but no
+    other validation has been performed.  <replaceable>role</replaceable> will
+    contain the role the user has requested to log in as.  The callback must
+    set output parameters in the <literal>result</literal> struct, which is
+    defined as below:
+
+<programlisting>
+typedef struct ValidatorModuleResult
+{
+    bool        authorized;
+    char       *authn_id;
+} ValidatorModuleResult;
+</programlisting>
+
+    The connection will only proceed if the module sets
+    <structfield>result->authorized</structfield> to <literal>true</literal>.  To
+    authenticate the user, the authenticated user name (as determined using the
+    token) shall be palloc'd and returned in the <structfield>result->authn_id</structfield>
+    field.  Alternatively, <structfield>result->authn_id</structfield> may be set to
+    NULL if the token is valid but the associated user identity cannot be
+    determined.
+   </para>
+   <para>
+    A validator may return <literal>false</literal> to signal an internal error,
+    in which case any result parameters are ignored and the connection fails.
+    Otherwise the validator should return <literal>true</literal> to indicate
+    that it has processed the token and made an authorization decision.
+   </para>
+   <para>
+    The behavior after <function>validate_cb</function> returns depends on the
+    specific HBA setup.  Normally, the <structfield>result->authn_id</structfield> user
+    name must exactly match the role that the user is logging in as.  (This
+    behavior may be modified with a usermap.)  But when authenticating against
+    an HBA rule with <literal>delegate_ident_mapping</literal> turned on,
+    <productname>PostgreSQL</productname> will not perform any checks on the value of
+    <structfield>result->authn_id</structfield> at all; in this case it is up to the
+    validator to ensure that the token carries enough privileges for the user to
+    log in under the indicated <replaceable>role</replaceable>.
+   </para>
+  </sect2>
+
+  <sect2 id="oauth-validator-callback-shutdown">
+   <title>Shutdown Callback</title>
+   <para>
+    The <function>shutdown_cb</function> callback is executed when the backend
+    process associated with the connection exits. If the validator module has
+    any allocated state, this callback should free it to avoid resource leaks.
+<programlisting>
+typedef void (*ValidatorShutdownCB) (ValidatorModuleState *state);
+</programlisting>
+   </para>
+  </sect2>
+
+ </sect1>
+</chapter>
diff --git a/doc/src/sgml/postgres.sgml b/doc/src/sgml/postgres.sgml
index 7be25c58507..af476c82fcc 100644
--- a/doc/src/sgml/postgres.sgml
+++ b/doc/src/sgml/postgres.sgml
@@ -229,6 +229,7 @@ break is not needed in a wider output rendering.
   &logicaldecoding;
   &replication-origins;
   &archive-modules;
+  &oauth-validators;
 
  </part>
 
diff --git a/doc/src/sgml/protocol.sgml b/doc/src/sgml/protocol.sgml
index fb5dec1172e..3bd9e68e6ce 100644
--- a/doc/src/sgml/protocol.sgml
+++ b/doc/src/sgml/protocol.sgml
@@ -1688,11 +1688,11 @@ SELCT 1/0;<!-- this typo is intentional -->
 
   <para>
    <firstterm>SASL</firstterm> is a framework for authentication in connection-oriented
-   protocols. At the moment, <productname>PostgreSQL</productname> implements two SASL
-   authentication mechanisms, SCRAM-SHA-256 and SCRAM-SHA-256-PLUS. More
-   might be added in the future. The below steps illustrate how SASL
-   authentication is performed in general, while the next subsection gives
-   more details on SCRAM-SHA-256 and SCRAM-SHA-256-PLUS.
+   protocols. At the moment, <productname>PostgreSQL</productname> implements three
+   SASL authentication mechanisms: SCRAM-SHA-256, SCRAM-SHA-256-PLUS, and
+   OAUTHBEARER. More might be added in the future. The below steps illustrate how SASL
+   authentication is performed in general, while the next subsections give
+   more details on particular mechanisms.
   </para>
 
   <procedure>
@@ -1727,7 +1727,7 @@ SELCT 1/0;<!-- this typo is intentional -->
    <step id="sasl-auth-end">
     <para>
      Finally, when the authentication exchange is completed successfully, the
-     server sends an AuthenticationSASLFinal message, followed
+     server sends an optional AuthenticationSASLFinal message, followed
      immediately by an AuthenticationOk message. The AuthenticationSASLFinal
      contains additional server-to-client data, whose content is particular to the
      selected authentication mechanism. If the authentication mechanism doesn't
@@ -1746,9 +1746,9 @@ SELCT 1/0;<!-- this typo is intentional -->
    <title>SCRAM-SHA-256 Authentication</title>
 
    <para>
-    The implemented SASL mechanisms at the moment
-    are <literal>SCRAM-SHA-256</literal> and its variant with channel
-    binding <literal>SCRAM-SHA-256-PLUS</literal>. They are described in
+    <literal>SCRAM-SHA-256</literal>, and its variant with channel
+    binding <literal>SCRAM-SHA-256-PLUS</literal>, are password-based
+    authentication mechanisms. They are described in
     detail in <ulink url="https://datatracker.ietf.org/doc/html/rfc7677">RFC 7677</ulink>
     and <ulink url="https://datatracker.ietf.org/doc/html/rfc5802">RFC 5802</ulink>.
    </para>
@@ -1850,6 +1850,121 @@ SELCT 1/0;<!-- this typo is intentional -->
     </step>
    </procedure>
   </sect2>
+
+  <sect2 id="sasl-oauthbearer">
+   <title>OAUTHBEARER Authentication</title>
+
+   <para>
+    <literal>OAUTHBEARER</literal> is a token-based mechanism for federated
+    authentication. It is described in detail in
+    <ulink url="https://datatracker.ietf.org/doc/html/rfc7628">RFC 7628</ulink>.
+   </para>
+
+   <para>
+    A typical exchange differs depending on whether or not the client already
+    has a bearer token cached for the current user. If it does not, the exchange
+    will take place over two connections: the first "discovery" connection to
+    obtain OAuth metadata from the server, and the second connection to send
+    the token after the client has obtained it. (libpq does not currently
+    implement a caching method as part of its builtin flow, so it uses the
+    two-connection exchange.)
+   </para>
+
+   <para>
+    This mechanism is client-initiated, like SCRAM. The client initial response
+    consists of the standard "GS2" header used by SCRAM, followed by a list of
+    <literal>key=value</literal> pairs. The only key currently supported by
+    the server is <literal>auth</literal>, which contains the bearer token.
+    <literal>OAUTHBEARER</literal> additionally specifies three optional
+    components of the client initial response (the <literal>authzid</literal> of
+    the GS2 header, and the <structfield>host</structfield> and
+    <structfield>port</structfield> keys) which are currently ignored by the
+    server.
+   </para>
+
+   <para>
+    <literal>OAUTHBEARER</literal> does not support channel binding, and there
+    is no "OAUTHBEARER-PLUS" mechanism. This mechanism does not make use of
+    server data during a successful authentication, so the
+    AuthenticationSASLFinal message is not used in the exchange.
+   </para>
+
+   <procedure>
+    <title>Example</title>
+    <step>
+     <para>
+      During the first exchange, the server sends an AuthenticationSASL message
+      with the <literal>OAUTHBEARER</literal> mechanism advertised.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      The client responds by sending a SASLInitialResponse message which
+      indicates the <literal>OAUTHBEARER</literal> mechanism. Assuming the
+      client does not already have a valid bearer token for the current user,
+      the <structfield>auth</structfield> field is empty, indicating a discovery
+      connection.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      Server sends an AuthenticationSASLContinue message containing an error
+      <literal>status</literal> alongside a well-known URI and scopes that the
+      client should use to conduct an OAuth flow.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      Client sends a SASLResponse message containing the empty set (a single
+      <literal>0x01</literal> byte) to finish its half of the discovery
+      exchange.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      Server sends an ErrorMessage to fail the first exchange.
+     </para>
+     <para>
+      At this point, the client conducts one of many possible OAuth flows to
+      obtain a bearer token, using any metadata that it has been configured with
+      in addition to that provided by the server. (This description is left
+      deliberately vague; <literal>OAUTHBEARER</literal> does not specify or
+      mandate any particular method for obtaining a token.)
+     </para>
+     <para>
+      Once it has a token, the client reconnects to the server for the final
+      exchange:
+     </para>
+    </step>
+
+    <step>
+     <para>
+      The server once again sends an AuthenticationSASL message with the
+      <literal>OAUTHBEARER</literal> mechanism advertised.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      The client responds by sending a SASLInitialResponse message, but this
+      time the <structfield>auth</structfield> field in the message contains the
+      bearer token that was obtained during the client flow.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      The server validates the token according to the instructions of the
+      token provider. If the client is authorized to connect, it sends an
+      AuthenticationOk message to end the SASL exchange.
+     </para>
+    </step>
+   </procedure>
+  </sect2>
  </sect1>
 
  <sect1 id="protocol-replication">
diff --git a/doc/src/sgml/regress.sgml b/doc/src/sgml/regress.sgml
index 7c474559bdf..0e5e8e8f309 100644
--- a/doc/src/sgml/regress.sgml
+++ b/doc/src/sgml/regress.sgml
@@ -347,6 +347,16 @@ make check-world PG_TEST_EXTRA='kerberos ldap ssl load_balance libpq_encryption'
       </para>
      </listitem>
     </varlistentry>
+
+    <varlistentry>
+     <term><literal>oauth</literal></term>
+     <listitem>
+      <para>
+       Runs the test suite under <filename>src/test/modules/oauth_validator</filename>.
+       This opens TCP/IP listen sockets for a test-server running HTTPS.
+      </para>
+     </listitem>
+    </varlistentry>
    </variablelist>
 
    Tests for features that are not supported by the current build
diff --git a/meson.build b/meson.build
index 7dd7110318d..574f992ed49 100644
--- a/meson.build
+++ b/meson.build
@@ -855,6 +855,101 @@ endif
 
 
 
+###############################################################
+# Library: libcurl
+###############################################################
+
+libcurlopt = get_option('libcurl')
+if not libcurlopt.disabled()
+  # Check for libcurl 7.61.0 or higher (corresponding to RHEL8 and the ability
+  # to explicitly set TLS 1.3 ciphersuites).
+  libcurl = dependency('libcurl', version: '>= 7.61.0', required: libcurlopt)
+  if libcurl.found()
+    cdata.set('USE_LIBCURL', 1)
+
+    # Check to see whether the current platform supports thread-safe Curl
+    # initialization.
+    libcurl_threadsafe_init = false
+
+    if not meson.is_cross_build()
+      r = cc.run('''
+        #include <curl/curl.h>
+
+        int main(void)
+        {
+            curl_version_info_data *info;
+
+            if (curl_global_init(CURL_GLOBAL_ALL))
+                return -1;
+
+            info = curl_version_info(CURLVERSION_NOW);
+        #ifdef CURL_VERSION_THREADSAFE
+            if (info->features & CURL_VERSION_THREADSAFE)
+                return 0;
+        #endif
+
+            return 1;
+        }''',
+        name: 'test for curl_global_init thread safety',
+        dependencies: libcurl,
+      )
+
+      assert(r.compiled())
+      if r.returncode() == 0
+        libcurl_threadsafe_init = true
+        message('curl_global_init is thread-safe')
+      elif r.returncode() == 1
+        message('curl_global_init is not thread-safe')
+      else
+        message('curl_global_init failed; assuming not thread-safe')
+      endif
+    endif
+
+    if libcurl_threadsafe_init
+      cdata.set('HAVE_THREADSAFE_CURL_GLOBAL_INIT', 1)
+    endif
+
+    # Warn if a thread-friendly DNS resolver isn't built.
+    libcurl_async_dns = false
+
+    if not meson.is_cross_build()
+      r = cc.run('''
+        #include <curl/curl.h>
+
+        int main(void)
+        {
+            curl_version_info_data *info;
+
+            if (curl_global_init(CURL_GLOBAL_ALL))
+                return -1;
+
+            info = curl_version_info(CURLVERSION_NOW);
+            return (info->features & CURL_VERSION_ASYNCHDNS) ? 0 : 1;
+        }''',
+        name: 'test for curl support for asynchronous DNS',
+        dependencies: libcurl,
+      )
+
+      assert(r.compiled())
+      if r.returncode() == 0
+        libcurl_async_dns = true
+      endif
+    endif
+
+    if not libcurl_async_dns
+      warning('''
+*** The installed version of libcurl does not support asynchronous DNS
+*** lookups. Connection timeouts will not be honored during DNS resolution,
+*** which may lead to hangs in client programs.''')
+    endif
+  endif
+
+else
+  libcurl = not_found_dep
+endif
+
+
+
 ###############################################################
 # Library: libxml
 ###############################################################
@@ -3045,6 +3140,10 @@ libpq_deps += [
 
   gssapi,
   ldap_r,
+  # XXX libcurl must link after libgssapi_krb5 on FreeBSD to avoid segfaults
+  # during gss_acquire_cred(). This is possibly related to Curl's Heimdal
+  # dependency on that platform?
+  libcurl,
   libintl,
   ssl,
 ]
@@ -3721,6 +3820,7 @@ if meson.version().version_compare('>=0.57')
       'gss': gssapi,
       'icu': icu,
       'ldap': ldap,
+      'libcurl': libcurl,
       'libxml': libxml,
       'libxslt': libxslt,
       'llvm': llvm,
diff --git a/meson_options.txt b/meson_options.txt
index d9c7ddccbc4..702c4517145 100644
--- a/meson_options.txt
+++ b/meson_options.txt
@@ -100,6 +100,9 @@ option('icu', type: 'feature', value: 'auto',
 option('ldap', type: 'feature', value: 'auto',
   description: 'LDAP support')
 
+option('libcurl', type : 'feature', value: 'auto',
+  description: 'libcurl support')
+
 option('libedit_preferred', type: 'boolean', value: false,
   description: 'Prefer BSD Libedit over GNU Readline')
 
diff --git a/src/Makefile.global.in b/src/Makefile.global.in
index bbe11e75bf0..3b620bac5ac 100644
--- a/src/Makefile.global.in
+++ b/src/Makefile.global.in
@@ -190,6 +190,7 @@ with_systemd	= @with_systemd@
 with_gssapi	= @with_gssapi@
 with_krb_srvnam	= @with_krb_srvnam@
 with_ldap	= @with_ldap@
+with_libcurl	= @with_libcurl@
 with_libxml	= @with_libxml@
 with_libxslt	= @with_libxslt@
 with_llvm	= @with_llvm@
diff --git a/src/backend/libpq/Makefile b/src/backend/libpq/Makefile
index 6d385fd6a45..98eb2a8242d 100644
--- a/src/backend/libpq/Makefile
+++ b/src/backend/libpq/Makefile
@@ -15,6 +15,7 @@ include $(top_builddir)/src/Makefile.global
 # be-fsstubs is here for historical reasons, probably belongs elsewhere
 
 OBJS = \
+	auth-oauth.o \
 	auth-sasl.o \
 	auth-scram.o \
 	auth.o \
diff --git a/src/backend/libpq/auth-oauth.c b/src/backend/libpq/auth-oauth.c
new file mode 100644
index 00000000000..27f7af7be00
--- /dev/null
+++ b/src/backend/libpq/auth-oauth.c
@@ -0,0 +1,894 @@
+/*-------------------------------------------------------------------------
+ *
+ * auth-oauth.c
+ *	  Server-side implementation of the SASL OAUTHBEARER mechanism.
+ *
+ * See the following RFC for more details:
+ * - RFC 7628: https://datatracker.ietf.org/doc/html/rfc7628
+ *
+ * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/backend/libpq/auth-oauth.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include <unistd.h>
+#include <fcntl.h>
+
+#include "common/oauth-common.h"
+#include "fmgr.h"
+#include "lib/stringinfo.h"
+#include "libpq/auth.h"
+#include "libpq/hba.h"
+#include "libpq/oauth.h"
+#include "libpq/sasl.h"
+#include "storage/fd.h"
+#include "storage/ipc.h"
+#include "utils/json.h"
+#include "utils/varlena.h"
+
+/* GUC */
+char	   *oauth_validator_libraries_string = NULL;
+
+static void oauth_get_mechanisms(Port *port, StringInfo buf);
+static void *oauth_init(Port *port, const char *selected_mech, const char *shadow_pass);
+static int	oauth_exchange(void *opaq, const char *input, int inputlen,
+						   char **output, int *outputlen, const char **logdetail);
+
+static void load_validator_library(const char *libname);
+static void shutdown_validator_library(void *arg);
+
+static ValidatorModuleState *validator_module_state;
+static const OAuthValidatorCallbacks *ValidatorCallbacks;
+
+/* Mechanism declaration */
+const pg_be_sasl_mech pg_be_oauth_mech = {
+	.get_mechanisms = oauth_get_mechanisms,
+	.init = oauth_init,
+	.exchange = oauth_exchange,
+
+	.max_message_length = PG_MAX_AUTH_TOKEN_LENGTH,
+};
+
+/* Valid states for the oauth_exchange() machine. */
+enum oauth_state
+{
+	OAUTH_STATE_INIT = 0,
+	OAUTH_STATE_ERROR,
+	OAUTH_STATE_FINISHED,
+};
+
+/* Mechanism callback state. */
+struct oauth_ctx
+{
+	enum oauth_state state;
+	Port	   *port;
+	const char *issuer;
+	const char *scope;
+};
+
+static char *sanitize_char(char c);
+static char *parse_kvpairs_for_auth(char **input);
+static void generate_error_response(struct oauth_ctx *ctx, char **output, int *outputlen);
+static bool validate(Port *port, const char *auth);
+
+/* Constants seen in an OAUTHBEARER client initial response. */
+#define KVSEP 0x01				/* separator byte for key/value pairs */
+#define AUTH_KEY "auth"			/* key containing the Authorization header */
+#define BEARER_SCHEME "Bearer " /* required header scheme (case-insensitive!) */
+
+/*
+ * Retrieves the OAUTHBEARER mechanism list (currently a single item).
+ *
+ * For a full description of the API, see libpq/sasl.h.
+ */
+static void
+oauth_get_mechanisms(Port *port, StringInfo buf)
+{
+	/* Only OAUTHBEARER is supported. */
+	appendStringInfoString(buf, OAUTHBEARER_NAME);
+	appendStringInfoChar(buf, '\0');
+}
+
+/*
+ * Initializes mechanism state and loads the configured validator module.
+ *
+ * For a full description of the API, see libpq/sasl.h.
+ */
+static void *
+oauth_init(Port *port, const char *selected_mech, const char *shadow_pass)
+{
+	struct oauth_ctx *ctx;
+
+	if (strcmp(selected_mech, OAUTHBEARER_NAME) != 0)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("client selected an invalid SASL authentication mechanism"));
+
+	ctx = palloc0(sizeof(*ctx));
+
+	ctx->state = OAUTH_STATE_INIT;
+	ctx->port = port;
+
+	Assert(port->hba);
+	ctx->issuer = port->hba->oauth_issuer;
+	ctx->scope = port->hba->oauth_scope;
+
+	load_validator_library(port->hba->oauth_validator);
+
+	return ctx;
+}
+
+/*
+ * Implements the OAUTHBEARER SASL exchange (RFC 7628, Sec. 3.2). This pulls
+ * apart the client initial response and validates the Bearer token. It also
+ * handles the dummy error response for a failed handshake, as described in
+ * Sec. 3.2.3.
+ *
+ * For a full description of the API, see libpq/sasl.h.
+ */
+static int
+oauth_exchange(void *opaq, const char *input, int inputlen,
+			   char **output, int *outputlen, const char **logdetail)
+{
+	char	   *input_copy;
+	char	   *p;
+	char		cbind_flag;
+	char	   *auth;
+	int			status;
+
+	struct oauth_ctx *ctx = opaq;
+
+	*output = NULL;
+	*outputlen = -1;
+
+	/*
+	 * If the client didn't include an "Initial Client Response" in the
+	 * SASLInitialResponse message, send an empty challenge, to which the
+	 * client will respond with the same data that usually comes in the
+	 * Initial Client Response.
+	 */
+	if (input == NULL)
+	{
+		Assert(ctx->state == OAUTH_STATE_INIT);
+
+		*output = pstrdup("");
+		*outputlen = 0;
+		return PG_SASL_EXCHANGE_CONTINUE;
+	}
+
+	/*
+	 * Check that the input length agrees with the string length of the input.
+	 */
+	if (inputlen == 0)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("The message is empty."));
+	if (inputlen != strlen(input))
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message length does not match input length."));
+
+	switch (ctx->state)
+	{
+		case OAUTH_STATE_INIT:
+			/* Handle this case below. */
+			break;
+
+		case OAUTH_STATE_ERROR:
+
+			/*
+			 * Only one response is valid for the client during authentication
+			 * failure: a single kvsep.
+			 */
+			if (inputlen != 1 || *input != KVSEP)
+				ereport(ERROR,
+						errcode(ERRCODE_PROTOCOL_VIOLATION),
+						errmsg("malformed OAUTHBEARER message"),
+						errdetail("Client did not send a kvsep response."));
+
+			/* The (failed) handshake is now complete. */
+			ctx->state = OAUTH_STATE_FINISHED;
+			return PG_SASL_EXCHANGE_FAILURE;
+
+		default:
+			elog(ERROR, "invalid OAUTHBEARER exchange state");
+			return PG_SASL_EXCHANGE_FAILURE;
+	}
+
+	/* Handle the client's initial message. */
+	p = input_copy = pstrdup(input);
+
+	/*
+	 * OAUTHBEARER does not currently define a channel binding (so there is no
+	 * OAUTHBEARER-PLUS, and we do not accept a 'p' specifier). We accept a
+	 * 'y' specifier purely for the remote chance that a future specification
+	 * could define one; then future clients can still interoperate with this
+	 * server implementation. 'n' is the expected case.
+	 */
+	cbind_flag = *p;
+	switch (cbind_flag)
+	{
+		case 'p':
+			ereport(ERROR,
+					errcode(ERRCODE_PROTOCOL_VIOLATION),
+					errmsg("malformed OAUTHBEARER message"),
+					errdetail("The server does not support channel binding for OAuth, but the client message includes channel binding data."));
+			break;
+
+		case 'y':				/* fall through */
+		case 'n':
+			p++;
+			if (*p != ',')
+				ereport(ERROR,
+						errcode(ERRCODE_PROTOCOL_VIOLATION),
+						errmsg("malformed OAUTHBEARER message"),
+						errdetail("Comma expected, but found character \"%s\".",
+								  sanitize_char(*p)));
+			p++;
+			break;
+
+		default:
+			ereport(ERROR,
+					errcode(ERRCODE_PROTOCOL_VIOLATION),
+					errmsg("malformed OAUTHBEARER message"),
+					errdetail("Unexpected channel-binding flag \"%s\".",
+							  sanitize_char(cbind_flag)));
+	}
+
+	/*
+	 * Forbid optional authzid (authorization identity).  We don't support it.
+	 */
+	if (*p == 'a')
+		ereport(ERROR,
+				errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				errmsg("client uses authorization identity, but it is not supported"));
+	if (*p != ',')
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Unexpected attribute \"%s\" in client-first-message.",
+						  sanitize_char(*p)));
+	p++;
+
+	/* All remaining fields are separated by the RFC's kvsep (\x01). */
+	if (*p != KVSEP)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Key-value separator expected, but found character \"%s\".",
+						  sanitize_char(*p)));
+	p++;
+
+	auth = parse_kvpairs_for_auth(&p);
+	if (!auth)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message does not contain an auth value."));
+
+	/* We should be at the end of our message. */
+	if (*p)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message contains additional data after the final terminator."));
+
+	if (!validate(ctx->port, auth))
+	{
+		generate_error_response(ctx, output, outputlen);
+
+		ctx->state = OAUTH_STATE_ERROR;
+		status = PG_SASL_EXCHANGE_CONTINUE;
+	}
+	else
+	{
+		ctx->state = OAUTH_STATE_FINISHED;
+		status = PG_SASL_EXCHANGE_SUCCESS;
+	}
+
+	/* Don't let extra copies of the bearer token hang around. */
+	explicit_bzero(input_copy, inputlen);
+
+	return status;
+}
+
+/*
+ * Convert an arbitrary byte to printable form.  For error messages.
+ *
+ * If it's a printable ASCII character, print it as a single character.
+ * otherwise, print it in hex.
+ *
+ * The returned pointer points to a static buffer.
+ */
+static char *
+sanitize_char(char c)
+{
+	static char buf[5];
+
+	if (c >= 0x21 && c <= 0x7E)
+		snprintf(buf, sizeof(buf), "'%c'", c);
+	else
+		snprintf(buf, sizeof(buf), "0x%02x", (unsigned char) c);
+	return buf;
+}
+
+/*
+ * Performs syntactic validation of a key and value from the initial client
+ * response. (Semantic validation of interesting values must be performed
+ * later.)
+ */
+static void
+validate_kvpair(const char *key, const char *val)
+{
+	/*-----
+	 * From Sec 3.1:
+	 *     key            = 1*(ALPHA)
+	 */
+	static const char *key_allowed_set =
+		"abcdefghijklmnopqrstuvwxyz"
+		"ABCDEFGHIJKLMNOPQRSTUVWXYZ";
+
+	size_t		span;
+
+	if (!key[0])
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message contains an empty key name."));
+
+	span = strspn(key, key_allowed_set);
+	if (key[span] != '\0')
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message contains an invalid key name."));
+
+	/*-----
+	 * From Sec 3.1:
+	 *     value          = *(VCHAR / SP / HTAB / CR / LF )
+	 *
+	 * The VCHAR (visible character) class is large; a loop is more
+	 * straightforward than strspn().
+	 */
+	for (; *val; ++val)
+	{
+		if (0x21 <= *val && *val <= 0x7E)
+			continue;			/* VCHAR */
+
+		switch (*val)
+		{
+			case ' ':
+			case '\t':
+			case '\r':
+			case '\n':
+				continue;		/* SP, HTAB, CR, LF */
+
+			default:
+				ereport(ERROR,
+						errcode(ERRCODE_PROTOCOL_VIOLATION),
+						errmsg("malformed OAUTHBEARER message"),
+						errdetail("Message contains an invalid value."));
+		}
+	}
+}
+
+/*
+ * Consumes all kvpairs in an OAUTHBEARER exchange message. If the "auth" key is
+ * found, its value is returned.
+ */
+static char *
+parse_kvpairs_for_auth(char **input)
+{
+	char	   *pos = *input;
+	char	   *auth = NULL;
+
+	/*----
+	 * The relevant ABNF, from Sec. 3.1:
+	 *
+	 *     kvsep          = %x01
+	 *     key            = 1*(ALPHA)
+	 *     value          = *(VCHAR / SP / HTAB / CR / LF )
+	 *     kvpair         = key "=" value kvsep
+	 *   ;;gs2-header     = See RFC 5801
+	 *     client-resp    = (gs2-header kvsep *kvpair kvsep) / kvsep
+	 *
+	 * By the time we reach this code, the gs2-header and initial kvsep have
+	 * already been validated. We start at the beginning of the first kvpair.
+	 */
+
+	while (*pos)
+	{
+		char	   *end;
+		char	   *sep;
+		char	   *key;
+		char	   *value;
+
+		/*
+		 * Find the end of this kvpair. Note that input is null-terminated by
+		 * the SASL code, so the strchr() is bounded.
+		 */
+		end = strchr(pos, KVSEP);
+		if (!end)
+			ereport(ERROR,
+					errcode(ERRCODE_PROTOCOL_VIOLATION),
+					errmsg("malformed OAUTHBEARER message"),
+					errdetail("Message contains an unterminated key/value pair."));
+		*end = '\0';
+
+		if (pos == end)
+		{
+			/* Empty kvpair, signifying the end of the list. */
+			*input = pos + 1;
+			return auth;
+		}
+
+		/*
+		 * Find the end of the key name.
+		 */
+		sep = strchr(pos, '=');
+		if (!sep)
+			ereport(ERROR,
+					errcode(ERRCODE_PROTOCOL_VIOLATION),
+					errmsg("malformed OAUTHBEARER message"),
+					errdetail("Message contains a key without a value."));
+		*sep = '\0';
+
+		/* Both key and value are now safely terminated. */
+		key = pos;
+		value = sep + 1;
+		validate_kvpair(key, value);
+
+		if (strcmp(key, AUTH_KEY) == 0)
+		{
+			if (auth)
+				ereport(ERROR,
+						errcode(ERRCODE_PROTOCOL_VIOLATION),
+						errmsg("malformed OAUTHBEARER message"),
+						errdetail("Message contains multiple auth values."));
+
+			auth = value;
+		}
+		else
+		{
+			/*
+			 * The RFC also defines the host and port keys, but they are not
+			 * required for OAUTHBEARER and we do not use them. Also, per Sec.
+			 * 3.1, any key/value pairs we don't recognize must be ignored.
+			 */
+		}
+
+		/* Move to the next pair. */
+		pos = end + 1;
+	}
+
+	ereport(ERROR,
+			errcode(ERRCODE_PROTOCOL_VIOLATION),
+			errmsg("malformed OAUTHBEARER message"),
+			errdetail("Message did not contain a final terminator."));
+
+	pg_unreachable();
+	return NULL;
+}
+
+/*
+ * Builds the JSON response for failed authentication (RFC 7628, Sec. 3.2.2).
+ * This contains the required scopes for entry and a pointer to the OAuth/OpenID
+ * discovery document, which the client may use to conduct its OAuth flow.
+ */
+static void
+generate_error_response(struct oauth_ctx *ctx, char **output, int *outputlen)
+{
+	StringInfoData buf;
+	StringInfoData issuer;
+
+	/*
+	 * The admin needs to set an issuer and scope for OAuth to work. There's
+	 * not really a way to hide this from the user, either, because we can't
+	 * choose a "default" issuer, so be honest in the failure message. (In
+	 * practice such configurations are rejected during HBA parsing.)
+	 */
+	if (!ctx->issuer || !ctx->scope)
+		ereport(FATAL,
+				errcode(ERRCODE_INTERNAL_ERROR),
+				errmsg("OAuth is not properly configured for this user"),
+				errdetail_log("The issuer and scope parameters must be set in pg_hba.conf."));
+
+	/*
+	 * Build a default .well-known URI based on our issuer, unless the HBA has
+	 * already provided one.
+	 */
+	initStringInfo(&issuer);
+	appendStringInfoString(&issuer, ctx->issuer);
+	if (strstr(ctx->issuer, "/.well-known/") == NULL)
+		appendStringInfoString(&issuer, "/.well-known/openid-configuration");
+
+	initStringInfo(&buf);
+
+	/*
+	 * Escaping the string here is belt-and-suspenders defensive programming
+	 * since escapable characters aren't valid in either the issuer URI or the
+	 * scope list, but the HBA doesn't enforce that yet.
+	 */
+	appendStringInfoString(&buf, "{ \"status\": \"invalid_token\", ");
+
+	appendStringInfoString(&buf, "\"openid-configuration\": ");
+	escape_json(&buf, issuer.data);
+	pfree(issuer.data);
+
+	appendStringInfoString(&buf, ", \"scope\": ");
+	escape_json(&buf, ctx->scope);
+
+	appendStringInfoString(&buf, " }");
+
+	*output = buf.data;
+	*outputlen = buf.len;
+}
+
+/*-----
+ * Validates the provided Authorization header and returns the token from
+ * within it. NULL is returned on validation failure.
+ *
+ * Only Bearer tokens are accepted. The ABNF is defined in RFC 6750, Sec.
+ * 2.1:
+ *
+ *      b64token    = 1*( ALPHA / DIGIT /
+ *                        "-" / "." / "_" / "~" / "+" / "/" ) *"="
+ *      credentials = "Bearer" 1*SP b64token
+ *
+ * The "credentials" construction is what we receive in our auth value.
+ *
+ * Since that spec is subordinate to HTTP (i.e. the HTTP Authorization
+ * header format; RFC 9110 Sec. 11), the "Bearer" scheme string must be
+ * compared case-insensitively. (This is not mentioned in RFC 6750, but the
+ * OAUTHBEARER spec points it out: RFC 7628 Sec. 4.)
+ *
+ * Invalid formats are technically a protocol violation, but we shouldn't
+ * reflect any information about the sensitive Bearer token back to the
+ * client; log at COMMERROR instead.
+ */
+static const char *
+validate_token_format(const char *header)
+{
+	size_t		span;
+	const char *token;
+	static const char *const b64token_allowed_set =
+		"abcdefghijklmnopqrstuvwxyz"
+		"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+		"0123456789-._~+/";
+
+	/* Missing auth headers should be handled by the caller. */
+	Assert(header);
+
+	if (header[0] == '\0')
+	{
+		/*
+		 * A completely empty auth header represents a query for
+		 * authentication parameters. The client expects it to fail; there's
+		 * no need to make any extra noise in the logs.
+		 *
+		 * TODO: should we find a way to return STATUS_EOF at the top level,
+		 * to suppress the authentication error entirely?
+		 */
+		return NULL;
+	}
+
+	if (pg_strncasecmp(header, BEARER_SCHEME, strlen(BEARER_SCHEME)))
+	{
+		ereport(COMMERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAuth bearer token"),
+				errdetail_log("Client response indicated a non-Bearer authentication scheme."));
+		return NULL;
+	}
+
+	/* Pull the bearer token out of the auth value. */
+	token = header + strlen(BEARER_SCHEME);
+
+	/* Swallow any additional spaces. */
+	while (*token == ' ')
+		token++;
+
+	/* Tokens must not be empty. */
+	if (!*token)
+	{
+		ereport(COMMERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAuth bearer token"),
+				errdetail_log("Bearer token is empty."));
+		return NULL;
+	}
+
+	/*
+	 * Make sure the token contains only allowed characters. Tokens may end
+	 * with any number of '=' characters.
+	 */
+	span = strspn(token, b64token_allowed_set);
+	while (token[span] == '=')
+		span++;
+
+	if (token[span] != '\0')
+	{
+		/*
+		 * This error message could be more helpful by printing the
+		 * problematic character(s), but that'd be a bit like printing a piece
+		 * of someone's password into the logs.
+		 */
+		ereport(COMMERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAuth bearer token"),
+				errdetail_log("Bearer token is not in the correct format."));
+		return NULL;
+	}
+
+	return token;
+}
+
+/*
+ * Checks that the "auth" kvpair in the client response contains a syntactically
+ * valid Bearer token, then passes it along to the loaded validator module for
+ * authorization. Returns true if validation succeeds.
+ */
+static bool
+validate(Port *port, const char *auth)
+{
+	int			map_status;
+	ValidatorModuleResult *ret;
+	const char *token;
+	bool		status;
+
+	/* Ensure that we have a correct token to validate */
+	if (!(token = validate_token_format(auth)))
+		return false;
+
+	/*
+	 * Ensure that we have a validation library loaded, this should always be
+	 * the case and an error here is indicative of a bug.
+	 */
+	if (!ValidatorCallbacks || !ValidatorCallbacks->validate_cb)
+		ereport(FATAL,
+				errcode(ERRCODE_INTERNAL_ERROR),
+				errmsg("validation of OAuth token requested without a validator loaded"));
+
+	/* Call the validation function from the validator module */
+	ret = palloc0(sizeof(ValidatorModuleResult));
+	if (!ValidatorCallbacks->validate_cb(validator_module_state, token,
+										 port->user_name, ret))
+	{
+		ereport(WARNING,
+				errcode(ERRCODE_INTERNAL_ERROR),
+				errmsg("internal error in OAuth validator module"));
+		return false;
+	}
+
+	/*
+	 * Log any authentication results even if the token isn't authorized; it
+	 * might be useful for auditing or troubleshooting.
+	 */
+	if (ret->authn_id)
+		set_authn_id(port, ret->authn_id);
+
+	if (!ret->authorized)
+	{
+		ereport(LOG,
+				errmsg("OAuth bearer authentication failed for user \"%s\"",
+					   port->user_name),
+				errdetail_log("Validator failed to authorize the provided token."));
+
+		status = false;
+		goto cleanup;
+	}
+
+	if (port->hba->oauth_skip_usermap)
+	{
+		/*
+		 * If the validator is our authorization authority, we're done.
+		 * Authentication may or may not have been performed depending on the
+		 * validator implementation; all that matters is that the validator
+		 * says the user can log in with the target role.
+		 */
+		status = true;
+		goto cleanup;
+	}
+
+	/* Make sure the validator authenticated the user. */
+	if (ret->authn_id == NULL || ret->authn_id[0] == '\0')
+	{
+		ereport(LOG,
+				errmsg("OAuth bearer authentication failed for user \"%s\"",
+					   port->user_name),
+				errdetail_log("Validator provided no identity."));
+
+		status = false;
+		goto cleanup;
+	}
+
+	/* Finally, check the user map. */
+	map_status = check_usermap(port->hba->usermap, port->user_name,
+							   MyClientConnectionInfo.authn_id, false);
+	status = (map_status == STATUS_OK);
+
+cleanup:
+
+	/*
+	 * Clear and free the validation result from the validator module once
+	 * we're done with it.
+	 */
+	if (ret->authn_id != NULL)
+		pfree(ret->authn_id);
+	pfree(ret);
+
+	return status;
+}
+
+/*
+ * load_validator_library
+ *
+ * Load the configured validator library in order to perform token validation.
+ * There is no built-in fallback since validation is implementation specific. If
+ * no validator library is configured, or if it fails to load, then error out
+ * since token validation won't be possible.
+ */
+static void
+load_validator_library(const char *libname)
+{
+	OAuthValidatorModuleInit validator_init;
+	MemoryContextCallback *mcb;
+
+	/*
+	 * The presence, and validity, of libname has already been established by
+	 * check_oauth_validator so we don't need to perform more than Assert
+	 * level checking here.
+	 */
+	Assert(libname && *libname);
+
+	validator_init = (OAuthValidatorModuleInit)
+		load_external_function(libname, "_PG_oauth_validator_module_init",
+							   false, NULL);
+
+	/*
+	 * The validator init function is required since it will set the callbacks
+	 * for the validator library.
+	 */
+	if (validator_init == NULL)
+		ereport(ERROR,
+				errmsg("%s module \"%s\" must define the symbol %s",
+					   "OAuth validator", libname, "_PG_oauth_validator_module_init"));
+
+	ValidatorCallbacks = (*validator_init) ();
+	Assert(ValidatorCallbacks);
+
+	/*
+	 * Check the magic number, to protect against break-glass scenarios where
+	 * the ABI must change within a major version. load_external_function()
+	 * already checks for compatibility across major versions.
+	 */
+	if (ValidatorCallbacks->magic != PG_OAUTH_VALIDATOR_MAGIC)
+		ereport(ERROR,
+				errmsg("%s module \"%s\": magic number mismatch",
+					   "OAuth validator", libname),
+				errdetail("Server has magic number 0x%08X, module has 0x%08X.",
+						  PG_OAUTH_VALIDATOR_MAGIC, ValidatorCallbacks->magic));
+
+	/*
+	 * Make sure all required callbacks are present in the ValidatorCallbacks
+	 * structure. Right now only the validation callback is required.
+	 */
+	if (ValidatorCallbacks->validate_cb == NULL)
+		ereport(ERROR,
+				errmsg("%s module \"%s\" must provide a %s callback",
+					   "OAuth validator", libname, "validate_cb"));
+
+	/* Allocate memory for validator library private state data */
+	validator_module_state = (ValidatorModuleState *) palloc0(sizeof(ValidatorModuleState));
+	validator_module_state->sversion = PG_VERSION_NUM;
+
+	if (ValidatorCallbacks->startup_cb != NULL)
+		ValidatorCallbacks->startup_cb(validator_module_state);
+
+	/* Shut down the library before cleaning up its state. */
+	mcb = palloc0(sizeof(*mcb));
+	mcb->func = shutdown_validator_library;
+
+	MemoryContextRegisterResetCallback(CurrentMemoryContext, mcb);
+}
+
+/*
+ * Call the validator module's shutdown callback, if one is provided. This is
+ * invoked during memory context reset.
+ */
+static void
+shutdown_validator_library(void *arg)
+{
+	if (ValidatorCallbacks->shutdown_cb != NULL)
+		ValidatorCallbacks->shutdown_cb(validator_module_state);
+}
+
+/*
+ * Ensure an OAuth validator named in the HBA is permitted by the configuration.
+ *
+ * If the validator is currently unset and exactly one library is declared in
+ * oauth_validator_libraries, then that library will be used as the validator.
+ * Otherwise the name must be present in the list of oauth_validator_libraries.
+ */
+bool
+check_oauth_validator(HbaLine *hbaline, int elevel, char **err_msg)
+{
+	int			line_num = hbaline->linenumber;
+	const char *file_name = hbaline->sourcefile;
+	char	   *rawstring;
+	List	   *elemlist = NIL;
+
+	*err_msg = NULL;
+
+	if (oauth_validator_libraries_string[0] == '\0')
+	{
+		ereport(elevel,
+				errcode(ERRCODE_CONFIG_FILE_ERROR),
+				errmsg("oauth_validator_libraries must be set for authentication method %s",
+					   "oauth"),
+				errcontext("line %d of configuration file \"%s\"",
+						   line_num, file_name));
+		*err_msg = psprintf("oauth_validator_libraries must be set for authentication method %s",
+							"oauth");
+		return false;
+	}
+
+	/* SplitDirectoriesString needs a modifiable copy */
+	rawstring = pstrdup(oauth_validator_libraries_string);
+
+	if (!SplitDirectoriesString(rawstring, ',', &elemlist))
+	{
+		/* syntax error in list */
+		ereport(elevel,
+				errcode(ERRCODE_CONFIG_FILE_ERROR),
+				errmsg("invalid list syntax in parameter \"%s\"",
+					   "oauth_validator_libraries"));
+		*err_msg = psprintf("invalid list syntax in parameter \"%s\"",
+							"oauth_validator_libraries");
+		goto done;
+	}
+
+	if (!hbaline->oauth_validator)
+	{
+		if (elemlist->length == 1)
+		{
+			hbaline->oauth_validator = pstrdup(linitial(elemlist));
+			goto done;
+		}
+
+		ereport(elevel,
+				errcode(ERRCODE_CONFIG_FILE_ERROR),
+				errmsg("authentication method \"oauth\" requires argument \"validator\" to be set when oauth_validator_libraries contains multiple options"),
+				errcontext("line %d of configuration file \"%s\"",
+						   line_num, file_name));
+		*err_msg = "authentication method \"oauth\" requires argument \"validator\" to be set when oauth_validator_libraries contains multiple options";
+		goto done;
+	}
+
+	foreach_ptr(char, allowed, elemlist)
+	{
+		if (strcmp(allowed, hbaline->oauth_validator) == 0)
+			goto done;
+	}
+
+	ereport(elevel,
+			errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+			errmsg("validator \"%s\" is not permitted by %s",
+				   hbaline->oauth_validator, "oauth_validator_libraries"),
+			errcontext("line %d of configuration file \"%s\"",
+					   line_num, file_name));
+	*err_msg = psprintf("validator \"%s\" is not permitted by %s",
+						hbaline->oauth_validator, "oauth_validator_libraries");
+
+done:
+	list_free_deep(elemlist);
+	pfree(rawstring);
+
+	return (*err_msg == NULL);
+}
diff --git a/src/backend/libpq/auth.c b/src/backend/libpq/auth.c
index d6ef32cc823..0f65014e64f 100644
--- a/src/backend/libpq/auth.c
+++ b/src/backend/libpq/auth.c
@@ -29,6 +29,7 @@
 #include "libpq/auth.h"
 #include "libpq/crypt.h"
 #include "libpq/libpq.h"
+#include "libpq/oauth.h"
 #include "libpq/pqformat.h"
 #include "libpq/sasl.h"
 #include "libpq/scram.h"
@@ -45,7 +46,6 @@
  */
 static void auth_failed(Port *port, int status, const char *logdetail);
 static char *recv_password_packet(Port *port);
-static void set_authn_id(Port *port, const char *id);
 
 
 /*----------------------------------------------------------------
@@ -289,6 +289,9 @@ auth_failed(Port *port, int status, const char *logdetail)
 		case uaRADIUS:
 			errstr = gettext_noop("RADIUS authentication failed for user \"%s\"");
 			break;
+		case uaOAuth:
+			errstr = gettext_noop("OAuth bearer authentication failed for user \"%s\"");
+			break;
 		default:
 			errstr = gettext_noop("authentication failed for user \"%s\": invalid authentication method");
 			break;
@@ -324,7 +327,7 @@ auth_failed(Port *port, int status, const char *logdetail)
  * lifetime of MyClientConnectionInfo, so it is safe to pass a string that is
  * managed by an external library.
  */
-static void
+void
 set_authn_id(Port *port, const char *id)
 {
 	Assert(id);
@@ -611,6 +614,9 @@ ClientAuthentication(Port *port)
 		case uaTrust:
 			status = STATUS_OK;
 			break;
+		case uaOAuth:
+			status = CheckSASLAuth(&pg_be_oauth_mech, port, NULL, NULL);
+			break;
 	}
 
 	if ((status == STATUS_OK && port->hba->clientcert == clientCertFull)
diff --git a/src/backend/libpq/hba.c b/src/backend/libpq/hba.c
index 510c9ffc6d7..332fad27835 100644
--- a/src/backend/libpq/hba.c
+++ b/src/backend/libpq/hba.c
@@ -32,6 +32,7 @@
 #include "libpq/hba.h"
 #include "libpq/ifaddr.h"
 #include "libpq/libpq-be.h"
+#include "libpq/oauth.h"
 #include "postmaster/postmaster.h"
 #include "regex/regex.h"
 #include "replication/walsender.h"
@@ -114,7 +115,8 @@ static const char *const UserAuthName[] =
 	"ldap",
 	"cert",
 	"radius",
-	"peer"
+	"peer",
+	"oauth",
 };
 
 /*
@@ -1747,6 +1749,8 @@ parse_hba_line(TokenizedAuthLine *tok_line, int elevel)
 #endif
 	else if (strcmp(token->string, "radius") == 0)
 		parsedline->auth_method = uaRADIUS;
+	else if (strcmp(token->string, "oauth") == 0)
+		parsedline->auth_method = uaOAuth;
 	else
 	{
 		ereport(elevel,
@@ -2039,6 +2043,36 @@ parse_hba_line(TokenizedAuthLine *tok_line, int elevel)
 		parsedline->clientcert = clientCertFull;
 	}
 
+	/*
+	 * Enforce proper configuration of OAuth authentication.
+	 */
+	if (parsedline->auth_method == uaOAuth)
+	{
+		MANDATORY_AUTH_ARG(parsedline->oauth_scope, "scope", "oauth");
+		MANDATORY_AUTH_ARG(parsedline->oauth_issuer, "issuer", "oauth");
+
+		/* Ensure a validator library is set and permitted by the config. */
+		if (!check_oauth_validator(parsedline, elevel, err_msg))
+			return NULL;
+
+		/*
+		 * Supplying a usermap combined with the option to skip usermapping is
+		 * nonsensical and indicates a configuration error.
+		 */
+		if (parsedline->oauth_skip_usermap && parsedline->usermap != NULL)
+		{
+			ereport(elevel,
+					errcode(ERRCODE_CONFIG_FILE_ERROR),
+			/* translator: strings are replaced with hba options */
+					errmsg("%s cannot be used in combination with %s",
+						   "map", "delegate_ident_mapping"),
+					errcontext("line %d of configuration file \"%s\"",
+							   line_num, file_name));
+			*err_msg = "map cannot be used in combination with delegate_ident_mapping";
+			return NULL;
+		}
+	}
+
 	return parsedline;
 }
 
@@ -2066,8 +2100,9 @@ parse_hba_auth_opt(char *name, char *val, HbaLine *hbaline,
 			hbaline->auth_method != uaPeer &&
 			hbaline->auth_method != uaGSS &&
 			hbaline->auth_method != uaSSPI &&
-			hbaline->auth_method != uaCert)
-			INVALID_AUTH_OPTION("map", gettext_noop("ident, peer, gssapi, sspi, and cert"));
+			hbaline->auth_method != uaCert &&
+			hbaline->auth_method != uaOAuth)
+			INVALID_AUTH_OPTION("map", gettext_noop("ident, peer, gssapi, sspi, cert, and oauth"));
 		hbaline->usermap = pstrdup(val);
 	}
 	else if (strcmp(name, "clientcert") == 0)
@@ -2450,6 +2485,29 @@ parse_hba_auth_opt(char *name, char *val, HbaLine *hbaline,
 		hbaline->radiusidentifiers = parsed_identifiers;
 		hbaline->radiusidentifiers_s = pstrdup(val);
 	}
+	else if (strcmp(name, "issuer") == 0)
+	{
+		REQUIRE_AUTH_OPTION(uaOAuth, "issuer", "oauth");
+		hbaline->oauth_issuer = pstrdup(val);
+	}
+	else if (strcmp(name, "scope") == 0)
+	{
+		REQUIRE_AUTH_OPTION(uaOAuth, "scope", "oauth");
+		hbaline->oauth_scope = pstrdup(val);
+	}
+	else if (strcmp(name, "validator") == 0)
+	{
+		REQUIRE_AUTH_OPTION(uaOAuth, "validator", "oauth");
+		hbaline->oauth_validator = pstrdup(val);
+	}
+	else if (strcmp(name, "delegate_ident_mapping") == 0)
+	{
+		REQUIRE_AUTH_OPTION(uaOAuth, "delegate_ident_mapping", "oauth");
+		if (strcmp(val, "1") == 0)
+			hbaline->oauth_skip_usermap = true;
+		else
+			hbaline->oauth_skip_usermap = false;
+	}
 	else
 	{
 		ereport(elevel,
diff --git a/src/backend/libpq/meson.build b/src/backend/libpq/meson.build
index 0f0421037e4..31aa2faae1e 100644
--- a/src/backend/libpq/meson.build
+++ b/src/backend/libpq/meson.build
@@ -1,6 +1,7 @@
 # Copyright (c) 2022-2025, PostgreSQL Global Development Group
 
 backend_sources += files(
+  'auth-oauth.c',
   'auth-sasl.c',
   'auth-scram.c',
   'auth.c',
diff --git a/src/backend/libpq/pg_hba.conf.sample b/src/backend/libpq/pg_hba.conf.sample
index bad13497a34..b64c8dea97c 100644
--- a/src/backend/libpq/pg_hba.conf.sample
+++ b/src/backend/libpq/pg_hba.conf.sample
@@ -53,8 +53,8 @@
 # directly connected to.
 #
 # METHOD can be "trust", "reject", "md5", "password", "scram-sha-256",
-# "gss", "sspi", "ident", "peer", "pam", "ldap", "radius" or "cert".
-# Note that "password" sends passwords in clear text; "md5" or
+# "gss", "sspi", "ident", "peer", "pam", "oauth", "ldap", "radius" or
+# "cert".  Note that "password" sends passwords in clear text; "md5" or
 # "scram-sha-256" are preferred since they send encrypted passwords.
 #
 # OPTIONS are a set of options for the authentication in the format
diff --git a/src/backend/utils/adt/hbafuncs.c b/src/backend/utils/adt/hbafuncs.c
index 03c38e8c451..b62c3d944cf 100644
--- a/src/backend/utils/adt/hbafuncs.c
+++ b/src/backend/utils/adt/hbafuncs.c
@@ -152,6 +152,25 @@ get_hba_options(HbaLine *hba)
 				CStringGetTextDatum(psprintf("radiusports=%s", hba->radiusports_s));
 	}
 
+	if (hba->auth_method == uaOAuth)
+	{
+		if (hba->oauth_issuer)
+			options[noptions++] =
+				CStringGetTextDatum(psprintf("issuer=%s", hba->oauth_issuer));
+
+		if (hba->oauth_scope)
+			options[noptions++] =
+				CStringGetTextDatum(psprintf("scope=%s", hba->oauth_scope));
+
+		if (hba->oauth_validator)
+			options[noptions++] =
+				CStringGetTextDatum(psprintf("validator=%s", hba->oauth_validator));
+
+		if (hba->oauth_skip_usermap)
+			options[noptions++] =
+				CStringGetTextDatum(psprintf("delegate_ident_mapping=true"));
+	}
+
 	/* If you add more options, consider increasing MAX_HBA_OPTIONS. */
 	Assert(noptions <= MAX_HBA_OPTIONS);
 
diff --git a/src/backend/utils/misc/guc_tables.c b/src/backend/utils/misc/guc_tables.c
index cce73314609..515091a3844 100644
--- a/src/backend/utils/misc/guc_tables.c
+++ b/src/backend/utils/misc/guc_tables.c
@@ -49,6 +49,7 @@
 #include "jit/jit.h"
 #include "libpq/auth.h"
 #include "libpq/libpq.h"
+#include "libpq/oauth.h"
 #include "libpq/scram.h"
 #include "nodes/queryjumble.h"
 #include "optimizer/cost.h"
@@ -4861,6 +4862,17 @@ struct config_string ConfigureNamesString[] =
 		check_restrict_nonsystem_relation_kind, assign_restrict_nonsystem_relation_kind, NULL
 	},
 
+	{
+		{"oauth_validator_libraries", PGC_SIGHUP, CONN_AUTH_AUTH,
+			gettext_noop("Lists libraries that may be called to validate OAuth v2 bearer tokens."),
+			NULL,
+			GUC_LIST_INPUT | GUC_LIST_QUOTE | GUC_SUPERUSER_ONLY
+		},
+		&oauth_validator_libraries_string,
+		"",
+		NULL, NULL, NULL
+	},
+
 	/* End-of-list marker */
 	{
 		{NULL, 0, 0, NULL, NULL}, NULL, NULL, NULL, NULL, NULL
diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample
index d472987ed46..ccefd214143 100644
--- a/src/backend/utils/misc/postgresql.conf.sample
+++ b/src/backend/utils/misc/postgresql.conf.sample
@@ -121,6 +121,9 @@
 #ssl_passphrase_command = ''
 #ssl_passphrase_command_supports_reload = off
 
+# OAuth
+#oauth_validator_libraries = ''	# comma-separated list of trusted validator modules
+
 
 #------------------------------------------------------------------------------
 # RESOURCE USAGE (except WAL)
diff --git a/src/include/common/oauth-common.h b/src/include/common/oauth-common.h
new file mode 100644
index 00000000000..5fb559d84b2
--- /dev/null
+++ b/src/include/common/oauth-common.h
@@ -0,0 +1,19 @@
+/*-------------------------------------------------------------------------
+ *
+ * oauth-common.h
+ *		Declarations for helper functions used for OAuth/OIDC authentication
+ *
+ * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/include/common/oauth-common.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef OAUTH_COMMON_H
+#define OAUTH_COMMON_H
+
+/* Name of SASL mechanism per IANA */
+#define OAUTHBEARER_NAME "OAUTHBEARER"
+
+#endif							/* OAUTH_COMMON_H */
diff --git a/src/include/libpq/auth.h b/src/include/libpq/auth.h
index 902c5f6de32..25b5742068f 100644
--- a/src/include/libpq/auth.h
+++ b/src/include/libpq/auth.h
@@ -39,6 +39,7 @@ extern PGDLLIMPORT bool pg_gss_accept_delegation;
 extern void ClientAuthentication(Port *port);
 extern void sendAuthRequest(Port *port, AuthRequest areq, const char *extradata,
 							int extralen);
+extern void set_authn_id(Port *port, const char *id);
 
 /* Hook for plugins to get control in ClientAuthentication() */
 typedef void (*ClientAuthentication_hook_type) (Port *, int);
diff --git a/src/include/libpq/hba.h b/src/include/libpq/hba.h
index b20d0051f7d..3657f182db3 100644
--- a/src/include/libpq/hba.h
+++ b/src/include/libpq/hba.h
@@ -39,7 +39,8 @@ typedef enum UserAuth
 	uaCert,
 	uaRADIUS,
 	uaPeer,
-#define USER_AUTH_LAST uaPeer	/* Must be last value of this enum */
+	uaOAuth,
+#define USER_AUTH_LAST uaOAuth	/* Must be last value of this enum */
 } UserAuth;
 
 /*
@@ -135,6 +136,10 @@ typedef struct HbaLine
 	char	   *radiusidentifiers_s;
 	List	   *radiusports;
 	char	   *radiusports_s;
+	char	   *oauth_issuer;
+	char	   *oauth_scope;
+	char	   *oauth_validator;
+	bool		oauth_skip_usermap;
 } HbaLine;
 
 typedef struct IdentLine
diff --git a/src/include/libpq/oauth.h b/src/include/libpq/oauth.h
new file mode 100644
index 00000000000..2f01b669633
--- /dev/null
+++ b/src/include/libpq/oauth.h
@@ -0,0 +1,101 @@
+/*-------------------------------------------------------------------------
+ *
+ * oauth.h
+ *	  Interface to libpq/auth-oauth.c
+ *
+ * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/include/libpq/oauth.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PG_OAUTH_H
+#define PG_OAUTH_H
+
+#include "libpq/libpq-be.h"
+#include "libpq/sasl.h"
+
+extern PGDLLIMPORT char *oauth_validator_libraries_string;
+
+typedef struct ValidatorModuleState
+{
+	/* Holds the server's PG_VERSION_NUM. Reserved for future extensibility. */
+	int			sversion;
+
+	/*
+	 * Private data pointer for use by a validator module. This can be used to
+	 * store state for the module that will be passed to each of its
+	 * callbacks.
+	 */
+	void	   *private_data;
+} ValidatorModuleState;
+
+typedef struct ValidatorModuleResult
+{
+	/*
+	 * Should be set to true if the token carries sufficient permissions for
+	 * the bearer to connect.
+	 */
+	bool		authorized;
+
+	/*
+	 * If the token authenticates the user, this should be set to a palloc'd
+	 * string containing the SYSTEM_USER to use for HBA mapping. Consider
+	 * setting this even if result->authorized is false so that DBAs may use
+	 * the logs to match end users to token failures.
+	 *
+	 * This is required if the module is not configured for ident mapping
+	 * delegation. See the validator module documentation for details.
+	 */
+	char	   *authn_id;
+} ValidatorModuleResult;
+
+/*
+ * Validator module callbacks
+ *
+ * These callback functions should be defined by validator modules and returned
+ * via _PG_oauth_validator_module_init().  ValidatorValidateCB is the only
+ * required callback. For more information about the purpose of each callback,
+ * refer to the OAuth validator modules documentation.
+ */
+typedef void (*ValidatorStartupCB) (ValidatorModuleState *state);
+typedef void (*ValidatorShutdownCB) (ValidatorModuleState *state);
+typedef bool (*ValidatorValidateCB) (const ValidatorModuleState *state,
+									 const char *token, const char *role,
+									 ValidatorModuleResult *result);
+
+/*
+ * Identifies the compiled ABI version of the validator module. Since the server
+ * already enforces the PG_MODULE_MAGIC number for modules across major
+ * versions, this is reserved for emergency use within a stable release line.
+ * May it never need to change.
+ */
+#define PG_OAUTH_VALIDATOR_MAGIC 0x20250207
+
+typedef struct OAuthValidatorCallbacks
+{
+	uint32		magic;			/* must be set to PG_OAUTH_VALIDATOR_MAGIC */
+
+	ValidatorStartupCB startup_cb;
+	ValidatorShutdownCB shutdown_cb;
+	ValidatorValidateCB validate_cb;
+} OAuthValidatorCallbacks;
+
+/*
+ * Type of the shared library symbol _PG_oauth_validator_module_init which is
+ * required for all validator modules.  This function will be invoked during
+ * module loading.
+ */
+typedef const OAuthValidatorCallbacks *(*OAuthValidatorModuleInit) (void);
+extern PGDLLEXPORT const OAuthValidatorCallbacks *_PG_oauth_validator_module_init(void);
+
+/* Implementation */
+extern const pg_be_sasl_mech pg_be_oauth_mech;
+
+/*
+ * Ensure a validator named in the HBA is permitted by the configuration.
+ */
+extern bool check_oauth_validator(HbaLine *hba, int elevel, char **err_msg);
+
+#endif							/* PG_OAUTH_H */
diff --git a/src/include/pg_config.h.in b/src/include/pg_config.h.in
index 07b2f798abd..db6454090d2 100644
--- a/src/include/pg_config.h.in
+++ b/src/include/pg_config.h.in
@@ -229,6 +229,9 @@
 /* Define to 1 if you have the `crypto' library (-lcrypto). */
 #undef HAVE_LIBCRYPTO
 
+/* Define to 1 if you have the `curl' library (-lcurl). */
+#undef HAVE_LIBCURL
+
 /* Define to 1 if you have the `ldap' library (-lldap). */
 #undef HAVE_LIBLDAP
 
@@ -442,6 +445,9 @@
 /* Define to 1 if you have the <termios.h> header file. */
 #undef HAVE_TERMIOS_H
 
+/* Define to 1 if curl_global_init() is guaranteed to be thread-safe. */
+#undef HAVE_THREADSAFE_CURL_GLOBAL_INIT
+
 /* Define to 1 if your compiler understands `typeof' or something similar. */
 #undef HAVE_TYPEOF
 
@@ -663,6 +669,9 @@
 /* Define to 1 to build with LDAP support. (--with-ldap) */
 #undef USE_LDAP
 
+/* Define to 1 to build with libcurl support. (--with-libcurl) */
+#undef USE_LIBCURL
+
 /* Define to 1 to build with XML support. (--with-libxml) */
 #undef USE_LIBXML
 
diff --git a/src/interfaces/libpq/Makefile b/src/interfaces/libpq/Makefile
index 701810a272a..90b0b65db6f 100644
--- a/src/interfaces/libpq/Makefile
+++ b/src/interfaces/libpq/Makefile
@@ -31,6 +31,7 @@ endif
 
 OBJS = \
 	$(WIN32RES) \
+	fe-auth-oauth.o \
 	fe-auth-scram.o \
 	fe-cancel.o \
 	fe-connect.o \
@@ -63,6 +64,10 @@ OBJS += \
 	fe-secure-gssapi.o
 endif
 
+ifeq ($(with_libcurl),yes)
+OBJS += fe-auth-oauth-curl.o
+endif
+
 ifeq ($(PORTNAME), cygwin)
 override shlib = cyg$(NAME)$(DLSUFFIX)
 endif
@@ -81,7 +86,7 @@ endif
 # that are built correctly for use in a shlib.
 SHLIB_LINK_INTERNAL = -lpgcommon_shlib -lpgport_shlib
 ifneq ($(PORTNAME), win32)
-SHLIB_LINK += $(filter -lcrypt -ldes -lcom_err -lcrypto -lk5crypto -lkrb5 -lgssapi_krb5 -lgss -lgssapi -lssl -lsocket -lnsl -lresolv -lintl -lm, $(LIBS)) $(LDAP_LIBS_FE) $(PTHREAD_LIBS)
+SHLIB_LINK += $(filter -lcrypt -ldes -lcom_err -lcrypto -lk5crypto -lkrb5 -lgssapi_krb5 -lgss -lgssapi -lssl -lcurl -lsocket -lnsl -lresolv -lintl -lm, $(LIBS)) $(LDAP_LIBS_FE) $(PTHREAD_LIBS)
 else
 SHLIB_LINK += $(filter -lcrypt -ldes -lcom_err -lcrypto -lk5crypto -lkrb5 -lgssapi32 -lssl -lsocket -lnsl -lresolv -lintl -lm $(PTHREAD_LIBS), $(LIBS)) $(LDAP_LIBS_FE)
 endif
@@ -110,6 +115,8 @@ backend_src = $(top_srcdir)/src/backend
 # which seems to insert references to that even in pure C code. Excluding
 # __tsan_func_exit is necessary when using ThreadSanitizer data race detector
 # which use this function for instrumentation of function exit.
+# libcurl registers an exit handler in the memory debugging code when running
+# with LeakSanitizer.
 # Skip the test when profiling, as gcc may insert exit() calls for that.
 # Also skip the test on platforms where libpq infrastructure may be provided
 # by statically-linked libraries, as we can't expect them to honor this
@@ -117,7 +124,7 @@ backend_src = $(top_srcdir)/src/backend
 libpq-refs-stamp: $(shlib)
 ifneq ($(enable_coverage), yes)
 ifeq (,$(filter solaris,$(PORTNAME)))
-	@if nm -A -u $< 2>/dev/null | grep -v -e __cxa_atexit -e __tsan_func_exit | grep exit; then \
+	@if nm -A -u $< 2>/dev/null | grep -v -e __cxa_atexit -e __tsan_func_exit -e _atexit | grep exit; then \
 		echo 'libpq must not be calling any function which invokes exit'; exit 1; \
 	fi
 endif
diff --git a/src/interfaces/libpq/exports.txt b/src/interfaces/libpq/exports.txt
index 2ad2cbf5ca3..9b789cbec0b 100644
--- a/src/interfaces/libpq/exports.txt
+++ b/src/interfaces/libpq/exports.txt
@@ -206,3 +206,6 @@ PQsocketPoll              203
 PQsetChunkedRowsMode      204
 PQgetCurrentTimeUSec      205
 PQservice                 206
+PQsetAuthDataHook         207
+PQgetAuthDataHook         208
+PQdefaultAuthDataHook     209
diff --git a/src/interfaces/libpq/fe-auth-oauth-curl.c b/src/interfaces/libpq/fe-auth-oauth-curl.c
new file mode 100644
index 00000000000..a80e2047bb7
--- /dev/null
+++ b/src/interfaces/libpq/fe-auth-oauth-curl.c
@@ -0,0 +1,2883 @@
+/*-------------------------------------------------------------------------
+ *
+ * fe-auth-oauth-curl.c
+ *	   The libcurl implementation of OAuth/OIDC authentication, using the
+ *	   OAuth Device Authorization Grant (RFC 8628).
+ *
+ * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/interfaces/libpq/fe-auth-oauth-curl.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres_fe.h"
+
+#include <curl/curl.h>
+#include <math.h>
+#ifdef HAVE_SYS_EPOLL_H
+#include <sys/epoll.h>
+#include <sys/timerfd.h>
+#endif
+#ifdef HAVE_SYS_EVENT_H
+#include <sys/event.h>
+#endif
+#include <unistd.h>
+
+#include "common/jsonapi.h"
+#include "fe-auth.h"
+#include "fe-auth-oauth.h"
+#include "libpq-int.h"
+#include "mb/pg_wchar.h"
+
+/*
+ * It's generally prudent to set a maximum response size to buffer in memory,
+ * but it's less clear what size to choose. The biggest of our expected
+ * responses is the server metadata JSON, which will only continue to grow in
+ * size; the number of IANA-registered parameters in that document is up to 78
+ * as of February 2025.
+ *
+ * Even if every single parameter were to take up 2k on average (a previously
+ * common limit on the size of a URL), 256k gives us 128 parameter values before
+ * we give up. (That's almost certainly complete overkill in practice; 2-4k
+ * appears to be common among popular providers at the moment.)
+ */
+#define MAX_OAUTH_RESPONSE_SIZE (256 * 1024)
+
+/*
+ * Parsed JSON Representations
+ *
+ * As a general rule, we parse and cache only the fields we're currently using.
+ * When adding new fields, ensure the corresponding free_*() function is updated
+ * too.
+ */
+
+/*
+ * The OpenID Provider configuration (alternatively named "authorization server
+ * metadata") jointly described by OpenID Connect Discovery 1.0 and RFC 8414:
+ *
+ *     https://openid.net/specs/openid-connect-discovery-1_0.html
+ *     https://www.rfc-editor.org/rfc/rfc8414#section-3.2
+ */
+struct provider
+{
+	char	   *issuer;
+	char	   *token_endpoint;
+	char	   *device_authorization_endpoint;
+	struct curl_slist *grant_types_supported;
+};
+
+static void
+free_provider(struct provider *provider)
+{
+	free(provider->issuer);
+	free(provider->token_endpoint);
+	free(provider->device_authorization_endpoint);
+	curl_slist_free_all(provider->grant_types_supported);
+}
+
+/*
+ * The Device Authorization response, described by RFC 8628:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc8628#section-3.2
+ */
+struct device_authz
+{
+	char	   *device_code;
+	char	   *user_code;
+	char	   *verification_uri;
+	char	   *verification_uri_complete;
+	char	   *expires_in_str;
+	char	   *interval_str;
+
+	/* Fields below are parsed from the corresponding string above. */
+	int			expires_in;
+	int			interval;
+};
+
+static void
+free_device_authz(struct device_authz *authz)
+{
+	free(authz->device_code);
+	free(authz->user_code);
+	free(authz->verification_uri);
+	free(authz->verification_uri_complete);
+	free(authz->expires_in_str);
+	free(authz->interval_str);
+}
+
+/*
+ * The Token Endpoint error response, as described by RFC 6749:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc6749#section-5.2
+ *
+ * Note that this response type can also be returned from the Device
+ * Authorization Endpoint.
+ */
+struct token_error
+{
+	char	   *error;
+	char	   *error_description;
+};
+
+static void
+free_token_error(struct token_error *err)
+{
+	free(err->error);
+	free(err->error_description);
+}
+
+/*
+ * The Access Token response, as described by RFC 6749:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc6749#section-4.1.4
+ *
+ * During the Device Authorization flow, several temporary errors are expected
+ * as part of normal operation. To make it easy to handle these in the happy
+ * path, this contains an embedded token_error that is filled in if needed.
+ */
+struct token
+{
+	/* for successful responses */
+	char	   *access_token;
+	char	   *token_type;
+
+	/* for error responses */
+	struct token_error err;
+};
+
+static void
+free_token(struct token *tok)
+{
+	free(tok->access_token);
+	free(tok->token_type);
+	free_token_error(&tok->err);
+}
+
+/*
+ * Asynchronous State
+ */
+
+/* States for the overall async machine. */
+enum OAuthStep
+{
+	OAUTH_STEP_INIT = 0,
+	OAUTH_STEP_DISCOVERY,
+	OAUTH_STEP_DEVICE_AUTHORIZATION,
+	OAUTH_STEP_TOKEN_REQUEST,
+	OAUTH_STEP_WAIT_INTERVAL,
+};
+
+/*
+ * The async_ctx holds onto state that needs to persist across multiple calls
+ * to pg_fe_run_oauth_flow(). Almost everything interacts with this in some
+ * way.
+ */
+struct async_ctx
+{
+	enum OAuthStep step;		/* where are we in the flow? */
+
+	int			timerfd;		/* descriptor for signaling async timeouts */
+	pgsocket	mux;			/* the multiplexer socket containing all
+								 * descriptors tracked by libcurl, plus the
+								 * timerfd */
+	CURLM	   *curlm;			/* top-level multi handle for libcurl
+								 * operations */
+	CURL	   *curl;			/* the (single) easy handle for serial
+								 * requests */
+
+	struct curl_slist *headers; /* common headers for all requests */
+	PQExpBufferData work_data;	/* scratch buffer for general use (remember to
+								 * clear out prior contents first!) */
+
+	/*------
+	 * Since a single logical operation may stretch across multiple calls to
+	 * our entry point, errors have three parts:
+	 *
+	 * - errctx:	an optional static string, describing the global operation
+	 *				currently in progress. It'll be translated for you.
+	 *
+	 * - errbuf:	contains the actual error message. Generally speaking, use
+	 *				actx_error[_str] to manipulate this. This must be filled
+	 *				with something useful on an error.
+	 *
+	 * - curl_err:	an optional static error buffer used by libcurl to put
+	 *				detailed information about failures. Unfortunately
+	 *				untranslatable.
+	 *
+	 * These pieces will be combined into a single error message looking
+	 * something like the following, with errctx and/or curl_err omitted when
+	 * absent:
+	 *
+	 *     connection to server ... failed: errctx: errbuf (libcurl: curl_err)
+	 */
+	const char *errctx;			/* not freed; must point to static allocation */
+	PQExpBufferData errbuf;
+	char		curl_err[CURL_ERROR_SIZE];
+
+	/*
+	 * These documents need to survive over multiple calls, and are therefore
+	 * cached directly in the async_ctx.
+	 */
+	struct provider provider;
+	struct device_authz authz;
+
+	int			running;		/* is asynchronous work in progress? */
+	bool		user_prompted;	/* have we already sent the authz prompt? */
+	bool		used_basic_auth;	/* did we send a client secret? */
+	bool		debugging;		/* can we give unsafe developer assistance? */
+};
+
+/*
+ * Tears down the Curl handles and frees the async_ctx.
+ */
+static void
+free_async_ctx(PGconn *conn, struct async_ctx *actx)
+{
+	/*
+	 * In general, none of the error cases below should ever happen if we have
+	 * no bugs above. But if we do hit them, surfacing those errors somehow
+	 * might be the only way to have a chance to debug them.
+	 *
+	 * TODO: At some point it'd be nice to have a standard way to warn about
+	 * teardown failures. Appending to the connection's error message only
+	 * helps if the bug caused a connection failure; otherwise it'll be
+	 * buried...
+	 */
+
+	if (actx->curlm && actx->curl)
+	{
+		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));
+	}
+
+	if (actx->curl)
+	{
+		/*
+		 * curl_multi_cleanup() doesn't free any associated easy handles; we
+		 * need to do that separately. We only ever have one easy handle per
+		 * multi handle.
+		 */
+		curl_easy_cleanup(actx->curl);
+	}
+
+	if (actx->curlm)
+	{
+		CURLMcode	err = curl_multi_cleanup(actx->curlm);
+
+		if (err)
+			libpq_append_conn_error(conn,
+									"libcurl multi handle cleanup failed: %s",
+									curl_multi_strerror(err));
+	}
+
+	free_provider(&actx->provider);
+	free_device_authz(&actx->authz);
+
+	curl_slist_free_all(actx->headers);
+	termPQExpBuffer(&actx->work_data);
+	termPQExpBuffer(&actx->errbuf);
+
+	if (actx->mux != PGINVALID_SOCKET)
+		close(actx->mux);
+	if (actx->timerfd >= 0)
+		close(actx->timerfd);
+
+	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.
+ */
+void
+pg_fe_cleanup_oauth_flow(PGconn *conn)
+{
+	fe_oauth_state *state = conn->sasl_state;
+
+	if (state->async_ctx)
+	{
+		free_async_ctx(conn, state->async_ctx);
+		state->async_ctx = NULL;
+	}
+
+	conn->altsock = PGINVALID_SOCKET;
+}
+
+/*
+ * Macros for manipulating actx->errbuf. actx_error() translates and formats a
+ * string for you; actx_error_str() appends a string directly without
+ * translation.
+ */
+
+#define actx_error(ACTX, FMT, ...) \
+	appendPQExpBuffer(&(ACTX)->errbuf, libpq_gettext(FMT), ##__VA_ARGS__)
+
+#define actx_error_str(ACTX, S) \
+	appendPQExpBufferStr(&(ACTX)->errbuf, S)
+
+/*
+ * Macros for getting and setting state for the connection's two libcurl
+ * handles, so you don't have to write out the error handling every time.
+ */
+
+#define CHECK_MSETOPT(ACTX, OPT, VAL, FAILACTION) \
+	do { \
+		struct async_ctx *_actx = (ACTX); \
+		CURLMcode	_setopterr = curl_multi_setopt(_actx->curlm, OPT, VAL); \
+		if (_setopterr) { \
+			actx_error(_actx, "failed to set %s on OAuth connection: %s",\
+					   #OPT, curl_multi_strerror(_setopterr)); \
+			FAILACTION; \
+		} \
+	} while (0)
+
+#define CHECK_SETOPT(ACTX, OPT, VAL, FAILACTION) \
+	do { \
+		struct async_ctx *_actx = (ACTX); \
+		CURLcode	_setopterr = curl_easy_setopt(_actx->curl, OPT, VAL); \
+		if (_setopterr) { \
+			actx_error(_actx, "failed to set %s on OAuth connection: %s",\
+					   #OPT, curl_easy_strerror(_setopterr)); \
+			FAILACTION; \
+		} \
+	} while (0)
+
+#define CHECK_GETINFO(ACTX, INFO, OUT, FAILACTION) \
+	do { \
+		struct async_ctx *_actx = (ACTX); \
+		CURLcode	_getinfoerr = curl_easy_getinfo(_actx->curl, INFO, OUT); \
+		if (_getinfoerr) { \
+			actx_error(_actx, "failed to get %s from OAuth response: %s",\
+					   #INFO, curl_easy_strerror(_getinfoerr)); \
+			FAILACTION; \
+		} \
+	} while (0)
+
+/*
+ * General JSON Parsing for OAuth Responses
+ */
+
+/*
+ * Represents a single name/value pair in a JSON object. This is the primary
+ * interface to parse_oauth_json().
+ *
+ * All fields are stored internally as strings or lists of strings, so clients
+ * have to explicitly parse other scalar types (though they will have gone
+ * through basic lexical validation). Storing nested objects is not currently
+ * supported, nor is parsing arrays of anything other than strings.
+ */
+struct json_field
+{
+	const char *name;			/* name (key) of the member */
+
+	JsonTokenType type;			/* currently supports JSON_TOKEN_STRING,
+								 * JSON_TOKEN_NUMBER, and
+								 * JSON_TOKEN_ARRAY_START */
+	union
+	{
+		char	  **scalar;		/* for all scalar types */
+		struct curl_slist **array;	/* for type == JSON_TOKEN_ARRAY_START */
+	}			target;
+
+	bool		required;		/* REQUIRED field, or just OPTIONAL? */
+};
+
+/* Documentation macros for json_field.required. */
+#define REQUIRED true
+#define OPTIONAL false
+
+/* Parse state for parse_oauth_json(). */
+struct oauth_parse
+{
+	PQExpBuffer errbuf;			/* detail message for JSON_SEM_ACTION_FAILED */
+	int			nested;			/* nesting level (zero is the top) */
+
+	const struct json_field *fields;	/* field definition array */
+	const struct json_field *active;	/* points inside the fields array */
+};
+
+#define oauth_parse_set_error(ctx, fmt, ...) \
+	appendPQExpBuffer((ctx)->errbuf, libpq_gettext(fmt), ##__VA_ARGS__)
+
+static void
+report_type_mismatch(struct oauth_parse *ctx)
+{
+	char	   *msgfmt;
+
+	Assert(ctx->active);
+
+	/*
+	 * At the moment, the only fields we're interested in are strings,
+	 * numbers, and arrays of strings.
+	 */
+	switch (ctx->active->type)
+	{
+		case JSON_TOKEN_STRING:
+			msgfmt = "field \"%s\" must be a string";
+			break;
+
+		case JSON_TOKEN_NUMBER:
+			msgfmt = "field \"%s\" must be a number";
+			break;
+
+		case JSON_TOKEN_ARRAY_START:
+			msgfmt = "field \"%s\" must be an array of strings";
+			break;
+
+		default:
+			Assert(false);
+			msgfmt = "field \"%s\" has unexpected type";
+	}
+
+	oauth_parse_set_error(ctx, msgfmt, ctx->active->name);
+}
+
+static JsonParseErrorType
+oauth_json_object_start(void *state)
+{
+	struct oauth_parse *ctx = state;
+
+	if (ctx->active)
+	{
+		/*
+		 * Currently, none of the fields we're interested in can be or contain
+		 * objects, so we can reject this case outright.
+		 */
+		report_type_mismatch(ctx);
+		return JSON_SEM_ACTION_FAILED;
+	}
+
+	++ctx->nested;
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_object_field_start(void *state, char *name, bool isnull)
+{
+	struct oauth_parse *ctx = state;
+
+	/* We care only about the top-level fields. */
+	if (ctx->nested == 1)
+	{
+		const struct json_field *field = ctx->fields;
+
+		/*
+		 * We should never start parsing a new field while a previous one is
+		 * still active.
+		 */
+		if (ctx->active)
+		{
+			Assert(false);
+			oauth_parse_set_error(ctx,
+								  "internal error: started field '%s' before field '%s' was finished",
+								  name, ctx->active->name);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		while (field->name)
+		{
+			if (strcmp(name, field->name) == 0)
+			{
+				ctx->active = field;
+				break;
+			}
+
+			++field;
+		}
+
+		/*
+		 * We don't allow duplicate field names; error out if the target has
+		 * already been set.
+		 */
+		if (ctx->active)
+		{
+			field = ctx->active;
+
+			if ((field->type == JSON_TOKEN_ARRAY_START && *field->target.array)
+				|| (field->type != JSON_TOKEN_ARRAY_START && *field->target.scalar))
+			{
+				oauth_parse_set_error(ctx, "field \"%s\" is duplicated",
+									  field->name);
+				return JSON_SEM_ACTION_FAILED;
+			}
+		}
+	}
+
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_object_end(void *state)
+{
+	struct oauth_parse *ctx = state;
+
+	--ctx->nested;
+
+	/*
+	 * All fields should be fully processed by the end of the top-level
+	 * object.
+	 */
+	if (!ctx->nested && ctx->active)
+	{
+		Assert(false);
+		oauth_parse_set_error(ctx,
+							  "internal error: field '%s' still active at end of object",
+							  ctx->active->name);
+		return JSON_SEM_ACTION_FAILED;
+	}
+
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_array_start(void *state)
+{
+	struct oauth_parse *ctx = state;
+
+	if (!ctx->nested)
+	{
+		oauth_parse_set_error(ctx, "top-level element must be an object");
+		return JSON_SEM_ACTION_FAILED;
+	}
+
+	if (ctx->active)
+	{
+		if (ctx->active->type != JSON_TOKEN_ARRAY_START
+		/* The arrays we care about must not have arrays as values. */
+			|| ctx->nested > 1)
+		{
+			report_type_mismatch(ctx);
+			return JSON_SEM_ACTION_FAILED;
+		}
+	}
+
+	++ctx->nested;
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_array_end(void *state)
+{
+	struct oauth_parse *ctx = state;
+
+	if (ctx->active)
+	{
+		/*
+		 * Clear the target (which should be an array inside the top-level
+		 * object). For this to be safe, no target arrays can contain other
+		 * arrays; we check for that in the array_start callback.
+		 */
+		if (ctx->nested != 2 || ctx->active->type != JSON_TOKEN_ARRAY_START)
+		{
+			Assert(false);
+			oauth_parse_set_error(ctx,
+								  "internal error: found unexpected array end while parsing field '%s'",
+								  ctx->active->name);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		ctx->active = NULL;
+	}
+
+	--ctx->nested;
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_scalar(void *state, char *token, JsonTokenType type)
+{
+	struct oauth_parse *ctx = state;
+
+	if (!ctx->nested)
+	{
+		oauth_parse_set_error(ctx, "top-level element must be an object");
+		return JSON_SEM_ACTION_FAILED;
+	}
+
+	if (ctx->active)
+	{
+		const struct json_field *field = ctx->active;
+		JsonTokenType expected = field->type;
+
+		/* Make sure this matches what the active field expects. */
+		if (expected == JSON_TOKEN_ARRAY_START)
+		{
+			/* Are we actually inside an array? */
+			if (ctx->nested < 2)
+			{
+				report_type_mismatch(ctx);
+				return JSON_SEM_ACTION_FAILED;
+			}
+
+			/* Currently, arrays can only contain strings. */
+			expected = JSON_TOKEN_STRING;
+		}
+
+		if (type != expected)
+		{
+			report_type_mismatch(ctx);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		if (field->type != JSON_TOKEN_ARRAY_START)
+		{
+			/* Ensure that we're parsing the top-level keys... */
+			if (ctx->nested != 1)
+			{
+				Assert(false);
+				oauth_parse_set_error(ctx,
+									  "internal error: scalar target found at nesting level %d",
+									  ctx->nested);
+				return JSON_SEM_ACTION_FAILED;
+			}
+
+			/* ...and that a result has not already been set. */
+			if (*field->target.scalar)
+			{
+				Assert(false);
+				oauth_parse_set_error(ctx,
+									  "internal error: scalar field '%s' would be assigned twice",
+									  ctx->active->name);
+				return JSON_SEM_ACTION_FAILED;
+			}
+
+			*field->target.scalar = strdup(token);
+			if (!*field->target.scalar)
+				return JSON_OUT_OF_MEMORY;
+
+			ctx->active = NULL;
+
+			return JSON_SUCCESS;
+		}
+		else
+		{
+			struct curl_slist *temp;
+
+			/* The target array should be inside the top-level object. */
+			if (ctx->nested != 2)
+			{
+				Assert(false);
+				oauth_parse_set_error(ctx,
+									  "internal error: array member found at nesting level %d",
+									  ctx->nested);
+				return JSON_SEM_ACTION_FAILED;
+			}
+
+			/* Note that curl_slist_append() makes a copy of the token. */
+			temp = curl_slist_append(*field->target.array, token);
+			if (!temp)
+				return JSON_OUT_OF_MEMORY;
+
+			*field->target.array = temp;
+		}
+	}
+	else
+	{
+		/* otherwise we just ignore it */
+	}
+
+	return JSON_SUCCESS;
+}
+
+/*
+ * Checks the Content-Type header against the expected type. Parameters are
+ * allowed but ignored.
+ */
+static bool
+check_content_type(struct async_ctx *actx, const char *type)
+{
+	const size_t type_len = strlen(type);
+	char	   *content_type;
+
+	CHECK_GETINFO(actx, CURLINFO_CONTENT_TYPE, &content_type, return false);
+
+	if (!content_type)
+	{
+		actx_error(actx, "no content type was provided");
+		return false;
+	}
+
+	/*
+	 * We need to perform a length limited comparison and not compare the
+	 * whole string.
+	 */
+	if (pg_strncasecmp(content_type, type, type_len) != 0)
+		goto fail;
+
+	/* On an exact match, we're done. */
+	Assert(strlen(content_type) >= type_len);
+	if (content_type[type_len] == '\0')
+		return true;
+
+	/*
+	 * Only a semicolon (optionally preceded by HTTP optional whitespace) is
+	 * acceptable after the prefix we checked. This marks the start of media
+	 * type parameters, which we currently have no use for.
+	 */
+	for (size_t i = type_len; content_type[i]; ++i)
+	{
+		switch (content_type[i])
+		{
+			case ';':
+				return true;	/* success! */
+
+			case ' ':
+			case '\t':
+				/* HTTP optional whitespace allows only spaces and htabs. */
+				break;
+
+			default:
+				goto fail;
+		}
+	}
+
+fail:
+	actx_error(actx, "unexpected content type: \"%s\"", content_type);
+	return false;
+}
+
+/*
+ * A helper function for general JSON parsing. fields is the array of field
+ * definitions with their backing pointers. The response will be parsed from
+ * actx->curl and actx->work_data (as set up by start_request()), and any
+ * parsing errors will be placed into actx->errbuf.
+ */
+static bool
+parse_oauth_json(struct async_ctx *actx, const struct json_field *fields)
+{
+	PQExpBuffer resp = &actx->work_data;
+	JsonLexContext lex = {0};
+	JsonSemAction sem = {0};
+	JsonParseErrorType err;
+	struct oauth_parse ctx = {0};
+	bool		success = false;
+
+	if (!check_content_type(actx, "application/json"))
+		return false;
+
+	if (strlen(resp->data) != resp->len)
+	{
+		actx_error(actx, "response contains embedded NULLs");
+		return false;
+	}
+
+	/*
+	 * pg_parse_json doesn't validate the incoming UTF-8, so we have to check
+	 * that up front.
+	 */
+	if (pg_encoding_verifymbstr(PG_UTF8, resp->data, resp->len) != resp->len)
+	{
+		actx_error(actx, "response is not valid UTF-8");
+		return false;
+	}
+
+	makeJsonLexContextCstringLen(&lex, resp->data, resp->len, PG_UTF8, true);
+	setJsonLexContextOwnsTokens(&lex, true);	/* must not leak on error */
+
+	ctx.errbuf = &actx->errbuf;
+	ctx.fields = fields;
+	sem.semstate = &ctx;
+
+	sem.object_start = oauth_json_object_start;
+	sem.object_field_start = oauth_json_object_field_start;
+	sem.object_end = oauth_json_object_end;
+	sem.array_start = oauth_json_array_start;
+	sem.array_end = oauth_json_array_end;
+	sem.scalar = oauth_json_scalar;
+
+	err = pg_parse_json(&lex, &sem);
+
+	if (err != JSON_SUCCESS)
+	{
+		/*
+		 * For JSON_SEM_ACTION_FAILED, we've already written the error
+		 * message. Other errors come directly from pg_parse_json(), already
+		 * translated.
+		 */
+		if (err != JSON_SEM_ACTION_FAILED)
+			actx_error_str(actx, json_errdetail(err, &lex));
+
+		goto cleanup;
+	}
+
+	/* Check all required fields. */
+	while (fields->name)
+	{
+		if (fields->required
+			&& !*fields->target.scalar
+			&& !*fields->target.array)
+		{
+			actx_error(actx, "field \"%s\" is missing", fields->name);
+			goto cleanup;
+		}
+
+		fields++;
+	}
+
+	success = true;
+
+cleanup:
+	freeJsonLexContext(&lex);
+	return success;
+}
+
+/*
+ * JSON Parser Definitions
+ */
+
+/*
+ * Parses authorization server metadata. Fields are defined by OIDC Discovery
+ * 1.0 and RFC 8414.
+ */
+static bool
+parse_provider(struct async_ctx *actx, struct provider *provider)
+{
+	struct json_field fields[] = {
+		{"issuer", JSON_TOKEN_STRING, {&provider->issuer}, REQUIRED},
+		{"token_endpoint", JSON_TOKEN_STRING, {&provider->token_endpoint}, REQUIRED},
+
+		/*----
+		 * The following fields are technically REQUIRED, but we don't use
+		 * them anywhere yet:
+		 *
+		 * - jwks_uri
+		 * - response_types_supported
+		 * - subject_types_supported
+		 * - id_token_signing_alg_values_supported
+		 */
+
+		{"device_authorization_endpoint", JSON_TOKEN_STRING, {&provider->device_authorization_endpoint}, OPTIONAL},
+		{"grant_types_supported", JSON_TOKEN_ARRAY_START, {.array = &provider->grant_types_supported}, OPTIONAL},
+
+		{0},
+	};
+
+	return parse_oauth_json(actx, fields);
+}
+
+/*
+ * Parses a valid JSON number into a double. The input must have come from
+ * pg_parse_json(), so that we know the lexer has validated it; there's no
+ * in-band signal for invalid formats.
+ */
+static double
+parse_json_number(const char *s)
+{
+	double		parsed;
+	int			cnt;
+
+	/*
+	 * The JSON lexer has already validated the number, which is stricter than
+	 * the %f format, so we should be good to use sscanf().
+	 */
+	cnt = sscanf(s, "%lf", &parsed);
+
+	if (cnt != 1)
+	{
+		/*
+		 * Either the lexer screwed up or our assumption above isn't true, and
+		 * either way a developer needs to take a look.
+		 */
+		Assert(false);
+		return 0;
+	}
+
+	return parsed;
+}
+
+/*
+ * Parses the "interval" JSON number, corresponding to the number of seconds to
+ * wait between token endpoint requests.
+ *
+ * RFC 8628 is pretty silent on sanity checks for the interval. As a matter of
+ * practicality, round any fractional intervals up to the next second, and clamp
+ * the result at a minimum of one. (Zero-second intervals would result in an
+ * expensive network polling loop.) Tests may remove the lower bound with
+ * PGOAUTHDEBUG, for improved performance.
+ */
+static int
+parse_interval(struct async_ctx *actx, const char *interval_str)
+{
+	double		parsed;
+
+	parsed = parse_json_number(interval_str);
+	parsed = ceil(parsed);
+
+	if (parsed < 1)
+		return actx->debugging ? 0 : 1;
+
+	else if (parsed >= INT_MAX)
+		return INT_MAX;
+
+	return parsed;
+}
+
+/*
+ * Parses the "expires_in" JSON number, corresponding to the number of seconds
+ * remaining in the lifetime of the device code request.
+ *
+ * Similar to parse_interval, but we have even fewer requirements for reasonable
+ * values since we don't use the expiration time directly (it's passed to the
+ * PQAUTHDATA_PROMPT_OAUTH_DEVICE hook, in case the application wants to do
+ * something with it). We simply round down and clamp to int range.
+ */
+static int
+parse_expires_in(struct async_ctx *actx, const char *expires_in_str)
+{
+	double		parsed;
+
+	parsed = parse_json_number(expires_in_str);
+	parsed = floor(parsed);
+
+	if (parsed >= INT_MAX)
+		return INT_MAX;
+	else if (parsed <= INT_MIN)
+		return INT_MIN;
+
+	return parsed;
+}
+
+/*
+ * Parses the Device Authorization Response (RFC 8628, Sec. 3.2).
+ */
+static bool
+parse_device_authz(struct async_ctx *actx, struct device_authz *authz)
+{
+	struct json_field fields[] = {
+		{"device_code", JSON_TOKEN_STRING, {&authz->device_code}, REQUIRED},
+		{"user_code", JSON_TOKEN_STRING, {&authz->user_code}, REQUIRED},
+		{"verification_uri", JSON_TOKEN_STRING, {&authz->verification_uri}, REQUIRED},
+		{"expires_in", JSON_TOKEN_NUMBER, {&authz->expires_in_str}, REQUIRED},
+
+		/*
+		 * Some services (Google, Azure) spell verification_uri differently.
+		 * We accept either.
+		 */
+		{"verification_url", JSON_TOKEN_STRING, {&authz->verification_uri}, REQUIRED},
+
+		/*
+		 * There is no evidence of verification_uri_complete being spelled
+		 * with "url" instead with any service provider, so only support
+		 * "uri".
+		 */
+		{"verification_uri_complete", JSON_TOKEN_STRING, {&authz->verification_uri_complete}, OPTIONAL},
+		{"interval", JSON_TOKEN_NUMBER, {&authz->interval_str}, OPTIONAL},
+
+		{0},
+	};
+
+	if (!parse_oauth_json(actx, fields))
+		return false;
+
+	/*
+	 * Parse our numeric fields. Lexing has already completed by this time, so
+	 * we at least know they're valid JSON numbers.
+	 */
+	if (authz->interval_str)
+		authz->interval = parse_interval(actx, authz->interval_str);
+	else
+	{
+		/*
+		 * RFC 8628 specifies 5 seconds as the default value if the server
+		 * doesn't provide an interval.
+		 */
+		authz->interval = 5;
+	}
+
+	Assert(authz->expires_in_str);	/* ensured by parse_oauth_json() */
+	authz->expires_in = parse_expires_in(actx, authz->expires_in_str);
+
+	return true;
+}
+
+/*
+ * Parses the device access token error response (RFC 8628, Sec. 3.5, which
+ * uses the error response defined in RFC 6749, Sec. 5.2).
+ */
+static bool
+parse_token_error(struct async_ctx *actx, struct token_error *err)
+{
+	bool		result;
+	struct json_field fields[] = {
+		{"error", JSON_TOKEN_STRING, {&err->error}, REQUIRED},
+
+		{"error_description", JSON_TOKEN_STRING, {&err->error_description}, OPTIONAL},
+
+		{0},
+	};
+
+	result = parse_oauth_json(actx, fields);
+
+	/*
+	 * Since token errors are parsed during other active error paths, only
+	 * override the errctx if parsing explicitly fails.
+	 */
+	if (!result)
+		actx->errctx = "failed to parse token error response";
+
+	return result;
+}
+
+/*
+ * Constructs a message from the token error response and puts it into
+ * actx->errbuf.
+ */
+static void
+record_token_error(struct async_ctx *actx, const struct token_error *err)
+{
+	if (err->error_description)
+		appendPQExpBuffer(&actx->errbuf, "%s ", err->error_description);
+	else
+	{
+		/*
+		 * Try to get some more helpful detail into the error string. A 401
+		 * status in particular implies that the oauth_client_secret is
+		 * missing or wrong.
+		 */
+		long		response_code;
+
+		CHECK_GETINFO(actx, CURLINFO_RESPONSE_CODE, &response_code, response_code = 0);
+
+		if (response_code == 401)
+		{
+			actx_error(actx, actx->used_basic_auth
+					   ? "provider rejected the oauth_client_secret"
+					   : "provider requires client authentication, and no oauth_client_secret is set");
+			actx_error_str(actx, " ");
+		}
+	}
+
+	appendPQExpBuffer(&actx->errbuf, "(%s)", err->error);
+}
+
+/*
+ * Parses the device access token response (RFC 8628, Sec. 3.5, which uses the
+ * success response defined in RFC 6749, Sec. 5.1).
+ */
+static bool
+parse_access_token(struct async_ctx *actx, struct token *tok)
+{
+	struct json_field fields[] = {
+		{"access_token", JSON_TOKEN_STRING, {&tok->access_token}, REQUIRED},
+		{"token_type", JSON_TOKEN_STRING, {&tok->token_type}, REQUIRED},
+
+		/*---
+		 * We currently have no use for the following OPTIONAL fields:
+		 *
+		 * - expires_in: This will be important for maintaining a token cache,
+		 *               but we do not yet implement one.
+		 *
+		 * - refresh_token: Ditto.
+		 *
+		 * - scope: This is only sent when the authorization server sees fit to
+		 *          change our scope request. It's not clear what we should do
+		 *          about this; either it's been done as a matter of policy, or
+		 *          the user has explicitly denied part of the authorization,
+		 *          and either way the server-side validator is in a better
+		 *          place to complain if the change isn't acceptable.
+		 */
+
+		{0},
+	};
+
+	return parse_oauth_json(actx, fields);
+}
+
+/*
+ * libcurl Multi Setup/Callbacks
+ */
+
+/*
+ * Sets up the actx->mux, which is the altsock that PQconnectPoll clients will
+ * select() on instead of the Postgres socket during OAuth negotiation.
+ *
+ * This is just an epoll set or kqueue abstracting multiple other descriptors.
+ * For epoll, the timerfd is always part of the set; it's just disabled when
+ * we're not using it. For kqueue, the "timerfd" is actually a second kqueue
+ * instance which is only added to the set when needed.
+ */
+static bool
+setup_multiplexer(struct async_ctx *actx)
+{
+#ifdef HAVE_SYS_EPOLL_H
+	struct epoll_event ev = {.events = EPOLLIN};
+
+	actx->mux = epoll_create1(EPOLL_CLOEXEC);
+	if (actx->mux < 0)
+	{
+		actx_error(actx, "failed to create epoll set: %m");
+		return false;
+	}
+
+	actx->timerfd = timerfd_create(CLOCK_MONOTONIC, TFD_CLOEXEC);
+	if (actx->timerfd < 0)
+	{
+		actx_error(actx, "failed to create timerfd: %m");
+		return false;
+	}
+
+	if (epoll_ctl(actx->mux, EPOLL_CTL_ADD, actx->timerfd, &ev) < 0)
+	{
+		actx_error(actx, "failed to add timerfd to epoll set: %m");
+		return false;
+	}
+
+	return true;
+#endif
+#ifdef HAVE_SYS_EVENT_H
+	actx->mux = kqueue();
+	if (actx->mux < 0)
+	{
+		/*- translator: the term "kqueue" (kernel queue) should not be translated */
+		actx_error(actx, "failed to create kqueue: %m");
+		return false;
+	}
+
+	/*
+	 * Originally, we set EVFILT_TIMER directly on the top-level multiplexer.
+	 * This makes it difficult to implement timer_expired(), though, so now we
+	 * set EVFILT_TIMER on a separate actx->timerfd, which is chained to
+	 * actx->mux while the timer is active.
+	 */
+	actx->timerfd = kqueue();
+	if (actx->timerfd < 0)
+	{
+		actx_error(actx, "failed to create timer kqueue: %m");
+		return false;
+	}
+
+	return true;
+#endif
+
+	actx_error(actx, "libpq does not support the Device Authorization flow on this platform");
+	return false;
+}
+
+/*
+ * Adds and removes sockets from the multiplexer set, as directed by the
+ * libcurl multi handle.
+ */
+static int
+register_socket(CURL *curl, curl_socket_t socket, int what, void *ctx,
+				void *socketp)
+{
+#ifdef HAVE_SYS_EPOLL_H
+	struct async_ctx *actx = ctx;
+	struct epoll_event ev = {0};
+	int			res;
+	int			op = EPOLL_CTL_ADD;
+
+	switch (what)
+	{
+		case CURL_POLL_IN:
+			ev.events = EPOLLIN;
+			break;
+
+		case CURL_POLL_OUT:
+			ev.events = EPOLLOUT;
+			break;
+
+		case CURL_POLL_INOUT:
+			ev.events = EPOLLIN | EPOLLOUT;
+			break;
+
+		case CURL_POLL_REMOVE:
+			op = EPOLL_CTL_DEL;
+			break;
+
+		default:
+			actx_error(actx, "unknown libcurl socket operation: %d", what);
+			return -1;
+	}
+
+	res = epoll_ctl(actx->mux, op, socket, &ev);
+	if (res < 0 && errno == EEXIST)
+	{
+		/* We already had this socket in the pollset. */
+		op = EPOLL_CTL_MOD;
+		res = epoll_ctl(actx->mux, op, socket, &ev);
+	}
+
+	if (res < 0)
+	{
+		switch (op)
+		{
+			case EPOLL_CTL_ADD:
+				actx_error(actx, "could not add to epoll set: %m");
+				break;
+
+			case EPOLL_CTL_DEL:
+				actx_error(actx, "could not delete from epoll set: %m");
+				break;
+
+			default:
+				actx_error(actx, "could not update epoll set: %m");
+		}
+
+		return -1;
+	}
+
+	return 0;
+#endif
+#ifdef HAVE_SYS_EVENT_H
+	struct async_ctx *actx = ctx;
+	struct kevent ev[2] = {{0}};
+	struct kevent ev_out[2];
+	struct timespec timeout = {0};
+	int			nev = 0;
+	int			res;
+
+	switch (what)
+	{
+		case CURL_POLL_IN:
+			EV_SET(&ev[nev], socket, EVFILT_READ, EV_ADD | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			break;
+
+		case CURL_POLL_OUT:
+			EV_SET(&ev[nev], socket, EVFILT_WRITE, EV_ADD | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			break;
+
+		case CURL_POLL_INOUT:
+			EV_SET(&ev[nev], socket, EVFILT_READ, EV_ADD | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			EV_SET(&ev[nev], socket, EVFILT_WRITE, EV_ADD | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			break;
+
+		case CURL_POLL_REMOVE:
+
+			/*
+			 * We don't know which of these is currently registered, perhaps
+			 * both, so we try to remove both.  This means we need to tolerate
+			 * ENOENT below.
+			 */
+			EV_SET(&ev[nev], socket, EVFILT_READ, EV_DELETE | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			EV_SET(&ev[nev], socket, EVFILT_WRITE, EV_DELETE | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			break;
+
+		default:
+			actx_error(actx, "unknown libcurl socket operation: %d", what);
+			return -1;
+	}
+
+	res = kevent(actx->mux, ev, nev, ev_out, lengthof(ev_out), &timeout);
+	if (res < 0)
+	{
+		actx_error(actx, "could not modify kqueue: %m");
+		return -1;
+	}
+
+	/*
+	 * We can't use the simple errno version of kevent, because we need to
+	 * skip over ENOENT while still allowing a second change to be processed.
+	 * So we need a longer-form error checking loop.
+	 */
+	for (int i = 0; i < res; ++i)
+	{
+		/*
+		 * EV_RECEIPT should guarantee one EV_ERROR result for every change,
+		 * whether successful or not. Failed entries contain a non-zero errno
+		 * in the data field.
+		 */
+		Assert(ev_out[i].flags & EV_ERROR);
+
+		errno = ev_out[i].data;
+		if (errno && errno != ENOENT)
+		{
+			switch (what)
+			{
+				case CURL_POLL_REMOVE:
+					actx_error(actx, "could not delete from kqueue: %m");
+					break;
+				default:
+					actx_error(actx, "could not add to kqueue: %m");
+			}
+			return -1;
+		}
+	}
+
+	return 0;
+#endif
+
+	actx_error(actx, "libpq does not support multiplexer sockets on this platform");
+	return -1;
+}
+
+/*
+ * Enables or disables the timer in the multiplexer set. The timeout value is
+ * in milliseconds (negative values disable the timer).
+ *
+ * For epoll, rather than continually adding and removing the timer, we keep it
+ * in the set at all times and just disarm it when it's not needed. For kqueue,
+ * the timer is removed completely when disabled to prevent stale timeouts from
+ * remaining in the queue.
+ */
+static bool
+set_timer(struct async_ctx *actx, long timeout)
+{
+#if HAVE_SYS_EPOLL_H
+	struct itimerspec spec = {0};
+
+	if (timeout < 0)
+	{
+		/* the zero itimerspec will disarm the timer below */
+	}
+	else if (timeout == 0)
+	{
+		/*
+		 * A zero timeout means libcurl wants us to call back immediately.
+		 * That's not technically an option for timerfd, but we can make the
+		 * timeout ridiculously short.
+		 */
+		spec.it_value.tv_nsec = 1;
+	}
+	else
+	{
+		spec.it_value.tv_sec = timeout / 1000;
+		spec.it_value.tv_nsec = (timeout % 1000) * 1000000;
+	}
+
+	if (timerfd_settime(actx->timerfd, 0 /* no flags */ , &spec, NULL) < 0)
+	{
+		actx_error(actx, "setting timerfd to %ld: %m", timeout);
+		return false;
+	}
+
+	return true;
+#endif
+#ifdef HAVE_SYS_EVENT_H
+	struct kevent ev;
+
+	/* Enable/disable the timer itself. */
+	EV_SET(&ev, 1, EVFILT_TIMER, timeout < 0 ? EV_DELETE : (EV_ADD | EV_ONESHOT),
+		   0, timeout, 0);
+	if (kevent(actx->timerfd, &ev, 1, NULL, 0, NULL) < 0 && errno != ENOENT)
+	{
+		actx_error(actx, "setting kqueue timer to %ld: %m", timeout);
+		return false;
+	}
+
+	/*
+	 * Add/remove the timer to/from the mux. (In contrast with epoll, if we
+	 * allowed the timer to remain registered here after being disabled, the
+	 * mux queue would retain any previous stale timeout notifications and
+	 * remain readable.)
+	 */
+	EV_SET(&ev, actx->timerfd, EVFILT_READ, timeout < 0 ? EV_DELETE : EV_ADD,
+		   0, 0, 0);
+	if (kevent(actx->mux, &ev, 1, NULL, 0, NULL) < 0 && errno != ENOENT)
+	{
+		actx_error(actx, "could not update timer on kqueue: %m");
+		return false;
+	}
+
+	return true;
+#endif
+
+	actx_error(actx, "libpq does not support timers on this platform");
+	return false;
+}
+
+/*
+ * Returns 1 if the timeout in the multiplexer set has expired since the last
+ * call to set_timer(), 0 if the timer is still running, or -1 (with an
+ * actx_error() report) if the timer cannot be queried.
+ */
+static int
+timer_expired(struct async_ctx *actx)
+{
+#if HAVE_SYS_EPOLL_H
+	struct itimerspec spec = {0};
+
+	if (timerfd_gettime(actx->timerfd, &spec) < 0)
+	{
+		actx_error(actx, "getting timerfd value: %m");
+		return -1;
+	}
+
+	/*
+	 * This implementation assumes we're using single-shot timers. If you
+	 * change to using intervals, you'll need to reimplement this function
+	 * too, possibly with the read() or select() interfaces for timerfd.
+	 */
+	Assert(spec.it_interval.tv_sec == 0
+		   && spec.it_interval.tv_nsec == 0);
+
+	/* If the remaining time to expiration is zero, we're done. */
+	return (spec.it_value.tv_sec == 0
+			&& spec.it_value.tv_nsec == 0);
+#endif
+#ifdef HAVE_SYS_EVENT_H
+	int			res;
+
+	/* Is the timer queue ready? */
+	res = PQsocketPoll(actx->timerfd, 1 /* forRead */ , 0, 0);
+	if (res < 0)
+	{
+		actx_error(actx, "checking kqueue for timeout: %m");
+		return -1;
+	}
+
+	return (res > 0);
+#endif
+
+	actx_error(actx, "libpq does not support timers on this platform");
+	return -1;
+}
+
+/*
+ * Adds or removes timeouts from the multiplexer set, as directed by the
+ * libcurl multi handle.
+ */
+static int
+register_timer(CURLM *curlm, long timeout, void *ctx)
+{
+	struct async_ctx *actx = ctx;
+
+	/*
+	 * There might be an optimization opportunity here: if timeout == 0, we
+	 * could signal drive_request to immediately call
+	 * curl_multi_socket_action, rather than returning all the way up the
+	 * stack only to come right back. But it's not clear that the additional
+	 * code complexity is worth it.
+	 */
+	if (!set_timer(actx, timeout))
+		return -1;				/* actx_error already called */
+
+	return 0;
+}
+
+/*
+ * Prints Curl request debugging information to stderr.
+ *
+ * Note that this will expose a number of critical secrets, so users have to opt
+ * into this (see PGOAUTHDEBUG).
+ */
+static int
+debug_callback(CURL *handle, curl_infotype type, char *data, size_t size,
+			   void *clientp)
+{
+	const char *prefix;
+	bool		printed_prefix = false;
+	PQExpBufferData buf;
+
+	/* Prefixes are modeled off of the default libcurl debug output. */
+	switch (type)
+	{
+		case CURLINFO_TEXT:
+			prefix = "*";
+			break;
+
+		case CURLINFO_HEADER_IN:	/* fall through */
+		case CURLINFO_DATA_IN:
+			prefix = "<";
+			break;
+
+		case CURLINFO_HEADER_OUT:	/* fall through */
+		case CURLINFO_DATA_OUT:
+			prefix = ">";
+			break;
+
+		default:
+			return 0;
+	}
+
+	initPQExpBuffer(&buf);
+
+	/*
+	 * Split the output into lines for readability; sometimes multiple headers
+	 * are included in a single call. We also don't allow unprintable ASCII
+	 * through without a basic <XX> escape.
+	 */
+	for (int i = 0; i < size; i++)
+	{
+		char		c = data[i];
+
+		if (!printed_prefix)
+		{
+			appendPQExpBuffer(&buf, "[libcurl] %s ", prefix);
+			printed_prefix = true;
+		}
+
+		if (c >= 0x20 && c <= 0x7E)
+			appendPQExpBufferChar(&buf, c);
+		else if ((type == CURLINFO_HEADER_IN
+				  || type == CURLINFO_HEADER_OUT
+				  || type == CURLINFO_TEXT)
+				 && (c == '\r' || c == '\n'))
+		{
+			/*
+			 * Don't bother emitting <0D><0A> for headers and text; it's not
+			 * helpful noise.
+			 */
+		}
+		else
+			appendPQExpBuffer(&buf, "<%02X>", c);
+
+		if (c == '\n')
+		{
+			appendPQExpBufferChar(&buf, c);
+			printed_prefix = false;
+		}
+	}
+
+	if (printed_prefix)
+		appendPQExpBufferChar(&buf, '\n');	/* finish the line */
+
+	fprintf(stderr, "%s", buf.data);
+	termPQExpBuffer(&buf);
+	return 0;
+}
+
+/*
+ * Initializes the two libcurl handles in the async_ctx. The multi handle,
+ * actx->curlm, is what drives the asynchronous engine and tells us what to do
+ * next. The easy handle, actx->curl, encapsulates the state for a single
+ * request/response. It's added to the multi handle as needed, during
+ * start_request().
+ */
+static bool
+setup_curl_handles(struct async_ctx *actx)
+{
+	/*
+	 * Create our multi handle. This encapsulates the entire conversation with
+	 * libcurl for this connection.
+	 */
+	actx->curlm = curl_multi_init();
+	if (!actx->curlm)
+	{
+		/* We don't get a lot of feedback on the failure reason. */
+		actx_error(actx, "failed to create libcurl multi handle");
+		return false;
+	}
+
+	/*
+	 * The multi handle tells us what to wait on using two callbacks. These
+	 * will manipulate actx->mux as needed.
+	 */
+	CHECK_MSETOPT(actx, CURLMOPT_SOCKETFUNCTION, register_socket, return false);
+	CHECK_MSETOPT(actx, CURLMOPT_SOCKETDATA, actx, return false);
+	CHECK_MSETOPT(actx, CURLMOPT_TIMERFUNCTION, register_timer, return false);
+	CHECK_MSETOPT(actx, CURLMOPT_TIMERDATA, actx, return false);
+
+	/*
+	 * Set up an easy handle. All of our requests are made serially, so we
+	 * only ever need to keep track of one.
+	 */
+	actx->curl = curl_easy_init();
+	if (!actx->curl)
+	{
+		actx_error(actx, "failed to create libcurl handle");
+		return false;
+	}
+
+	/*
+	 * Multi-threaded applications must set CURLOPT_NOSIGNAL. This requires us
+	 * to handle the possibility of SIGPIPE ourselves using pq_block_sigpipe;
+	 * see pg_fe_run_oauth_flow().
+	 *
+	 * NB: If libcurl is not built against a friendly DNS resolver (c-ares or
+	 * threaded), setting this option prevents DNS lookups from timing out
+	 * correctly. We warn about this situation at configure time.
+	 *
+	 * TODO: Perhaps there's a clever way to warn the user about synchronous
+	 * DNS at runtime too? It's not immediately clear how to do that in a
+	 * helpful way: for many standard single-threaded use cases, the user
+	 * might not care at all, so spraying warnings to stderr would probably do
+	 * more harm than good.
+	 */
+	CHECK_SETOPT(actx, CURLOPT_NOSIGNAL, 1L, return false);
+
+	if (actx->debugging)
+	{
+		/*
+		 * Set a callback for retrieving error information from libcurl, the
+		 * function only takes effect when CURLOPT_VERBOSE has been set so
+		 * make sure the order is kept.
+		 */
+		CHECK_SETOPT(actx, CURLOPT_DEBUGFUNCTION, debug_callback, return false);
+		CHECK_SETOPT(actx, CURLOPT_VERBOSE, 1L, return false);
+	}
+
+	CHECK_SETOPT(actx, CURLOPT_ERRORBUFFER, actx->curl_err, return false);
+
+	/*
+	 * Only HTTPS is allowed. (Debug mode additionally allows HTTP; this is
+	 * intended for testing only.)
+	 *
+	 * There's a bit of unfortunate complexity around the choice of
+	 * CURLoption. CURLOPT_PROTOCOLS is deprecated in modern Curls, but its
+	 * replacement didn't show up until relatively recently.
+	 */
+	{
+#if CURL_AT_LEAST_VERSION(7, 85, 0)
+		const CURLoption popt = CURLOPT_PROTOCOLS_STR;
+		const char *protos = "https";
+		const char *const unsafe = "https,http";
+#else
+		const CURLoption popt = CURLOPT_PROTOCOLS;
+		long		protos = CURLPROTO_HTTPS;
+		const long	unsafe = CURLPROTO_HTTPS | CURLPROTO_HTTP;
+#endif
+
+		if (actx->debugging)
+			protos = unsafe;
+
+		CHECK_SETOPT(actx, popt, protos, return false);
+	}
+
+	/*
+	 * If we're in debug mode, allow the developer to change the trusted CA
+	 * list. For now, this is not something we expose outside of the UNSAFE
+	 * mode, because it's not clear that it's useful in production: both libpq
+	 * and the user's browser must trust the same authorization servers for
+	 * the flow to work at all, so any changes to the roots are likely to be
+	 * done system-wide.
+	 */
+	if (actx->debugging)
+	{
+		const char *env;
+
+		if ((env = getenv("PGOAUTHCAFILE")) != NULL)
+			CHECK_SETOPT(actx, CURLOPT_CAINFO, env, return false);
+	}
+
+	/*
+	 * Suppress the Accept header to make our request as minimal as possible.
+	 * (Ideally we would set it to "application/json" instead, but OpenID is
+	 * pretty strict when it comes to provider behavior, so we have to check
+	 * what comes back anyway.)
+	 */
+	actx->headers = curl_slist_append(actx->headers, "Accept:");
+	if (actx->headers == NULL)
+	{
+		actx_error(actx, "out of memory");
+		return false;
+	}
+	CHECK_SETOPT(actx, CURLOPT_HTTPHEADER, actx->headers, return false);
+
+	return true;
+}
+
+/*
+ * Generic HTTP Request Handlers
+ */
+
+/*
+ * Response callback from libcurl which appends the response body into
+ * actx->work_data (see start_request()). The maximum size of the data is
+ * defined by CURL_MAX_WRITE_SIZE which by default is 16kb (and can only be
+ * changed by recompiling libcurl).
+ */
+static size_t
+append_data(char *buf, size_t size, size_t nmemb, void *userdata)
+{
+	struct async_ctx *actx = userdata;
+	PQExpBuffer resp = &actx->work_data;
+	size_t		len = size * nmemb;
+
+	/* In case we receive data over the threshold, abort the transfer */
+	if ((resp->len + len) > MAX_OAUTH_RESPONSE_SIZE)
+	{
+		actx_error(actx, "response is too large");
+		return 0;
+	}
+
+	/* The data passed from libcurl is not null-terminated */
+	appendBinaryPQExpBuffer(resp, buf, len);
+
+	/*
+	 * Signal an error in order to abort the transfer in case we ran out of
+	 * memory in accepting the data.
+	 */
+	if (PQExpBufferBroken(resp))
+	{
+		actx_error(actx, "out of memory");
+		return 0;
+	}
+
+	return len;
+}
+
+/*
+ * Begins an HTTP request on the multi handle. The caller should have set up all
+ * request-specific options on actx->curl first. The server's response body will
+ * be accumulated in actx->work_data (which will be reset, so don't store
+ * anything important there across this call).
+ *
+ * Once a request is queued, it can be driven to completion via drive_request().
+ * If actx->running is zero upon return, the request has already finished and
+ * drive_request() can be called without returning control to the client.
+ */
+static bool
+start_request(struct async_ctx *actx)
+{
+	CURLMcode	err;
+
+	resetPQExpBuffer(&actx->work_data);
+	CHECK_SETOPT(actx, CURLOPT_WRITEFUNCTION, append_data, return false);
+	CHECK_SETOPT(actx, CURLOPT_WRITEDATA, actx, return false);
+
+	err = curl_multi_add_handle(actx->curlm, actx->curl);
+	if (err)
+	{
+		actx_error(actx, "failed to queue HTTP request: %s",
+				   curl_multi_strerror(err));
+		return false;
+	}
+
+	/*
+	 * actx->running tracks the number of running handles, so we can
+	 * immediately call back if no waiting is needed.
+	 *
+	 * Even though this is nominally an asynchronous process, there are some
+	 * operations that can synchronously fail by this point (e.g. connections
+	 * to closed local ports) or even synchronously succeed if the stars align
+	 * (all the libcurl connection caches hit and the server is fast).
+	 */
+	err = curl_multi_socket_action(actx->curlm, CURL_SOCKET_TIMEOUT, 0, &actx->running);
+	if (err)
+	{
+		actx_error(actx, "asynchronous HTTP request failed: %s",
+				   curl_multi_strerror(err));
+		return false;
+	}
+
+	return true;
+}
+
+/*
+ * CURL_IGNORE_DEPRECATION was added in 7.87.0. If it's not defined, we can make
+ * it a no-op.
+ */
+#ifndef CURL_IGNORE_DEPRECATION
+#define CURL_IGNORE_DEPRECATION(x) x
+#endif
+
+/*
+ * Drives the multi handle towards completion. The caller should have already
+ * set up an asynchronous request via start_request().
+ */
+static PostgresPollingStatusType
+drive_request(struct async_ctx *actx)
+{
+	CURLMcode	err;
+	CURLMsg    *msg;
+	int			msgs_left;
+	bool		done;
+
+	if (actx->running)
+	{
+		/*---
+		 * There's an async request in progress. Pump the multi handle.
+		 *
+		 * curl_multi_socket_all() is officially deprecated, because it's
+		 * inefficient and pointless if your event loop has already handed you
+		 * the exact sockets that are ready. But that's not our use case --
+		 * our client has no way to tell us which sockets are ready. (They
+		 * don't even know there are sockets to begin with.)
+		 *
+		 * We can grab the list of triggered events from the multiplexer
+		 * ourselves, but that's effectively what curl_multi_socket_all() is
+		 * going to do. And there are currently no plans for the Curl project
+		 * to remove or break this API, so ignore the deprecation. See
+		 *
+		 *    https://curl.se/mail/lib-2024-11/0028.html
+		 *
+		 */
+		CURL_IGNORE_DEPRECATION(
+			err = curl_multi_socket_all(actx->curlm, &actx->running);
+		)
+
+		if (err)
+		{
+			actx_error(actx, "asynchronous HTTP request failed: %s",
+					   curl_multi_strerror(err));
+			return PGRES_POLLING_FAILED;
+		}
+
+		if (actx->running)
+		{
+			/* We'll come back again. */
+			return PGRES_POLLING_READING;
+		}
+	}
+
+	done = false;
+	while ((msg = curl_multi_info_read(actx->curlm, &msgs_left)) != NULL)
+	{
+		if (msg->msg != CURLMSG_DONE)
+		{
+			/*
+			 * Future libcurl versions may define new message types; we don't
+			 * know how to handle them, so we'll ignore them.
+			 */
+			continue;
+		}
+
+		/* First check the status of the request itself. */
+		if (msg->data.result != CURLE_OK)
+		{
+			/*
+			 * If a more specific error hasn't already been reported, use
+			 * libcurl's description.
+			 */
+			if (actx->errbuf.len == 0)
+				actx_error_str(actx, curl_easy_strerror(msg->data.result));
+
+			return PGRES_POLLING_FAILED;
+		}
+
+		/* Now remove the finished handle; we'll add it back later if needed. */
+		err = curl_multi_remove_handle(actx->curlm, msg->easy_handle);
+		if (err)
+		{
+			actx_error(actx, "libcurl easy handle removal failed: %s",
+					   curl_multi_strerror(err));
+			return PGRES_POLLING_FAILED;
+		}
+
+		done = true;
+	}
+
+	/* Sanity check. */
+	if (!done)
+	{
+		actx_error(actx, "no result was retrieved for the finished handle");
+		return PGRES_POLLING_FAILED;
+	}
+
+	return PGRES_POLLING_OK;
+}
+
+/*
+ * URL-Encoding Helpers
+ */
+
+/*
+ * Encodes a string using the application/x-www-form-urlencoded format, and
+ * appends it to the given buffer.
+ */
+static void
+append_urlencoded(PQExpBuffer buf, const char *s)
+{
+	char	   *escaped;
+	char	   *haystack;
+	char	   *match;
+
+	/* The first parameter to curl_easy_escape is deprecated by Curl */
+	escaped = curl_easy_escape(NULL, s, 0);
+	if (!escaped)
+	{
+		termPQExpBuffer(buf);	/* mark the buffer broken */
+		return;
+	}
+
+	/*
+	 * curl_easy_escape() almost does what we want, but we need the
+	 * query-specific flavor which uses '+' instead of '%20' for spaces. The
+	 * Curl command-line tool does this with a simple search-and-replace, so
+	 * follow its lead.
+	 */
+	haystack = escaped;
+
+	while ((match = strstr(haystack, "%20")) != NULL)
+	{
+		/* Append the unmatched portion, followed by the plus sign. */
+		appendBinaryPQExpBuffer(buf, haystack, match - haystack);
+		appendPQExpBufferChar(buf, '+');
+
+		/* Keep searching after the match. */
+		haystack = match + 3 /* strlen("%20") */ ;
+	}
+
+	/* Push the remainder of the string onto the buffer. */
+	appendPQExpBufferStr(buf, haystack);
+
+	curl_free(escaped);
+}
+
+/*
+ * Convenience wrapper for encoding a single string. Returns NULL on allocation
+ * failure.
+ */
+static char *
+urlencode(const char *s)
+{
+	PQExpBufferData buf;
+
+	initPQExpBuffer(&buf);
+	append_urlencoded(&buf, s);
+
+	return PQExpBufferDataBroken(buf) ? NULL : buf.data;
+}
+
+/*
+ * Appends a key/value pair to the end of an application/x-www-form-urlencoded
+ * list.
+ */
+static void
+build_urlencoded(PQExpBuffer buf, const char *key, const char *value)
+{
+	if (buf->len)
+		appendPQExpBufferChar(buf, '&');
+
+	append_urlencoded(buf, key);
+	appendPQExpBufferChar(buf, '=');
+	append_urlencoded(buf, value);
+}
+
+/*
+ * Specific HTTP Request Handlers
+ *
+ * This is finally the beginning of the actual application logic. Generally
+ * speaking, a single request consists of a start_* and a finish_* step, with
+ * drive_request() pumping the machine in between.
+ */
+
+/*
+ * Queue an OpenID Provider Configuration Request:
+ *
+ *     https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationRequest
+ *     https://www.rfc-editor.org/rfc/rfc8414#section-3.1
+ *
+ * This is done first to get the endpoint URIs we need to contact and to make
+ * sure the provider provides a device authorization flow. finish_discovery()
+ * will fill in actx->provider.
+ */
+static bool
+start_discovery(struct async_ctx *actx, const char *discovery_uri)
+{
+	CHECK_SETOPT(actx, CURLOPT_HTTPGET, 1L, return false);
+	CHECK_SETOPT(actx, CURLOPT_URL, discovery_uri, return false);
+
+	return start_request(actx);
+}
+
+static bool
+finish_discovery(struct async_ctx *actx)
+{
+	long		response_code;
+
+	/*----
+	 * Now check the response. OIDC Discovery 1.0 is pretty strict:
+	 *
+	 *     A successful response MUST use the 200 OK HTTP status code and
+	 *     return a JSON object using the application/json content type that
+	 *     contains a set of Claims as its members that are a subset of the
+	 *     Metadata values defined in Section 3.
+	 *
+	 * Compared to standard HTTP semantics, this makes life easy -- we don't
+	 * need to worry about redirections (which would call the Issuer host
+	 * validation into question), or non-authoritative responses, or any other
+	 * complications.
+	 */
+	CHECK_GETINFO(actx, CURLINFO_RESPONSE_CODE, &response_code, return false);
+
+	if (response_code != 200)
+	{
+		actx_error(actx, "unexpected response code %ld", response_code);
+		return false;
+	}
+
+	/*
+	 * Pull the fields we care about from the document.
+	 */
+	actx->errctx = "failed to parse OpenID discovery document";
+	if (!parse_provider(actx, &actx->provider))
+		return false;			/* error message already set */
+
+	/*
+	 * Fill in any defaults for OPTIONAL/RECOMMENDED fields we care about.
+	 */
+	if (!actx->provider.grant_types_supported)
+	{
+		/*
+		 * Per Section 3, the default is ["authorization_code", "implicit"].
+		 */
+		struct curl_slist *temp = actx->provider.grant_types_supported;
+
+		temp = curl_slist_append(temp, "authorization_code");
+		if (temp)
+		{
+			temp = curl_slist_append(temp, "implicit");
+		}
+
+		if (!temp)
+		{
+			actx_error(actx, "out of memory");
+			return false;
+		}
+
+		actx->provider.grant_types_supported = temp;
+	}
+
+	return true;
+}
+
+/*
+ * Ensure that the discovery document is provided by the expected issuer.
+ * Currently, issuers are statically configured in the connection string.
+ */
+static bool
+check_issuer(struct async_ctx *actx, PGconn *conn)
+{
+	const struct provider *provider = &actx->provider;
+
+	Assert(conn->oauth_issuer_id);	/* ensured by setup_oauth_parameters() */
+	Assert(provider->issuer);	/* ensured by parse_provider() */
+
+	/*---
+	 * We require strict equality for issuer identifiers -- no path or case
+	 * normalization, no substitution of default ports and schemes, etc. This
+	 * is done to match the rules in OIDC Discovery Sec. 4.3 for config
+	 * validation:
+	 *
+	 *    The issuer value returned MUST be identical to the Issuer URL that
+	 *    was used as the prefix to /.well-known/openid-configuration to
+	 *    retrieve the configuration information.
+	 *
+	 * as well as the rules set out in RFC 9207 for avoiding mix-up attacks:
+	 *
+	 *    Clients MUST then [...] compare the result to the issuer identifier
+	 *    of the authorization server where the authorization request was
+	 *    sent to. This comparison MUST use simple string comparison as defined
+	 *    in Section 6.2.1 of [RFC3986].
+	 */
+	if (strcmp(conn->oauth_issuer_id, provider->issuer) != 0)
+	{
+		actx_error(actx,
+				   "the issuer identifier (%s) does not match oauth_issuer (%s)",
+				   provider->issuer, conn->oauth_issuer_id);
+		return false;
+	}
+
+	return true;
+}
+
+#define HTTPS_SCHEME "https://"
+#define OAUTH_GRANT_TYPE_DEVICE_CODE "urn:ietf:params:oauth:grant-type:device_code"
+
+/*
+ * Ensure that the provider supports the Device Authorization flow (i.e. it
+ * provides an authorization endpoint, and both the token and authorization
+ * endpoint URLs seem reasonable).
+ */
+static bool
+check_for_device_flow(struct async_ctx *actx)
+{
+	const struct provider *provider = &actx->provider;
+
+	Assert(provider->issuer);	/* ensured by parse_provider() */
+	Assert(provider->token_endpoint);	/* ensured by parse_provider() */
+
+	if (!provider->device_authorization_endpoint)
+	{
+		actx_error(actx,
+				   "issuer \"%s\" does not provide a device authorization endpoint",
+				   provider->issuer);
+		return false;
+	}
+
+	/*
+	 * The original implementation checked that OAUTH_GRANT_TYPE_DEVICE_CODE
+	 * was present in the discovery document's grant_types_supported list. MS
+	 * Entra does not advertise this grant type, though, and since it doesn't
+	 * make sense to stand up a device_authorization_endpoint without also
+	 * accepting device codes at the token_endpoint, that's the only thing we
+	 * currently require.
+	 */
+
+	/*
+	 * Although libcurl will fail later if the URL contains an unsupported
+	 * scheme, that error message is going to be a bit opaque. This is a
+	 * decent time to bail out if we're not using HTTPS for the endpoints
+	 * we'll use for the flow.
+	 */
+	if (!actx->debugging)
+	{
+		if (pg_strncasecmp(provider->device_authorization_endpoint,
+						   HTTPS_SCHEME, strlen(HTTPS_SCHEME)) != 0)
+		{
+			actx_error(actx,
+					   "device authorization endpoint \"%s\" must use HTTPS",
+					   provider->device_authorization_endpoint);
+			return false;
+		}
+
+		if (pg_strncasecmp(provider->token_endpoint,
+						   HTTPS_SCHEME, strlen(HTTPS_SCHEME)) != 0)
+		{
+			actx_error(actx,
+					   "token endpoint \"%s\" must use HTTPS",
+					   provider->token_endpoint);
+			return false;
+		}
+	}
+
+	return true;
+}
+
+/*
+ * Adds the client ID (and secret, if provided) to the current request, using
+ * either HTTP headers or the request body.
+ */
+static bool
+add_client_identification(struct async_ctx *actx, PQExpBuffer reqbody, PGconn *conn)
+{
+	bool		success = false;
+	char	   *username = NULL;
+	char	   *password = NULL;
+
+	if (conn->oauth_client_secret)	/* Zero-length secrets are permitted! */
+	{
+		/*----
+		 * Use HTTP Basic auth to send the client_id and secret. Per RFC 6749,
+		 * Sec. 2.3.1,
+		 *
+		 *   Including the client credentials in the request-body using the
+		 *   two parameters is NOT RECOMMENDED and SHOULD be limited to
+		 *   clients unable to directly utilize the HTTP Basic authentication
+		 *   scheme (or other password-based HTTP authentication schemes).
+		 *
+		 * Additionally:
+		 *
+		 *   The client identifier is encoded using the
+		 *   "application/x-www-form-urlencoded" encoding algorithm per Appendix
+		 *   B, and the encoded value is used as the username; the client
+		 *   password is encoded using the same algorithm and used as the
+		 *   password.
+		 *
+		 * (Appendix B modifies application/x-www-form-urlencoded by requiring
+		 * an initial UTF-8 encoding step. Since the client ID and secret must
+		 * both be 7-bit ASCII -- RFC 6749 Appendix A -- we don't worry about
+		 * that in this function.)
+		 *
+		 * client_id is not added to the request body in this case. Not only
+		 * would it be redundant, but some providers in the wild (e.g. Okta)
+		 * refuse to accept it.
+		 */
+		username = urlencode(conn->oauth_client_id);
+		password = urlencode(conn->oauth_client_secret);
+
+		if (!username || !password)
+		{
+			actx_error(actx, "out of memory");
+			goto cleanup;
+		}
+
+		CHECK_SETOPT(actx, CURLOPT_HTTPAUTH, CURLAUTH_BASIC, goto cleanup);
+		CHECK_SETOPT(actx, CURLOPT_USERNAME, username, goto cleanup);
+		CHECK_SETOPT(actx, CURLOPT_PASSWORD, password, goto cleanup);
+
+		actx->used_basic_auth = true;
+	}
+	else
+	{
+		/*
+		 * If we're not otherwise authenticating, client_id is REQUIRED in the
+		 * request body.
+		 */
+		build_urlencoded(reqbody, "client_id", conn->oauth_client_id);
+
+		CHECK_SETOPT(actx, CURLOPT_HTTPAUTH, CURLAUTH_NONE, goto cleanup);
+		actx->used_basic_auth = false;
+	}
+
+	success = true;
+
+cleanup:
+	free(username);
+	free(password);
+
+	return success;
+}
+
+/*
+ * Queue a Device Authorization Request:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc8628#section-3.1
+ *
+ * This is the second step. We ask the provider to verify the end user out of
+ * band and authorize us to act on their behalf; it will give us the required
+ * nonces for us to later poll the request status, which we'll grab in
+ * finish_device_authz().
+ */
+static bool
+start_device_authz(struct async_ctx *actx, PGconn *conn)
+{
+	const char *device_authz_uri = actx->provider.device_authorization_endpoint;
+	PQExpBuffer work_buffer = &actx->work_data;
+
+	Assert(conn->oauth_client_id);	/* ensured by setup_oauth_parameters() */
+	Assert(device_authz_uri);	/* ensured by check_for_device_flow() */
+
+	/* Construct our request body. */
+	resetPQExpBuffer(work_buffer);
+	if (conn->oauth_scope && conn->oauth_scope[0])
+		build_urlencoded(work_buffer, "scope", conn->oauth_scope);
+
+	if (!add_client_identification(actx, work_buffer, conn))
+		return false;
+
+	if (PQExpBufferBroken(work_buffer))
+	{
+		actx_error(actx, "out of memory");
+		return false;
+	}
+
+	/* Make our request. */
+	CHECK_SETOPT(actx, CURLOPT_URL, device_authz_uri, return false);
+	CHECK_SETOPT(actx, CURLOPT_COPYPOSTFIELDS, work_buffer->data, return false);
+
+	return start_request(actx);
+}
+
+static bool
+finish_device_authz(struct async_ctx *actx)
+{
+	long		response_code;
+
+	CHECK_GETINFO(actx, CURLINFO_RESPONSE_CODE, &response_code, return false);
+
+	/*
+	 * Per RFC 8628, Section 3, a successful device authorization response
+	 * uses 200 OK.
+	 */
+	if (response_code == 200)
+	{
+		actx->errctx = "failed to parse device authorization";
+		if (!parse_device_authz(actx, &actx->authz))
+			return false;		/* error message already set */
+
+		return true;
+	}
+
+	/*
+	 * The device authorization endpoint uses the same error response as the
+	 * token endpoint, so the error handling roughly follows
+	 * finish_token_request(). The key difference is that an error here is
+	 * immediately fatal.
+	 */
+	if (response_code == 400 || response_code == 401)
+	{
+		struct token_error err = {0};
+
+		if (!parse_token_error(actx, &err))
+		{
+			free_token_error(&err);
+			return false;
+		}
+
+		/* Copy the token error into the context error buffer */
+		record_token_error(actx, &err);
+
+		free_token_error(&err);
+		return false;
+	}
+
+	/* Any other response codes are considered invalid */
+	actx_error(actx, "unexpected response code %ld", response_code);
+	return false;
+}
+
+/*
+ * Queue an Access Token Request:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc6749#section-4.1.3
+ *
+ * This is the final step. We continually poll the token endpoint to see if the
+ * user has authorized us yet. finish_token_request() will pull either the token
+ * or a (ideally temporary) error status from the provider.
+ */
+static bool
+start_token_request(struct async_ctx *actx, PGconn *conn)
+{
+	const char *token_uri = actx->provider.token_endpoint;
+	const char *device_code = actx->authz.device_code;
+	PQExpBuffer work_buffer = &actx->work_data;
+
+	Assert(conn->oauth_client_id);	/* ensured by setup_oauth_parameters() */
+	Assert(token_uri);			/* ensured by parse_provider() */
+	Assert(device_code);		/* ensured by parse_device_authz() */
+
+	/* Construct our request body. */
+	resetPQExpBuffer(work_buffer);
+	build_urlencoded(work_buffer, "device_code", device_code);
+	build_urlencoded(work_buffer, "grant_type", OAUTH_GRANT_TYPE_DEVICE_CODE);
+
+	if (!add_client_identification(actx, work_buffer, conn))
+		return false;
+
+	if (PQExpBufferBroken(work_buffer))
+	{
+		actx_error(actx, "out of memory");
+		return false;
+	}
+
+	/* Make our request. */
+	CHECK_SETOPT(actx, CURLOPT_URL, token_uri, return false);
+	CHECK_SETOPT(actx, CURLOPT_COPYPOSTFIELDS, work_buffer->data, return false);
+
+	return start_request(actx);
+}
+
+static bool
+finish_token_request(struct async_ctx *actx, struct token *tok)
+{
+	long		response_code;
+
+	CHECK_GETINFO(actx, CURLINFO_RESPONSE_CODE, &response_code, return false);
+
+	/*
+	 * Per RFC 6749, Section 5, a successful response uses 200 OK.
+	 */
+	if (response_code == 200)
+	{
+		actx->errctx = "failed to parse access token response";
+		if (!parse_access_token(actx, tok))
+			return false;		/* error message already set */
+
+		return true;
+	}
+
+	/*
+	 * An error response uses either 400 Bad Request or 401 Unauthorized.
+	 * There are references online to implementations using 403 for error
+	 * return which would violate the specification. For now we stick to the
+	 * specification but we might have to revisit this.
+	 */
+	if (response_code == 400 || response_code == 401)
+	{
+		if (!parse_token_error(actx, &tok->err))
+			return false;
+
+		return true;
+	}
+
+	/* Any other response codes are considered invalid */
+	actx_error(actx, "unexpected response code %ld", response_code);
+	return false;
+}
+
+/*
+ * Finishes the token request and examines the response. If the flow has
+ * completed, a valid token will be returned via the parameter list. Otherwise,
+ * the token parameter remains unchanged, and the caller needs to wait for
+ * another interval (which will have been increased in response to a slow_down
+ * message from the server) before starting a new token request.
+ *
+ * False is returned only for permanent error conditions.
+ */
+static bool
+handle_token_response(struct async_ctx *actx, char **token)
+{
+	bool		success = false;
+	struct token tok = {0};
+	const struct token_error *err;
+
+	if (!finish_token_request(actx, &tok))
+		goto token_cleanup;
+
+	/* A successful token request gives either a token or an in-band error. */
+	Assert(tok.access_token || tok.err.error);
+
+	if (tok.access_token)
+	{
+		*token = tok.access_token;
+		tok.access_token = NULL;
+
+		success = true;
+		goto token_cleanup;
+	}
+
+	/*
+	 * authorization_pending and slow_down are the only acceptable errors;
+	 * anything else and we bail. These are defined in RFC 8628, Sec. 3.5.
+	 */
+	err = &tok.err;
+	if (strcmp(err->error, "authorization_pending") != 0 &&
+		strcmp(err->error, "slow_down") != 0)
+	{
+		record_token_error(actx, err);
+		goto token_cleanup;
+	}
+
+	/*
+	 * A slow_down error requires us to permanently increase our retry
+	 * interval by five seconds.
+	 */
+	if (strcmp(err->error, "slow_down") == 0)
+	{
+		int			prev_interval = actx->authz.interval;
+
+		actx->authz.interval += 5;
+		if (actx->authz.interval < prev_interval)
+		{
+			actx_error(actx, "slow_down interval overflow");
+			goto token_cleanup;
+		}
+	}
+
+	success = true;
+
+token_cleanup:
+	free_token(&tok);
+	return success;
+}
+
+/*
+ * Displays a device authorization prompt for action by the end user, either via
+ * the PQauthDataHook, or by a message on standard error if no hook is set.
+ */
+static bool
+prompt_user(struct async_ctx *actx, PGconn *conn)
+{
+	int			res;
+	PGpromptOAuthDevice prompt = {
+		.verification_uri = actx->authz.verification_uri,
+		.user_code = actx->authz.user_code,
+		.verification_uri_complete = actx->authz.verification_uri_complete,
+		.expires_in = actx->authz.expires_in,
+	};
+
+	res = PQauthDataHook(PQAUTHDATA_PROMPT_OAUTH_DEVICE, conn, &prompt);
+
+	if (!res)
+	{
+		/*
+		 * translator: The first %s is a URL for the user to visit in a
+		 * browser, and the second %s is a code to be copy-pasted there.
+		 */
+		fprintf(stderr, libpq_gettext("Visit %s and enter the code: %s\n"),
+				prompt.verification_uri, prompt.user_code);
+	}
+	else if (res < 0)
+	{
+		actx_error(actx, "device prompt failed");
+		return false;
+	}
+
+	return true;
+}
+
+/*
+ * Calls curl_global_init() in a thread-safe way.
+ *
+ * libcurl has stringent requirements for the thread context in which you call
+ * curl_global_init(), because it's going to try initializing a bunch of other
+ * libraries (OpenSSL, Winsock, etc). Recent versions of libcurl have improved
+ * the thread-safety situation, but there's a chicken-and-egg problem at
+ * runtime: you can't check the thread safety until you've initialized libcurl,
+ * which you can't do from within a thread unless you know it's thread-safe...
+ *
+ * Returns true if initialization was successful. Successful or not, this
+ * function will not try to reinitialize Curl on successive calls.
+ */
+static bool
+initialize_curl(PGconn *conn)
+{
+	/*
+	 * Don't let the compiler play tricks with this variable. In the
+	 * HAVE_THREADSAFE_CURL_GLOBAL_INIT case, we don't care if two threads
+	 * enter simultaneously, but we do care if this gets set transiently to
+	 * PG_BOOL_YES/NO in cases where that's not the final answer.
+	 */
+	static volatile PGTernaryBool init_successful = PG_BOOL_UNKNOWN;
+#if HAVE_THREADSAFE_CURL_GLOBAL_INIT
+	curl_version_info_data *info;
+#endif
+
+#if !HAVE_THREADSAFE_CURL_GLOBAL_INIT
+
+	/*
+	 * Lock around the whole function. If a libpq client performs its own work
+	 * with libcurl, it must either ensure that Curl is initialized safely
+	 * before calling us (in which case our call will be a no-op), or else it
+	 * must guard its own calls to curl_global_init() with a registered
+	 * threadlock handler. See PQregisterThreadLock().
+	 */
+	pglock_thread();
+#endif
+
+	/*
+	 * Skip initialization if we've already done it. (Curl tracks the number
+	 * of calls; there's no point in incrementing the counter every time we
+	 * connect.)
+	 */
+	if (init_successful == PG_BOOL_YES)
+		goto done;
+	else if (init_successful == PG_BOOL_NO)
+	{
+		libpq_append_conn_error(conn,
+								"curl_global_init previously failed during OAuth setup");
+		goto done;
+	}
+
+	/*
+	 * We know we've already initialized Winsock by this point (see
+	 * pqMakeEmptyPGconn()), so we should be able to safely skip that bit. But
+	 * we have to tell libcurl to initialize everything else, because other
+	 * pieces of our client executable may already be using libcurl for their
+	 * own purposes. If we initialize libcurl with only a subset of its
+	 * features, we could break those other clients nondeterministically, and
+	 * that would probably be a nightmare to debug.
+	 *
+	 * If some other part of the program has already called this, it's a
+	 * no-op.
+	 */
+	if (curl_global_init(CURL_GLOBAL_ALL & ~CURL_GLOBAL_WIN32) != CURLE_OK)
+	{
+		libpq_append_conn_error(conn,
+								"curl_global_init failed during OAuth setup");
+		init_successful = PG_BOOL_NO;
+		goto done;
+	}
+
+#if HAVE_THREADSAFE_CURL_GLOBAL_INIT
+
+	/*
+	 * If we determined at configure time that the Curl installation is
+	 * thread-safe, our job here is much easier. We simply initialize above
+	 * without any locking (concurrent or duplicated calls are fine in that
+	 * situation), then double-check to make sure the runtime setting agrees,
+	 * to try to catch silent downgrades.
+	 */
+	info = curl_version_info(CURLVERSION_NOW);
+	if (!(info->features & CURL_VERSION_THREADSAFE))
+	{
+		/*
+		 * 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.");
+		init_successful = PG_BOOL_NO;
+		goto done;
+	}
+#endif
+
+	init_successful = PG_BOOL_YES;
+
+done:
+#if !HAVE_THREADSAFE_CURL_GLOBAL_INIT
+	pgunlock_thread();
+#endif
+	return (init_successful == PG_BOOL_YES);
+}
+
+/*
+ * The core nonblocking libcurl implementation. This will be called several
+ * times to pump the async engine.
+ *
+ * The architecture is based on PQconnectPoll(). The first half drives the
+ * connection state forward as necessary, returning if we're not ready to
+ * proceed to the next step yet. The second half performs the actual transition
+ * between states.
+ *
+ * You can trace the overall OAuth flow through the second half. It's linear
+ * until we get to the end, where we flip back and forth between
+ * OAUTH_STEP_TOKEN_REQUEST and OAUTH_STEP_WAIT_INTERVAL to regularly ping the
+ * provider.
+ */
+static PostgresPollingStatusType
+pg_fe_run_oauth_flow_impl(PGconn *conn)
+{
+	fe_oauth_state *state = conn->sasl_state;
+	struct async_ctx *actx;
+
+	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. */
+		conn->altsock = actx->mux;
+
+		switch (actx->step)
+		{
+			case OAUTH_STEP_INIT:
+				break;
+
+			case OAUTH_STEP_DISCOVERY:
+			case OAUTH_STEP_DEVICE_AUTHORIZATION:
+			case OAUTH_STEP_TOKEN_REQUEST:
+				{
+					PostgresPollingStatusType status;
+
+					status = drive_request(actx);
+
+					if (status == PGRES_POLLING_FAILED)
+						goto error_return;
+					else if (status != PGRES_POLLING_OK)
+					{
+						/* not done yet */
+						return status;
+					}
+
+					break;
+				}
+
+			case OAUTH_STEP_WAIT_INTERVAL:
+
+				/*
+				 * The client application is supposed to wait until our timer
+				 * expires before calling PQconnectPoll() again, but that
+				 * might not happen. To avoid sending a token request early,
+				 * check the timer before continuing.
+				 */
+				if (!timer_expired(actx))
+				{
+					conn->altsock = actx->timerfd;
+					return PGRES_POLLING_READING;
+				}
+
+				/* Disable the expired timer. */
+				if (!set_timer(actx, -1))
+					goto error_return;
+
+				break;
+		}
+
+		/*
+		 * Each case here must ensure that actx->running is set while we're
+		 * waiting on some asynchronous work. Most cases rely on
+		 * start_request() to do that for them.
+		 */
+		switch (actx->step)
+		{
+			case OAUTH_STEP_INIT:
+				actx->errctx = "failed to fetch OpenID discovery document";
+				if (!start_discovery(actx, conn->oauth_discovery_uri))
+					goto error_return;
+
+				actx->step = OAUTH_STEP_DISCOVERY;
+				break;
+
+			case OAUTH_STEP_DISCOVERY:
+				if (!finish_discovery(actx))
+					goto error_return;
+
+				if (!check_issuer(actx, conn))
+					goto error_return;
+
+				actx->errctx = "cannot run OAuth device authorization";
+				if (!check_for_device_flow(actx))
+					goto error_return;
+
+				actx->errctx = "failed to obtain device authorization";
+				if (!start_device_authz(actx, conn))
+					goto error_return;
+
+				actx->step = OAUTH_STEP_DEVICE_AUTHORIZATION;
+				break;
+
+			case OAUTH_STEP_DEVICE_AUTHORIZATION:
+				if (!finish_device_authz(actx))
+					goto error_return;
+
+				actx->errctx = "failed to obtain access token";
+				if (!start_token_request(actx, conn))
+					goto error_return;
+
+				actx->step = OAUTH_STEP_TOKEN_REQUEST;
+				break;
+
+			case OAUTH_STEP_TOKEN_REQUEST:
+				if (!handle_token_response(actx, &conn->oauth_token))
+					goto error_return;
+
+				if (!actx->user_prompted)
+				{
+					/*
+					 * Now that we know the token endpoint isn't broken, give
+					 * the user the login instructions.
+					 */
+					if (!prompt_user(actx, conn))
+						goto error_return;
+
+					actx->user_prompted = true;
+				}
+
+				if (conn->oauth_token)
+					break;		/* done! */
+
+				/*
+				 * Wait for the required interval before issuing the next
+				 * request.
+				 */
+				if (!set_timer(actx, actx->authz.interval * 1000))
+					goto error_return;
+
+				/*
+				 * No Curl requests are running, so we can simplify by having
+				 * the client wait directly on the timerfd rather than the
+				 * multiplexer.
+				 */
+				conn->altsock = actx->timerfd;
+
+				actx->step = OAUTH_STEP_WAIT_INTERVAL;
+				actx->running = 1;
+				break;
+
+			case OAUTH_STEP_WAIT_INTERVAL:
+				actx->errctx = "failed to obtain access token";
+				if (!start_token_request(actx, conn))
+					goto error_return;
+
+				actx->step = OAUTH_STEP_TOKEN_REQUEST;
+				break;
+		}
+
+		/*
+		 * The vast majority of the time, if we don't have a token at this
+		 * point, actx->running will be set. But there are some corner cases
+		 * where we can immediately loop back around; see start_request().
+		 */
+	} while (!conn->oauth_token && !actx->running);
+
+	/* If we've stored a token, we're done. Otherwise come back later. */
+	return conn->oauth_token ? PGRES_POLLING_OK : PGRES_POLLING_READING;
+
+error_return:
+
+	/*
+	 * Assemble the three parts of our error: context, body, and detail. See
+	 * also the documentation for struct async_ctx.
+	 */
+	if (actx->errctx)
+	{
+		appendPQExpBufferStr(&conn->errorMessage,
+							 libpq_gettext(actx->errctx));
+		appendPQExpBufferStr(&conn->errorMessage, ": ");
+	}
+
+	if (PQExpBufferDataBroken(actx->errbuf))
+		appendPQExpBufferStr(&conn->errorMessage,
+							 libpq_gettext("out of memory"));
+	else
+		appendPQExpBufferStr(&conn->errorMessage, actx->errbuf.data);
+
+	if (actx->curl_err[0])
+	{
+		size_t		len;
+
+		appendPQExpBuffer(&conn->errorMessage,
+						  " (libcurl: %s)", actx->curl_err);
+
+		/* Sometimes libcurl adds a newline to the error buffer. :( */
+		len = conn->errorMessage.len;
+		if (len >= 2 && conn->errorMessage.data[len - 2] == '\n')
+		{
+			conn->errorMessage.data[len - 2] = ')';
+			conn->errorMessage.data[len - 1] = '\0';
+			conn->errorMessage.len--;
+		}
+	}
+
+	appendPQExpBufferStr(&conn->errorMessage, "\n");
+
+	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.
+ */
+PostgresPollingStatusType
+pg_fe_run_oauth_flow(PGconn *conn)
+{
+	PostgresPollingStatusType result;
+#ifndef WIN32
+	sigset_t	osigset;
+	bool		sigpipe_pending;
+	bool		masked;
+
+	/*---
+	 * Ignore SIGPIPE on this thread during all Curl processing.
+	 *
+	 * Because we support multiple threads, we have to set up libcurl with
+	 * CURLOPT_NOSIGNAL, which disables its default global handling of
+	 * SIGPIPE. From the Curl docs:
+	 *
+	 *     libcurl makes an effort to never cause such SIGPIPE signals to
+	 *     trigger, but some operating systems have no way to avoid them and
+	 *     even on those that have there are some corner cases when they may
+	 *     still happen, contrary to our desire.
+	 *
+	 * Note that libcurl is also at the mercy of its DNS resolution and SSL
+	 * libraries; if any of them forget a MSG_NOSIGNAL then we're in trouble.
+	 * Modern platforms and libraries seem to get it right, so this is a
+	 * difficult corner case to exercise in practice, and unfortunately it's
+	 * not really clear whether it's necessary in all cases.
+	 */
+	masked = (pq_block_sigpipe(&osigset, &sigpipe_pending) == 0);
+#endif
+
+	result = pg_fe_run_oauth_flow_impl(conn);
+
+#ifndef WIN32
+	if (masked)
+	{
+		/*
+		 * Undo the SIGPIPE mask. Assume we may have gotten EPIPE (we have no
+		 * way of knowing at this level).
+		 */
+		pq_reset_sigpipe(&osigset, sigpipe_pending, true /* EPIPE, maybe */ );
+	}
+#endif
+
+	return result;
+}
diff --git a/src/interfaces/libpq/fe-auth-oauth.c b/src/interfaces/libpq/fe-auth-oauth.c
new file mode 100644
index 00000000000..24448c3e209
--- /dev/null
+++ b/src/interfaces/libpq/fe-auth-oauth.c
@@ -0,0 +1,1153 @@
+/*-------------------------------------------------------------------------
+ *
+ * fe-auth-oauth.c
+ *	   The front-end (client) implementation of OAuth/OIDC authentication
+ *	   using the SASL OAUTHBEARER mechanism.
+ *
+ * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/interfaces/libpq/fe-auth-oauth.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres_fe.h"
+
+#include "common/base64.h"
+#include "common/hmac.h"
+#include "common/jsonapi.h"
+#include "common/oauth-common.h"
+#include "fe-auth.h"
+#include "fe-auth-oauth.h"
+#include "mb/pg_wchar.h"
+
+/* The exported OAuth callback mechanism. */
+static void *oauth_init(PGconn *conn, const char *password,
+						const char *sasl_mechanism);
+static SASLStatus oauth_exchange(void *opaq, bool final,
+								 char *input, int inputlen,
+								 char **output, int *outputlen);
+static bool oauth_channel_bound(void *opaq);
+static void oauth_free(void *opaq);
+
+const pg_fe_sasl_mech pg_oauth_mech = {
+	oauth_init,
+	oauth_exchange,
+	oauth_channel_bound,
+	oauth_free,
+};
+
+/*
+ * Initializes mechanism state for OAUTHBEARER.
+ *
+ * For a full description of the API, see libpq/fe-auth-sasl.h.
+ */
+static void *
+oauth_init(PGconn *conn, const char *password,
+		   const char *sasl_mechanism)
+{
+	fe_oauth_state *state;
+
+	/*
+	 * We only support one SASL mechanism here; anything else is programmer
+	 * error.
+	 */
+	Assert(sasl_mechanism != NULL);
+	Assert(strcmp(sasl_mechanism, OAUTHBEARER_NAME) == 0);
+
+	state = calloc(1, sizeof(*state));
+	if (!state)
+		return NULL;
+
+	state->step = FE_OAUTH_INIT;
+	state->conn = conn;
+
+	return state;
+}
+
+/*
+ * Frees the state allocated by oauth_init().
+ *
+ * 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().
+ */
+static void
+oauth_free(void *opaq)
+{
+	fe_oauth_state *state = opaq;
+
+	/* Any async authentication state should have been cleaned up already. */
+	Assert(!state->async_ctx);
+
+	free(state);
+}
+
+#define kvsep "\x01"
+
+/*
+ * Constructs an OAUTHBEARER client initial response (RFC 7628, Sec. 3.1).
+ *
+ * If discover is true, the initial response will contain a request for the
+ * server's required OAuth parameters (Sec. 4.3). Otherwise, conn->token must
+ * be set; it will be sent as the connection's bearer token.
+ *
+ * Returns the response as a null-terminated string, or NULL on error.
+ */
+static char *
+client_initial_response(PGconn *conn, bool discover)
+{
+	static const char *const resp_format = "n,," kvsep "auth=%s%s" kvsep kvsep;
+
+	PQExpBufferData buf;
+	const char *authn_scheme;
+	char	   *response = NULL;
+	const char *token = conn->oauth_token;
+
+	if (discover)
+	{
+		/* Parameter discovery uses a completely empty auth value. */
+		authn_scheme = token = "";
+	}
+	else
+	{
+		/*
+		 * Use a Bearer authentication scheme (RFC 6750, Sec. 2.1). A trailing
+		 * space is used as a separator.
+		 */
+		authn_scheme = "Bearer ";
+
+		/* conn->token must have been set in this case. */
+		if (!token)
+		{
+			Assert(false);
+			libpq_append_conn_error(conn,
+									"internal error: no OAuth token was set for the connection");
+			return NULL;
+		}
+	}
+
+	initPQExpBuffer(&buf);
+	appendPQExpBuffer(&buf, resp_format, authn_scheme, token);
+
+	if (!PQExpBufferDataBroken(buf))
+		response = strdup(buf.data);
+	termPQExpBuffer(&buf);
+
+	if (!response)
+		libpq_append_conn_error(conn, "out of memory");
+
+	return response;
+}
+
+/*
+ * JSON Parser (for the OAUTHBEARER error result)
+ */
+
+/* Relevant JSON fields in the error result object. */
+#define ERROR_STATUS_FIELD "status"
+#define ERROR_SCOPE_FIELD "scope"
+#define ERROR_OPENID_CONFIGURATION_FIELD "openid-configuration"
+
+struct json_ctx
+{
+	char	   *errmsg;			/* any non-NULL value stops all processing */
+	PQExpBufferData errbuf;		/* backing memory for errmsg */
+	int			nested;			/* nesting level (zero is the top) */
+
+	const char *target_field_name;	/* points to a static allocation */
+	char	  **target_field;	/* see below */
+
+	/* target_field, if set, points to one of the following: */
+	char	   *status;
+	char	   *scope;
+	char	   *discovery_uri;
+};
+
+#define oauth_json_has_error(ctx) \
+	(PQExpBufferDataBroken((ctx)->errbuf) || (ctx)->errmsg)
+
+#define oauth_json_set_error(ctx, ...) \
+	do { \
+		appendPQExpBuffer(&(ctx)->errbuf, __VA_ARGS__); \
+		(ctx)->errmsg = (ctx)->errbuf.data; \
+	} while (0)
+
+static JsonParseErrorType
+oauth_json_object_start(void *state)
+{
+	struct json_ctx *ctx = state;
+
+	if (ctx->target_field)
+	{
+		Assert(ctx->nested == 1);
+
+		oauth_json_set_error(ctx,
+							 libpq_gettext("field \"%s\" must be a string"),
+							 ctx->target_field_name);
+	}
+
+	++ctx->nested;
+	return oauth_json_has_error(ctx) ? JSON_SEM_ACTION_FAILED : JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_object_end(void *state)
+{
+	struct json_ctx *ctx = state;
+
+	--ctx->nested;
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_object_field_start(void *state, char *name, bool isnull)
+{
+	struct json_ctx *ctx = state;
+
+	/* Only top-level keys are considered. */
+	if (ctx->nested == 1)
+	{
+		if (strcmp(name, ERROR_STATUS_FIELD) == 0)
+		{
+			ctx->target_field_name = ERROR_STATUS_FIELD;
+			ctx->target_field = &ctx->status;
+		}
+		else if (strcmp(name, ERROR_SCOPE_FIELD) == 0)
+		{
+			ctx->target_field_name = ERROR_SCOPE_FIELD;
+			ctx->target_field = &ctx->scope;
+		}
+		else if (strcmp(name, ERROR_OPENID_CONFIGURATION_FIELD) == 0)
+		{
+			ctx->target_field_name = ERROR_OPENID_CONFIGURATION_FIELD;
+			ctx->target_field = &ctx->discovery_uri;
+		}
+	}
+
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_array_start(void *state)
+{
+	struct json_ctx *ctx = state;
+
+	if (!ctx->nested)
+	{
+		ctx->errmsg = libpq_gettext("top-level element must be an object");
+	}
+	else if (ctx->target_field)
+	{
+		Assert(ctx->nested == 1);
+
+		oauth_json_set_error(ctx,
+							 libpq_gettext("field \"%s\" must be a string"),
+							 ctx->target_field_name);
+	}
+
+	return oauth_json_has_error(ctx) ? JSON_SEM_ACTION_FAILED : JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_scalar(void *state, char *token, JsonTokenType type)
+{
+	struct json_ctx *ctx = state;
+
+	if (!ctx->nested)
+	{
+		ctx->errmsg = libpq_gettext("top-level element must be an object");
+		return JSON_SEM_ACTION_FAILED;
+	}
+
+	if (ctx->target_field)
+	{
+		if (ctx->nested != 1)
+		{
+			/*
+			 * ctx->target_field should not have been set for nested keys.
+			 * Assert and don't continue any further for production builds.
+			 */
+			Assert(false);
+			oauth_json_set_error(ctx,
+								 "internal error: target scalar found at nesting level %d during OAUTHBEARER parsing",
+								 ctx->nested);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		/*
+		 * We don't allow duplicate field names; error out if the target has
+		 * already been set.
+		 */
+		if (*ctx->target_field)
+		{
+			oauth_json_set_error(ctx,
+								 libpq_gettext("field \"%s\" is duplicated"),
+								 ctx->target_field_name);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		/* The only fields we support are strings. */
+		if (type != JSON_TOKEN_STRING)
+		{
+			oauth_json_set_error(ctx,
+								 libpq_gettext("field \"%s\" must be a string"),
+								 ctx->target_field_name);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		*ctx->target_field = strdup(token);
+		if (!*ctx->target_field)
+			return JSON_OUT_OF_MEMORY;
+
+		ctx->target_field = NULL;
+		ctx->target_field_name = NULL;
+	}
+	else
+	{
+		/* otherwise we just ignore it */
+	}
+
+	return JSON_SUCCESS;
+}
+
+#define HTTPS_SCHEME "https://"
+#define HTTP_SCHEME "http://"
+
+/* We support both well-known suffixes defined by RFC 8414. */
+#define WK_PREFIX "/.well-known/"
+#define OPENID_WK_SUFFIX "openid-configuration"
+#define OAUTH_WK_SUFFIX "oauth-authorization-server"
+
+/*
+ * Derives an issuer identifier from one of our recognized .well-known URIs,
+ * using the rules in RFC 8414.
+ */
+static char *
+issuer_from_well_known_uri(PGconn *conn, const char *wkuri)
+{
+	const char *authority_start = NULL;
+	const char *wk_start;
+	const char *wk_end;
+	char	   *issuer;
+	ptrdiff_t	start_offset,
+				end_offset;
+	size_t		end_len;
+
+	/*
+	 * https:// is required for issuer identifiers (RFC 8414, Sec. 2; OIDC
+	 * Discovery 1.0, Sec. 3). This is a case-insensitive comparison at this
+	 * level (but issuer identifier comparison at the level above this is
+	 * case-sensitive, so in practice it's probably moot).
+	 */
+	if (pg_strncasecmp(wkuri, HTTPS_SCHEME, strlen(HTTPS_SCHEME)) == 0)
+		authority_start = wkuri + strlen(HTTPS_SCHEME);
+
+	if (!authority_start
+		&& oauth_unsafe_debugging_enabled()
+		&& pg_strncasecmp(wkuri, HTTP_SCHEME, strlen(HTTP_SCHEME)) == 0)
+	{
+		/* Allow http:// for testing only. */
+		authority_start = wkuri + strlen(HTTP_SCHEME);
+	}
+
+	if (!authority_start)
+	{
+		libpq_append_conn_error(conn,
+								"OAuth discovery URI \"%s\" must use HTTPS",
+								wkuri);
+		return NULL;
+	}
+
+	/*
+	 * Well-known URIs in general may support queries and fragments, but the
+	 * two types we support here do not. (They must be constructed from the
+	 * components of issuer identifiers, which themselves may not contain any
+	 * queries or fragments.)
+	 *
+	 * It's important to check this first, to avoid getting tricked later by a
+	 * prefix buried inside a query or fragment.
+	 */
+	if (strpbrk(authority_start, "?#") != NULL)
+	{
+		libpq_append_conn_error(conn,
+								"OAuth discovery URI \"%s\" must not contain query or fragment components",
+								wkuri);
+		return NULL;
+	}
+
+	/*
+	 * Find the start of the .well-known prefix. IETF rules (RFC 8615) state
+	 * this must be at the beginning of the path component, but OIDC defined
+	 * it at the end instead (OIDC Discovery 1.0, Sec. 4), so we have to
+	 * search for it anywhere.
+	 */
+	wk_start = strstr(authority_start, WK_PREFIX);
+	if (!wk_start)
+	{
+		libpq_append_conn_error(conn,
+								"OAuth discovery URI \"%s\" is not a .well-known URI",
+								wkuri);
+		return NULL;
+	}
+
+	/*
+	 * Now find the suffix type. We only support the two defined in OIDC
+	 * Discovery 1.0 and RFC 8414.
+	 */
+	wk_end = wk_start + strlen(WK_PREFIX);
+
+	if (strncmp(wk_end, OPENID_WK_SUFFIX, strlen(OPENID_WK_SUFFIX)) == 0)
+		wk_end += strlen(OPENID_WK_SUFFIX);
+	else if (strncmp(wk_end, OAUTH_WK_SUFFIX, strlen(OAUTH_WK_SUFFIX)) == 0)
+		wk_end += strlen(OAUTH_WK_SUFFIX);
+	else
+		wk_end = NULL;
+
+	/*
+	 * Even if there's a match, we still need to check to make sure the suffix
+	 * takes up the entire path segment, to weed out constructions like
+	 * "/.well-known/openid-configuration-bad".
+	 */
+	if (!wk_end || (*wk_end != '/' && *wk_end != '\0'))
+	{
+		libpq_append_conn_error(conn,
+								"OAuth discovery URI \"%s\" uses an unsupported .well-known suffix",
+								wkuri);
+		return NULL;
+	}
+
+	/*
+	 * Finally, make sure the .well-known components are provided either as a
+	 * prefix (IETF style) or as a postfix (OIDC style). In other words,
+	 * "https://localhost/a/.well-known/openid-configuration/b" is not allowed
+	 * to claim association with "https://localhost/a/b".
+	 */
+	if (*wk_end != '\0')
+	{
+		/*
+		 * It's not at the end, so it's required to be at the beginning at the
+		 * path. Find the starting slash.
+		 */
+		const char *path_start;
+
+		path_start = strchr(authority_start, '/');
+		Assert(path_start);		/* otherwise we wouldn't have found WK_PREFIX */
+
+		if (wk_start != path_start)
+		{
+			libpq_append_conn_error(conn,
+									"OAuth discovery URI \"%s\" uses an invalid format",
+									wkuri);
+			return NULL;
+		}
+	}
+
+	/* Checks passed! Now build the issuer. */
+	issuer = strdup(wkuri);
+	if (!issuer)
+	{
+		libpq_append_conn_error(conn, "out of memory");
+		return NULL;
+	}
+
+	/*
+	 * The .well-known components are from [wk_start, wk_end). Remove those to
+	 * form the issuer ID, by shifting the path suffix (which may be empty)
+	 * leftwards.
+	 */
+	start_offset = wk_start - wkuri;
+	end_offset = wk_end - wkuri;
+	end_len = strlen(wk_end) + 1;	/* move the NULL terminator too */
+
+	memmove(issuer + start_offset, issuer + end_offset, end_len);
+
+	return issuer;
+}
+
+/*
+ * Parses the server error result (RFC 7628, Sec. 3.2.2) contained in msg and
+ * stores any discovered openid_configuration and scope settings for the
+ * connection.
+ */
+static bool
+handle_oauth_sasl_error(PGconn *conn, const char *msg, int msglen)
+{
+	JsonLexContext lex = {0};
+	JsonSemAction sem = {0};
+	JsonParseErrorType err;
+	struct json_ctx ctx = {0};
+	char	   *errmsg = NULL;
+	bool		success = false;
+
+	Assert(conn->oauth_issuer_id);	/* ensured by setup_oauth_parameters() */
+
+	/* Sanity check. */
+	if (strlen(msg) != msglen)
+	{
+		libpq_append_conn_error(conn,
+								"server's error message contained an embedded NULL, and was discarded");
+		return false;
+	}
+
+	/*
+	 * pg_parse_json doesn't validate the incoming UTF-8, so we have to check
+	 * that up front.
+	 */
+	if (pg_encoding_verifymbstr(PG_UTF8, msg, msglen) != msglen)
+	{
+		libpq_append_conn_error(conn,
+								"server's error response is not valid UTF-8");
+		return false;
+	}
+
+	makeJsonLexContextCstringLen(&lex, msg, msglen, PG_UTF8, true);
+	setJsonLexContextOwnsTokens(&lex, true);	/* must not leak on error */
+
+	initPQExpBuffer(&ctx.errbuf);
+	sem.semstate = &ctx;
+
+	sem.object_start = oauth_json_object_start;
+	sem.object_end = oauth_json_object_end;
+	sem.object_field_start = oauth_json_object_field_start;
+	sem.array_start = oauth_json_array_start;
+	sem.scalar = oauth_json_scalar;
+
+	err = pg_parse_json(&lex, &sem);
+
+	if (err == JSON_SEM_ACTION_FAILED)
+	{
+		if (PQExpBufferDataBroken(ctx.errbuf))
+			errmsg = libpq_gettext("out of memory");
+		else if (ctx.errmsg)
+			errmsg = ctx.errmsg;
+		else
+		{
+			/*
+			 * Developer error: one of the action callbacks didn't call
+			 * oauth_json_set_error() before erroring out.
+			 */
+			Assert(oauth_json_has_error(&ctx));
+			errmsg = "<unexpected empty error>";
+		}
+	}
+	else if (err != JSON_SUCCESS)
+		errmsg = json_errdetail(err, &lex);
+
+	if (errmsg)
+		libpq_append_conn_error(conn,
+								"failed to parse server's error response: %s",
+								errmsg);
+
+	/* Don't need the error buffer or the JSON lexer anymore. */
+	termPQExpBuffer(&ctx.errbuf);
+	freeJsonLexContext(&lex);
+
+	if (errmsg)
+		goto cleanup;
+
+	if (ctx.discovery_uri)
+	{
+		char	   *discovery_issuer;
+
+		/*
+		 * The URI MUST correspond to our existing issuer, to avoid mix-ups.
+		 *
+		 * Issuer comparison is done byte-wise, rather than performing any URL
+		 * normalization; this follows the suggestions for issuer comparison
+		 * in RFC 9207 Sec. 2.4 (which requires simple string comparison) and
+		 * vastly simplifies things. Since this is the key protection against
+		 * a rogue server sending the client to an untrustworthy location,
+		 * simpler is better.
+		 */
+		discovery_issuer = issuer_from_well_known_uri(conn, ctx.discovery_uri);
+		if (!discovery_issuer)
+			goto cleanup;		/* error message already set */
+
+		if (strcmp(conn->oauth_issuer_id, discovery_issuer) != 0)
+		{
+			libpq_append_conn_error(conn,
+									"server's discovery document at %s (issuer \"%s\") is incompatible with oauth_issuer (%s)",
+									ctx.discovery_uri, discovery_issuer,
+									conn->oauth_issuer_id);
+
+			free(discovery_issuer);
+			goto cleanup;
+		}
+
+		free(discovery_issuer);
+
+		if (!conn->oauth_discovery_uri)
+		{
+			conn->oauth_discovery_uri = ctx.discovery_uri;
+			ctx.discovery_uri = NULL;
+		}
+		else
+		{
+			/* This must match the URI we'd previously determined. */
+			if (strcmp(conn->oauth_discovery_uri, ctx.discovery_uri) != 0)
+			{
+				libpq_append_conn_error(conn,
+										"server's discovery document has moved to %s (previous location was %s)",
+										ctx.discovery_uri,
+										conn->oauth_discovery_uri);
+				goto cleanup;
+			}
+		}
+	}
+
+	if (ctx.scope)
+	{
+		/* Servers may not override a previously set oauth_scope. */
+		if (!conn->oauth_scope)
+		{
+			conn->oauth_scope = ctx.scope;
+			ctx.scope = NULL;
+		}
+	}
+
+	if (!ctx.status)
+	{
+		libpq_append_conn_error(conn,
+								"server sent error response without a status");
+		goto cleanup;
+	}
+
+	if (strcmp(ctx.status, "invalid_token") != 0)
+	{
+		/*
+		 * invalid_token is the only error code we'll automatically retry for;
+		 * otherwise, just bail out now.
+		 */
+		libpq_append_conn_error(conn,
+								"server rejected OAuth bearer token: %s",
+								ctx.status);
+		goto cleanup;
+	}
+
+	success = true;
+
+cleanup:
+	free(ctx.status);
+	free(ctx.scope);
+	free(ctx.discovery_uri);
+
+	return success;
+}
+
+/*
+ * Callback implementation of conn->async_auth() for a user-defined OAuth flow.
+ * Delegates the retrieval of the token to the application's 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_*
+ * statuses for use by PQconnectPoll().
+ */
+static PostgresPollingStatusType
+run_user_oauth_flow(PGconn *conn)
+{
+	fe_oauth_state *state = conn->sasl_state;
+	PGoauthBearerRequest *request = state->async_ctx;
+	PostgresPollingStatusType status;
+
+	if (!request->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);
+	if (status == PGRES_POLLING_FAILED)
+	{
+		libpq_append_conn_error(conn, "user-defined OAuth flow failed");
+		return status;
+	}
+	else if (status == PGRES_POLLING_OK)
+	{
+		/*
+		 * 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.)
+		 */
+		if (!request->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);
+		if (!conn->oauth_token)
+		{
+			libpq_append_conn_error(conn, "out of memory");
+			return PGRES_POLLING_FAILED;
+		}
+
+		return PGRES_POLLING_OK;
+	}
+
+	/* The hook wants the client to poll the altsock. Make sure it set one. */
+	if (conn->altsock == PGINVALID_SOCKET)
+	{
+		libpq_append_conn_error(conn,
+								"user-defined OAuth flow did not provide a socket for polling");
+		return PGRES_POLLING_FAILED;
+	}
+
+	return status;
+}
+
+/*
+ * Cleanup callback for the async user flow. Delegates most of its job to the
+ * user-provided cleanup implementation, then disconnects the altsock.
+ */
+static void
+cleanup_user_oauth_flow(PGconn *conn)
+{
+	fe_oauth_state *state = conn->sasl_state;
+	PGoauthBearerRequest *request = state->async_ctx;
+
+	Assert(request);
+
+	if (request->cleanup)
+		request->cleanup(conn, request);
+	conn->altsock = PGINVALID_SOCKET;
+
+	free(request);
+	state->async_ctx = NULL;
+}
+
+/*
+ * Chooses an OAuth client flow for the connection, which will retrieve a Bearer
+ * 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
+ * asynchronous callbacks which will be managed by run_user_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.)
+ *
+ * If neither a custom handler nor the builtin flow is available, the connection
+ * fails here.
+ */
+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,
+	};
+
+	Assert(request.openid_configuration);
+
+	/* The client may have overridden the OAuth flow. */
+	res = PQauthDataHook(PQAUTHDATA_OAUTH_BEARER_TOKEN, conn, &request);
+	if (res > 0)
+	{
+		PGoauthBearerRequest *request_copy;
+
+		if (request.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);
+			if (!conn->oauth_token)
+			{
+				libpq_append_conn_error(conn, "out of memory");
+				goto fail;
+			}
+
+			/* short-circuit */
+			if (request.cleanup)
+				request.cleanup(conn, &request);
+			return true;
+		}
+
+		request_copy = malloc(sizeof(*request_copy));
+		if (!request_copy)
+		{
+			libpq_append_conn_error(conn, "out of memory");
+			goto fail;
+		}
+
+		memcpy(request_copy, &request, sizeof(request));
+
+		conn->async_auth = run_user_oauth_flow;
+		conn->cleanup_async_auth = cleanup_user_oauth_flow;
+		state->async_ctx = request_copy;
+	}
+	else if (res < 0)
+	{
+		libpq_append_conn_error(conn, "user-defined OAuth flow failed");
+		goto fail;
+	}
+	else
+	{
+#if USE_LIBCURL
+		/* Hand off to our built-in OAuth flow. */
+		conn->async_auth = pg_fe_run_oauth_flow;
+		conn->cleanup_async_auth = pg_fe_cleanup_oauth_flow;
+
+#else
+		libpq_append_conn_error(conn, "no custom OAuth flows are available, and libpq was not built with libcurl support");
+		goto fail;
+
+#endif
+	}
+
+	return true;
+
+fail:
+	if (request.cleanup)
+		request.cleanup(conn, &request);
+	return false;
+}
+
+/*
+ * Fill in our issuer identifier and discovery URI, if possible, using the
+ * connection parameters. If conn->oauth_discovery_uri can't be populated in
+ * this function, it will be requested from the server.
+ */
+static bool
+setup_oauth_parameters(PGconn *conn)
+{
+	/*---
+	 * To talk to a server, we require the user to provide issuer and client
+	 * identifiers.
+	 *
+	 * While it's possible for an OAuth client to support multiple issuers, it
+	 * requires additional effort to make sure the flows in use are safe -- to
+	 * quote RFC 9207,
+	 *
+	 *     OAuth clients that interact with only one authorization server are
+	 *     not vulnerable to mix-up attacks. However, when such clients decide
+	 *     to add support for a second authorization server in the future, they
+	 *     become vulnerable and need to apply countermeasures to mix-up
+	 *     attacks.
+	 *
+	 * For now, we allow only one.
+	 */
+	if (!conn->oauth_issuer || !conn->oauth_client_id)
+	{
+		libpq_append_conn_error(conn,
+								"server requires OAuth authentication, but oauth_issuer and oauth_client_id are not both set");
+		return false;
+	}
+
+	/*
+	 * oauth_issuer is interpreted differently if it's a well-known discovery
+	 * URI rather than just an issuer identifier.
+	 */
+	if (strstr(conn->oauth_issuer, WK_PREFIX) != NULL)
+	{
+		/*
+		 * Convert the URI back to an issuer identifier. (This also performs
+		 * validation of the URI format.)
+		 */
+		conn->oauth_issuer_id = issuer_from_well_known_uri(conn,
+														   conn->oauth_issuer);
+		if (!conn->oauth_issuer_id)
+			return false;		/* error message already set */
+
+		conn->oauth_discovery_uri = strdup(conn->oauth_issuer);
+		if (!conn->oauth_discovery_uri)
+		{
+			libpq_append_conn_error(conn, "out of memory");
+			return false;
+		}
+	}
+	else
+	{
+		/*
+		 * Treat oauth_issuer as an issuer identifier. We'll ask the server
+		 * for the discovery URI.
+		 */
+		conn->oauth_issuer_id = strdup(conn->oauth_issuer);
+		if (!conn->oauth_issuer_id)
+		{
+			libpq_append_conn_error(conn, "out of memory");
+			return false;
+		}
+	}
+
+	return true;
+}
+
+/*
+ * Implements the OAUTHBEARER SASL exchange (RFC 7628, Sec. 3.2).
+ *
+ * If the necessary OAuth parameters are set up on the connection, this will run
+ * the client flow asynchronously and present the resulting token to the server.
+ * Otherwise, an empty discovery response will be sent and any parameters sent
+ * back by the server will be stored for a second attempt.
+ *
+ * For a full description of the API, see libpq/sasl.h.
+ */
+static SASLStatus
+oauth_exchange(void *opaq, bool final,
+			   char *input, int inputlen,
+			   char **output, int *outputlen)
+{
+	fe_oauth_state *state = opaq;
+	PGconn	   *conn = state->conn;
+	bool		discover = false;
+
+	*output = NULL;
+	*outputlen = 0;
+
+	switch (state->step)
+	{
+		case FE_OAUTH_INIT:
+			/* We begin in the initial response phase. */
+			Assert(inputlen == -1);
+
+			if (!setup_oauth_parameters(conn))
+				return SASL_FAILED;
+
+			if (conn->oauth_token)
+			{
+				/*
+				 * A previous connection already fetched the token; we'll use
+				 * it below.
+				 */
+			}
+			else if (conn->oauth_discovery_uri)
+			{
+				/*
+				 * We don't have a token, but we have a discovery URI already
+				 * stored. Decide whether we're using a user-provided OAuth
+				 * flow or the one we have built in.
+				 */
+				if (!setup_token_request(conn, state))
+					return SASL_FAILED;
+
+				if (conn->oauth_token)
+				{
+					/*
+					 * A really smart user implementation may have already
+					 * given us the token (e.g. if there was an unexpired copy
+					 * already cached), and we can use it immediately.
+					 */
+				}
+				else
+				{
+					/*
+					 * Otherwise, we'll have to hand the connection over to
+					 * our OAuth implementation.
+					 *
+					 * This could take a while, since it generally involves a
+					 * user in the loop. To avoid consuming the server's
+					 * authentication timeout, we'll continue this handshake
+					 * to the end, so that the server can close its side of
+					 * the connection. We'll open a second connection later
+					 * once we've retrieved a token.
+					 */
+					discover = true;
+				}
+			}
+			else
+			{
+				/*
+				 * If we don't have a token, and we don't have a discovery URI
+				 * to be able to request a token, we ask the server for one
+				 * explicitly.
+				 */
+				discover = true;
+			}
+
+			/*
+			 * Generate an initial response. This either contains a token, if
+			 * we have one, or an empty discovery response which is doomed to
+			 * fail.
+			 */
+			*output = client_initial_response(conn, discover);
+			if (!*output)
+				return SASL_FAILED;
+
+			*outputlen = strlen(*output);
+			state->step = FE_OAUTH_BEARER_SENT;
+
+			if (conn->oauth_token)
+			{
+				/*
+				 * For the purposes of require_auth, our side of
+				 * authentication is done at this point; the server will
+				 * either accept the connection or send an error. Unlike
+				 * SCRAM, there is no additional server data to check upon
+				 * success.
+				 */
+				conn->client_finished_auth = true;
+			}
+
+			return SASL_CONTINUE;
+
+		case FE_OAUTH_BEARER_SENT:
+			if (final)
+			{
+				/*
+				 * OAUTHBEARER does not make use of additional data with a
+				 * successful SASL exchange, so we shouldn't get an
+				 * AuthenticationSASLFinal message.
+				 */
+				libpq_append_conn_error(conn,
+										"server sent unexpected additional OAuth data");
+				return SASL_FAILED;
+			}
+
+			/*
+			 * An error message was sent by the server. Respond with the
+			 * required dummy message (RFC 7628, sec. 3.2.3).
+			 */
+			*output = strdup(kvsep);
+			if (unlikely(!*output))
+			{
+				libpq_append_conn_error(conn, "out of memory");
+				return SASL_FAILED;
+			}
+			*outputlen = strlen(*output);	/* == 1 */
+
+			/* Grab the settings from discovery. */
+			if (!handle_oauth_sasl_error(conn, input, inputlen))
+				return SASL_FAILED;
+
+			if (conn->oauth_token)
+			{
+				/*
+				 * The server rejected our token. Continue onwards towards the
+				 * expected FATAL message, but mark our state to catch any
+				 * unexpected "success" from the server.
+				 */
+				state->step = FE_OAUTH_SERVER_ERROR;
+				return SASL_CONTINUE;
+			}
+
+			if (!conn->async_auth)
+			{
+				/*
+				 * No OAuth flow is set up yet. Did we get enough information
+				 * from the server to create one?
+				 */
+				if (!conn->oauth_discovery_uri)
+				{
+					libpq_append_conn_error(conn,
+											"server requires OAuth authentication, but no discovery metadata was provided");
+					return SASL_FAILED;
+				}
+
+				/* Yes. Set up the flow now. */
+				if (!setup_token_request(conn, state))
+					return SASL_FAILED;
+
+				if (conn->oauth_token)
+				{
+					/*
+					 * A token was available in a custom flow's cache. Skip
+					 * the asynchronous processing.
+					 */
+					goto reconnect;
+				}
+			}
+
+			/*
+			 * Time to retrieve a token. This involves a number of HTTP
+			 * connections and timed waits, so we escape the synchronous auth
+			 * processing and tell PQconnectPoll to transfer control to our
+			 * async implementation.
+			 */
+			Assert(conn->async_auth);	/* should have been set already */
+			state->step = FE_OAUTH_REQUESTING_TOKEN;
+			return SASL_ASYNC;
+
+		case FE_OAUTH_REQUESTING_TOKEN:
+
+			/*
+			 * We've returned successfully from token retrieval. Double-check
+			 * that we have what we need for the next connection.
+			 */
+			if (!conn->oauth_token)
+			{
+				Assert(false);	/* should have failed before this point! */
+				libpq_append_conn_error(conn,
+										"internal error: OAuth flow did not set a token");
+				return SASL_FAILED;
+			}
+
+			goto reconnect;
+
+		case FE_OAUTH_SERVER_ERROR:
+
+			/*
+			 * After an error, the server should send an error response to
+			 * fail the SASL handshake, which is handled in higher layers.
+			 *
+			 * If we get here, the server either sent *another* challenge
+			 * which isn't defined in the RFC, or completed the handshake
+			 * successfully after telling us it was going to fail. Neither is
+			 * acceptable.
+			 */
+			libpq_append_conn_error(conn,
+									"server sent additional OAuth data after error");
+			return SASL_FAILED;
+
+		default:
+			libpq_append_conn_error(conn, "invalid OAuth exchange state");
+			break;
+	}
+
+	Assert(false);				/* should never get here */
+	return SASL_FAILED;
+
+reconnect:
+
+	/*
+	 * Despite being a failure from the point of view of SASL, we have enough
+	 * information to restart with a new connection.
+	 */
+	libpq_append_conn_error(conn, "retrying connection with new bearer token");
+	conn->oauth_want_retry = true;
+	return SASL_FAILED;
+}
+
+static bool
+oauth_channel_bound(void *opaq)
+{
+	/* This mechanism does not support channel binding. */
+	return false;
+}
+
+/*
+ * Fully clears out any stored OAuth token. This is done proactively upon
+ * successful connection as well as during pqClosePGconn().
+ */
+void
+pqClearOAuthToken(PGconn *conn)
+{
+	if (!conn->oauth_token)
+		return;
+
+	explicit_bzero(conn->oauth_token, strlen(conn->oauth_token));
+	free(conn->oauth_token);
+	conn->oauth_token = NULL;
+}
+
+/*
+ * Returns true if the PGOAUTHDEBUG=UNSAFE flag is set in the environment.
+ */
+bool
+oauth_unsafe_debugging_enabled(void)
+{
+	const char *env = getenv("PGOAUTHDEBUG");
+
+	return (env && strcmp(env, "UNSAFE") == 0);
+}
diff --git a/src/interfaces/libpq/fe-auth-oauth.h b/src/interfaces/libpq/fe-auth-oauth.h
new file mode 100644
index 00000000000..32598721686
--- /dev/null
+++ b/src/interfaces/libpq/fe-auth-oauth.h
@@ -0,0 +1,45 @@
+/*-------------------------------------------------------------------------
+ *
+ * fe-auth-oauth.h
+ *
+ *	  Definitions for OAuth authentication implementations
+ *
+ * Portions Copyright (c) 2024, PostgreSQL Global Development Group
+ *
+ * src/interfaces/libpq/fe-auth-oauth.h
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#ifndef FE_AUTH_OAUTH_H
+#define FE_AUTH_OAUTH_H
+
+#include "libpq-fe.h"
+#include "libpq-int.h"
+
+
+enum fe_oauth_step
+{
+	FE_OAUTH_INIT,
+	FE_OAUTH_BEARER_SENT,
+	FE_OAUTH_REQUESTING_TOKEN,
+	FE_OAUTH_SERVER_ERROR,
+};
+
+typedef struct
+{
+	enum fe_oauth_step step;
+
+	PGconn	   *conn;
+	void	   *async_ctx;
+} fe_oauth_state;
+
+extern PostgresPollingStatusType pg_fe_run_oauth_flow(PGconn *conn);
+extern void pg_fe_cleanup_oauth_flow(PGconn *conn);
+extern void pqClearOAuthToken(PGconn *conn);
+extern bool oauth_unsafe_debugging_enabled(void);
+
+/* Mechanisms in fe-auth-oauth.c */
+extern const pg_fe_sasl_mech pg_oauth_mech;
+
+#endif							/* FE_AUTH_OAUTH_H */
diff --git a/src/interfaces/libpq/fe-auth.c b/src/interfaces/libpq/fe-auth.c
index 761ee8f88f7..ec7a9236044 100644
--- a/src/interfaces/libpq/fe-auth.c
+++ b/src/interfaces/libpq/fe-auth.c
@@ -40,9 +40,11 @@
 #endif
 
 #include "common/md5.h"
+#include "common/oauth-common.h"
 #include "common/scram-common.h"
 #include "fe-auth.h"
 #include "fe-auth-sasl.h"
+#include "fe-auth-oauth.h"
 #include "libpq-fe.h"
 
 #ifdef ENABLE_GSS
@@ -535,6 +537,13 @@ pg_SASL_init(PGconn *conn, int payloadlen, bool *async)
 			conn->sasl = &pg_scram_mech;
 			conn->password_needed = true;
 		}
+		else if (strcmp(mechanism_buf.data, OAUTHBEARER_NAME) == 0 &&
+				 !selected_mechanism)
+		{
+			selected_mechanism = OAUTHBEARER_NAME;
+			conn->sasl = &pg_oauth_mech;
+			conn->password_needed = false;
+		}
 	}
 
 	if (!selected_mechanism)
@@ -559,13 +568,6 @@ pg_SASL_init(PGconn *conn, int payloadlen, bool *async)
 
 		if (!allowed)
 		{
-			/*
-			 * TODO: this is dead code until a second SASL mechanism is added;
-			 * the connection can't have proceeded past check_expected_areq()
-			 * if no SASL methods are allowed.
-			 */
-			Assert(false);
-
 			libpq_append_conn_error(conn, "authentication method requirement \"%s\" failed: server requested %s authentication",
 									conn->require_auth, selected_mechanism);
 			goto error;
@@ -1580,3 +1582,23 @@ PQchangePassword(PGconn *conn, const char *user, const char *passwd)
 		}
 	}
 }
+
+PQauthDataHook_type PQauthDataHook = PQdefaultAuthDataHook;
+
+PQauthDataHook_type
+PQgetAuthDataHook(void)
+{
+	return PQauthDataHook;
+}
+
+void
+PQsetAuthDataHook(PQauthDataHook_type hook)
+{
+	PQauthDataHook = hook ? hook : PQdefaultAuthDataHook;
+}
+
+int
+PQdefaultAuthDataHook(PGauthData type, PGconn *conn, void *data)
+{
+	return 0;					/* handle nothing */
+}
diff --git a/src/interfaces/libpq/fe-auth.h b/src/interfaces/libpq/fe-auth.h
index 1d4991f8996..de98e0d20c4 100644
--- a/src/interfaces/libpq/fe-auth.h
+++ b/src/interfaces/libpq/fe-auth.h
@@ -18,6 +18,9 @@
 #include "libpq-int.h"
 
 
+extern PQauthDataHook_type PQauthDataHook;
+
+
 /* Prototypes for functions in fe-auth.c */
 extern int	pg_fe_sendauth(AuthRequest areq, int payloadlen, PGconn *conn,
 						   bool *async);
diff --git a/src/interfaces/libpq/fe-connect.c b/src/interfaces/libpq/fe-connect.c
index 85d1ca2864f..d5051f5e820 100644
--- a/src/interfaces/libpq/fe-connect.c
+++ b/src/interfaces/libpq/fe-connect.c
@@ -28,6 +28,7 @@
 #include "common/scram-common.h"
 #include "common/string.h"
 #include "fe-auth.h"
+#include "fe-auth-oauth.h"
 #include "libpq-fe.h"
 #include "libpq-int.h"
 #include "mb/pg_wchar.h"
@@ -373,6 +374,23 @@ static const internalPQconninfoOption PQconninfoOptions[] = {
 	{"scram_server_key", NULL, NULL, NULL, "SCRAM-Server-Key", "D", SCRAM_MAX_KEY_LEN * 2,
 	offsetof(struct pg_conn, scram_server_key)},
 
+	/* OAuth v2 */
+	{"oauth_issuer", NULL, NULL, NULL,
+		"OAuth-Issuer", "", 40,
+	offsetof(struct pg_conn, oauth_issuer)},
+
+	{"oauth_client_id", NULL, NULL, NULL,
+		"OAuth-Client-ID", "", 40,
+	offsetof(struct pg_conn, oauth_client_id)},
+
+	{"oauth_client_secret", NULL, NULL, NULL,
+		"OAuth-Client-Secret", "", 40,
+	offsetof(struct pg_conn, oauth_client_secret)},
+
+	{"oauth_scope", NULL, NULL, NULL,
+		"OAuth-Scope", "", 15,
+	offsetof(struct pg_conn, oauth_scope)},
+
 	/* Terminating entry --- MUST BE LAST */
 	{NULL, NULL, NULL, NULL,
 	NULL, NULL, 0}
@@ -399,6 +417,7 @@ static const PQEnvironmentOption EnvironmentOptions[] =
 static const pg_fe_sasl_mech *supported_sasl_mechs[] =
 {
 	&pg_scram_mech,
+	&pg_oauth_mech,
 };
 #define SASL_MECHANISM_COUNT lengthof(supported_sasl_mechs)
 
@@ -655,6 +674,7 @@ pqDropServerData(PGconn *conn)
 	conn->write_failed = false;
 	free(conn->write_err_msg);
 	conn->write_err_msg = NULL;
+	conn->oauth_want_retry = false;
 
 	/*
 	 * Cancel connections need to retain their be_pid and be_key across
@@ -1144,7 +1164,7 @@ static inline void
 fill_allowed_sasl_mechs(PGconn *conn)
 {
 	/*---
-	 * We only support one mechanism at the moment, so rather than deal with a
+	 * We only support two mechanisms at the moment, so rather than deal with a
 	 * linked list, conn->allowed_sasl_mechs is an array of static length. We
 	 * rely on the compile-time assertion here to keep us honest.
 	 *
@@ -1519,6 +1539,10 @@ pqConnectOptions2(PGconn *conn)
 			{
 				mech = &pg_scram_mech;
 			}
+			else if (strcmp(method, "oauth") == 0)
+			{
+				mech = &pg_oauth_mech;
+			}
 
 			/*
 			 * Final group: meta-options.
@@ -4111,7 +4135,19 @@ keep_going:						/* We will come back to here until there is
 				conn->inStart = conn->inCursor;
 
 				if (res != STATUS_OK)
+				{
+					/*
+					 * OAuth connections may perform two-step discovery, where
+					 * the first connection is a dummy.
+					 */
+					if (conn->sasl == &pg_oauth_mech && conn->oauth_want_retry)
+					{
+						need_new_connection = true;
+						goto keep_going;
+					}
+
 					goto error_return;
+				}
 
 				/*
 				 * Just make sure that any data sent by pg_fe_sendauth is
@@ -4390,6 +4426,9 @@ keep_going:						/* We will come back to here until there is
 					}
 				}
 
+				/* Don't hold onto any OAuth tokens longer than necessary. */
+				pqClearOAuthToken(conn);
+
 				/*
 				 * For non cancel requests we can release the address list
 				 * now. For cancel requests we never actually resolve
@@ -5002,6 +5041,12 @@ freePGconn(PGconn *conn)
 	free(conn->load_balance_hosts);
 	free(conn->scram_client_key);
 	free(conn->scram_server_key);
+	free(conn->oauth_issuer);
+	free(conn->oauth_issuer_id);
+	free(conn->oauth_discovery_uri);
+	free(conn->oauth_client_id);
+	free(conn->oauth_client_secret);
+	free(conn->oauth_scope);
 	termPQExpBuffer(&conn->errorMessage);
 	termPQExpBuffer(&conn->workBuffer);
 
@@ -5155,6 +5200,7 @@ pqClosePGconn(PGconn *conn)
 	conn->asyncStatus = PGASYNC_IDLE;
 	conn->xactStatus = PQTRANS_IDLE;
 	conn->pipelineStatus = PQ_PIPELINE_OFF;
+	pqClearOAuthToken(conn);
 	pqClearAsyncResult(conn);	/* deallocate result */
 	pqClearConnErrorState(conn);
 
diff --git a/src/interfaces/libpq/libpq-fe.h b/src/interfaces/libpq/libpq-fe.h
index a3491faf0c3..b7399dee58e 100644
--- a/src/interfaces/libpq/libpq-fe.h
+++ b/src/interfaces/libpq/libpq-fe.h
@@ -59,6 +59,8 @@ extern "C"
 /* Features added in PostgreSQL v18: */
 /* Indicates presence of PQfullProtocolVersion */
 #define LIBPQ_HAS_FULL_PROTOCOL_VERSION 1
+/* Indicates presence of the PQAUTHDATA_PROMPT_OAUTH_DEVICE authdata hook */
+#define LIBPQ_HAS_PROMPT_OAUTH_DEVICE 1
 
 /*
  * Option flags for PQcopyResult
@@ -186,6 +188,13 @@ typedef enum
 	PQ_PIPELINE_ABORTED
 } PGpipelineStatus;
 
+typedef enum
+{
+	PQAUTHDATA_PROMPT_OAUTH_DEVICE, /* user must visit a device-authorization
+									 * URL */
+	PQAUTHDATA_OAUTH_BEARER_TOKEN,	/* server requests an OAuth Bearer token */
+} PGauthData;
+
 /* PGconn encapsulates a connection to the backend.
  * The contents of this struct are not supposed to be known to applications.
  */
@@ -720,10 +729,86 @@ extern int	PQenv2encoding(void);
 
 /* === in fe-auth.c === */
 
+typedef struct _PGpromptOAuthDevice
+{
+	const char *verification_uri;	/* verification URI to visit */
+	const char *user_code;		/* user code to enter */
+	const char *verification_uri_complete;	/* optional combination of URI and
+											 * code, or NULL */
+	int			expires_in;		/* seconds until user code expires */
+} PGpromptOAuthDevice;
+
+/* for PGoauthBearerRequest.async() */
+#ifdef _WIN32
+#define SOCKTYPE uintptr_t		/* avoids depending on winsock2.h for SOCKET */
+#else
+#define SOCKTYPE int
+#endif
+
+typedef struct _PGoauthBearerRequest
+{
+	/* Hook inputs (constant across all calls) */
+	const char *const openid_configuration; /* OIDC discovery URI */
+	const char *const scope;	/* required scope(s), or NULL */
+
+	/* Hook outputs */
+
+	/*---------
+	 * Callback implementing a custom asynchronous OAuth flow.
+	 *
+	 * The callback may return
+	 * - PGRES_POLLING_READING/WRITING, to indicate that a socket descriptor
+	 *   has been stored in *altsock and libpq should wait until it is
+	 *   readable or writable before calling back;
+	 * - PGRES_POLLING_OK, to indicate that the flow is complete and
+	 *   request->token has been set; or
+	 * - PGRES_POLLING_FAILED, to indicate that token retrieval has failed.
+	 *
+	 * This callback is optional. If the token can be obtained without
+	 * 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.
+	 */
+	PostgresPollingStatusType (*async) (PGconn *conn,
+										struct _PGoauthBearerRequest *request,
+										SOCKTYPE * altsock);
+
+	/*
+	 * Callback to clean up custom allocations. A hook implementation may use
+	 * this to free request->token and any resources in request->user.
+	 *
+	 * This is technically optional, but highly recommended, because there is
+	 * no other indication as to when it is safe to free the token.
+	 */
+	void		(*cleanup) (PGconn *conn, struct _PGoauthBearerRequest *request);
+
+	/*
+	 * The hook should set this to the Bearer token contents for the
+	 * connection, once the flow is completed.  The token contents must remain
+	 * available to libpq until the hook's cleanup callback is called.
+	 */
+	char	   *token;
+
+	/*
+	 * Hook-defined data. libpq will not modify this pointer across calls to
+	 * the async callback, so it can be used to keep track of
+	 * application-specific state. Resources allocated here should be freed by
+	 * the cleanup callback.
+	 */
+	void	   *user;
+} PGoauthBearerRequest;
+
+#undef SOCKTYPE
+
 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);
 
+typedef int (*PQauthDataHook_type) (PGauthData type, PGconn *conn, void *data);
+extern void PQsetAuthDataHook(PQauthDataHook_type hook);
+extern PQauthDataHook_type PQgetAuthDataHook(void);
+extern int	PQdefaultAuthDataHook(PGauthData type, PGconn *conn, void *data);
+
 /* === in encnames.c === */
 
 extern int	pg_char_to_encoding(const char *name);
diff --git a/src/interfaces/libpq/libpq-int.h b/src/interfaces/libpq/libpq-int.h
index 2546f9f8a50..f36f7f19d58 100644
--- a/src/interfaces/libpq/libpq-int.h
+++ b/src/interfaces/libpq/libpq-int.h
@@ -437,6 +437,17 @@ struct pg_conn
 								 * cancel request, instead of being a normal
 								 * connection that's used for queries */
 
+	/* OAuth v2 */
+	char	   *oauth_issuer;	/* token issuer/URL */
+	char	   *oauth_issuer_id;	/* token issuer identifier */
+	char	   *oauth_discovery_uri;	/* URI of the issuer's discovery
+										 * document */
+	char	   *oauth_client_id;	/* client identifier */
+	char	   *oauth_client_secret;	/* client secret */
+	char	   *oauth_scope;	/* access token scope */
+	char	   *oauth_token;	/* access token */
+	bool		oauth_want_retry;	/* should we retry on failure? */
+
 	/* Optional file to write trace info to */
 	FILE	   *Pfdebug;
 	int			traceFlags;
@@ -505,7 +516,7 @@ struct pg_conn
 								 * the server? */
 	uint32		allowed_auth_methods;	/* bitmask of acceptable AuthRequest
 										 * codes */
-	const pg_fe_sasl_mech *allowed_sasl_mechs[1];	/* and acceptable SASL
+	const pg_fe_sasl_mech *allowed_sasl_mechs[2];	/* and acceptable SASL
 													 * mechanisms */
 	bool		client_finished_auth;	/* have we finished our half of the
 										 * authentication exchange? */
diff --git a/src/interfaces/libpq/meson.build b/src/interfaces/libpq/meson.build
index dd64d291b3e..19f4a52a97a 100644
--- a/src/interfaces/libpq/meson.build
+++ b/src/interfaces/libpq/meson.build
@@ -1,6 +1,7 @@
 # Copyright (c) 2022-2025, PostgreSQL Global Development Group
 
 libpq_sources = files(
+  'fe-auth-oauth.c',
   'fe-auth-scram.c',
   'fe-auth.c',
   'fe-cancel.c',
@@ -37,6 +38,10 @@ if gssapi.found()
   )
 endif
 
+if libcurl.found()
+  libpq_sources += files('fe-auth-oauth-curl.c')
+endif
+
 export_file = custom_target('libpq.exports',
   kwargs: gen_export_kwargs,
 )
diff --git a/src/makefiles/meson.build b/src/makefiles/meson.build
index d49b2079a44..60e13d50235 100644
--- a/src/makefiles/meson.build
+++ b/src/makefiles/meson.build
@@ -229,6 +229,7 @@ pgxs_deps = {
   'gssapi': gssapi,
   'icu': icu,
   'ldap': ldap,
+  'libcurl': libcurl,
   'libxml': libxml,
   'libxslt': libxslt,
   'llvm': llvm,
diff --git a/src/test/authentication/t/001_password.pl b/src/test/authentication/t/001_password.pl
index 1357f806b6f..4ce22ccbdf2 100644
--- a/src/test/authentication/t/001_password.pl
+++ b/src/test/authentication/t/001_password.pl
@@ -404,11 +404,11 @@ $node->connect_fails(
 $node->connect_fails(
 	"user=scram_role require_auth=!scram-sha-256",
 	"SCRAM authentication forbidden, fails with SCRAM auth",
-	expected_stderr => qr/server requested SASL authentication/);
+	expected_stderr => qr/server requested SCRAM-SHA-256 authentication/);
 $node->connect_fails(
 	"user=scram_role require_auth=!password,!md5,!scram-sha-256",
 	"multiple authentication types forbidden, fails with SCRAM auth",
-	expected_stderr => qr/server requested SASL authentication/);
+	expected_stderr => qr/server requested SCRAM-SHA-256 authentication/);
 
 # Test that bad passwords are rejected.
 $ENV{"PGPASSWORD"} = 'badpass';
@@ -465,13 +465,13 @@ $node->connect_fails(
 	"user=scram_role require_auth=!scram-sha-256",
 	"password authentication forbidden, fails with SCRAM auth",
 	expected_stderr =>
-	  qr/authentication method requirement "!scram-sha-256" failed: server requested SASL authentication/
+	  qr/authentication method requirement "!scram-sha-256" failed: server requested SCRAM-SHA-256 authentication/
 );
 $node->connect_fails(
 	"user=scram_role require_auth=!password,!md5,!scram-sha-256",
 	"multiple authentication types forbidden, fails with SCRAM auth",
 	expected_stderr =>
-	  qr/authentication method requirement "!password,!md5,!scram-sha-256" failed: server requested SASL authentication/
+	  qr/authentication method requirement "!password,!md5,!scram-sha-256" failed: server requested SCRAM-SHA-256 authentication/
 );
 
 # Test SYSTEM_USER <> NULL with parallel workers.
diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile
index 89e78b7d114..4e4be3fa511 100644
--- a/src/test/modules/Makefile
+++ b/src/test/modules/Makefile
@@ -11,6 +11,7 @@ SUBDIRS = \
 		  dummy_index_am \
 		  dummy_seclabel \
 		  libpq_pipeline \
+		  oauth_validator \
 		  plsample \
 		  spgist_name_ops \
 		  test_bloomfilter \
diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build
index a57077b682e..2b057451473 100644
--- a/src/test/modules/meson.build
+++ b/src/test/modules/meson.build
@@ -9,6 +9,7 @@ subdir('gin')
 subdir('injection_points')
 subdir('ldap_password_func')
 subdir('libpq_pipeline')
+subdir('oauth_validator')
 subdir('plsample')
 subdir('spgist_name_ops')
 subdir('ssl_passphrase_callback')
diff --git a/src/test/modules/oauth_validator/.gitignore b/src/test/modules/oauth_validator/.gitignore
new file mode 100644
index 00000000000..5dcb3ff9723
--- /dev/null
+++ b/src/test/modules/oauth_validator/.gitignore
@@ -0,0 +1,4 @@
+# Generated subdirectories
+/log/
+/results/
+/tmp_check/
diff --git a/src/test/modules/oauth_validator/Makefile b/src/test/modules/oauth_validator/Makefile
new file mode 100644
index 00000000000..05b9f06ed73
--- /dev/null
+++ b/src/test/modules/oauth_validator/Makefile
@@ -0,0 +1,40 @@
+#-------------------------------------------------------------------------
+#
+# Makefile for src/test/modules/oauth_validator
+#
+# Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/test/modules/oauth_validator/Makefile
+#
+#-------------------------------------------------------------------------
+
+MODULES = validator fail_validator magic_validator
+PGFILEDESC = "validator - test OAuth validator module"
+
+PROGRAM = oauth_hook_client
+PGAPPICON = win32
+OBJS = $(WIN32RES) oauth_hook_client.o
+
+PG_CPPFLAGS = -I$(libpq_srcdir)
+PG_LIBS_INTERNAL += $(libpq_pgport)
+
+NO_INSTALLCHECK = 1
+
+TAP_TESTS = 1
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = src/test/modules/oauth_validator
+top_builddir = ../../../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+
+export PYTHON
+export with_libcurl
+export with_python
+
+endif
diff --git a/src/test/modules/oauth_validator/README b/src/test/modules/oauth_validator/README
new file mode 100644
index 00000000000..54eac5b117e
--- /dev/null
+++ b/src/test/modules/oauth_validator/README
@@ -0,0 +1,13 @@
+Test programs and libraries for OAuth
+-------------------------------------
+
+This folder contains tests for the client- and server-side OAuth
+implementations. Most tests are run end-to-end to test both simultaneously. The
+tests in t/001_server use a mock OAuth authorization server, implemented jointly
+by t/OAuth/Server.pm and t/oauth_server.py, to run the libpq Device
+Authorization flow. The tests in t/002_client exercise custom OAuth flows and
+don't need an authorization server.
+
+Tests in this folder require 'oauth' to be present in PG_TEST_EXTRA, since
+HTTPS servers listening on localhost with TCP/IP sockets will be started. A
+Python installation is required to run the mock authorization server.
diff --git a/src/test/modules/oauth_validator/fail_validator.c b/src/test/modules/oauth_validator/fail_validator.c
new file mode 100644
index 00000000000..a4c7a4451d3
--- /dev/null
+++ b/src/test/modules/oauth_validator/fail_validator.c
@@ -0,0 +1,47 @@
+/*-------------------------------------------------------------------------
+ *
+ * fail_validator.c
+ *	  Test module for serverside OAuth token validation callbacks, which is
+ *	  guaranteed to always fail in the validation callback
+ *
+ * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/test/modules/oauth_validator/fail_validator.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "fmgr.h"
+#include "libpq/oauth.h"
+
+PG_MODULE_MAGIC;
+
+static bool fail_token(const ValidatorModuleState *state,
+					   const char *token,
+					   const char *role,
+					   ValidatorModuleResult *result);
+
+/* Callback implementations (we only need the main one) */
+static const OAuthValidatorCallbacks validator_callbacks = {
+	PG_OAUTH_VALIDATOR_MAGIC,
+
+	.validate_cb = fail_token,
+};
+
+const OAuthValidatorCallbacks *
+_PG_oauth_validator_module_init(void)
+{
+	return &validator_callbacks;
+}
+
+static bool
+fail_token(const ValidatorModuleState *state,
+		   const char *token, const char *role,
+		   ValidatorModuleResult *res)
+{
+	elog(FATAL, "fail_validator: sentinel error");
+	pg_unreachable();
+}
diff --git a/src/test/modules/oauth_validator/magic_validator.c b/src/test/modules/oauth_validator/magic_validator.c
new file mode 100644
index 00000000000..9dc55b602e3
--- /dev/null
+++ b/src/test/modules/oauth_validator/magic_validator.c
@@ -0,0 +1,48 @@
+/*-------------------------------------------------------------------------
+ *
+ * magic_validator.c
+ *	  Test module for serverside OAuth token validation callbacks, which
+ *	  should fail due to using the wrong PG_OAUTH_VALIDATOR_MAGIC marker
+ *	  and thus the wrong ABI version
+ *
+ * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/test/modules/oauth_validator/magic_validator.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "fmgr.h"
+#include "libpq/oauth.h"
+
+PG_MODULE_MAGIC;
+
+static bool validate_token(const ValidatorModuleState *state,
+						   const char *token,
+						   const char *role,
+						   ValidatorModuleResult *result);
+
+/* Callback implementations (we only need the main one) */
+static const OAuthValidatorCallbacks validator_callbacks = {
+	0xdeadbeef,
+
+	.validate_cb = validate_token,
+};
+
+const OAuthValidatorCallbacks *
+_PG_oauth_validator_module_init(void)
+{
+	return &validator_callbacks;
+}
+
+static bool
+validate_token(const ValidatorModuleState *state,
+			   const char *token, const char *role,
+			   ValidatorModuleResult *res)
+{
+	elog(FATAL, "magic_validator: this should be unreachable");
+	pg_unreachable();
+}
diff --git a/src/test/modules/oauth_validator/meson.build b/src/test/modules/oauth_validator/meson.build
new file mode 100644
index 00000000000..36d1b26369f
--- /dev/null
+++ b/src/test/modules/oauth_validator/meson.build
@@ -0,0 +1,85 @@
+# Copyright (c) 2025, PostgreSQL Global Development Group
+
+validator_sources = files(
+  'validator.c',
+)
+
+if host_system == 'windows'
+  validator_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'validator',
+    '--FILEDESC', 'validator - test OAuth validator module',])
+endif
+
+validator = shared_module('validator',
+  validator_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += validator
+
+fail_validator_sources = files(
+  'fail_validator.c',
+)
+
+if host_system == 'windows'
+  fail_validator_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'fail_validator',
+    '--FILEDESC', 'fail_validator - failing OAuth validator module',])
+endif
+
+fail_validator = shared_module('fail_validator',
+  fail_validator_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += fail_validator
+
+magic_validator_sources = files(
+  'magic_validator.c',
+)
+
+if host_system == 'windows'
+  magic_validator_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'magic_validator',
+    '--FILEDESC', 'magic_validator - ABI incompatible OAuth validator module',])
+endif
+
+magic_validator = shared_module('magic_validator',
+  magic_validator_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += magic_validator
+
+oauth_hook_client_sources = files(
+  'oauth_hook_client.c',
+)
+
+if host_system == 'windows'
+  oauth_hook_client_sources += rc_bin_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'oauth_hook_client',
+    '--FILEDESC', 'oauth_hook_client - test program for libpq OAuth hooks',])
+endif
+
+oauth_hook_client = executable('oauth_hook_client',
+  oauth_hook_client_sources,
+  dependencies: [frontend_code, libpq],
+  kwargs: default_bin_args + {
+    'install': false,
+  },
+)
+testprep_targets += oauth_hook_client
+
+tests += {
+  'name': 'oauth_validator',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'tap': {
+    'tests': [
+      't/001_server.pl',
+      't/002_client.pl',
+    ],
+    'env': {
+      'PYTHON': python.path(),
+      'with_libcurl': libcurl.found() ? 'yes' : 'no',
+      'with_python': 'yes',
+    },
+  },
+}
diff --git a/src/test/modules/oauth_validator/oauth_hook_client.c b/src/test/modules/oauth_validator/oauth_hook_client.c
new file mode 100644
index 00000000000..9f553792c05
--- /dev/null
+++ b/src/test/modules/oauth_validator/oauth_hook_client.c
@@ -0,0 +1,293 @@
+/*-------------------------------------------------------------------------
+ *
+ * oauth_hook_client.c
+ *		Test driver for t/002_client.pl, which verifies OAuth hook
+ *		functionality in libpq.
+ *
+ * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ *
+ * IDENTIFICATION
+ *		src/test/modules/oauth_validator/oauth_hook_client.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres_fe.h"
+
+#include <sys/socket.h>
+
+#include "getopt_long.h"
+#include "libpq-fe.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(" --expected-scope SCOPE	fail if received scopes do not match SCOPE\n");
+	printf(" --expected-uri URI		fail if received configuration link does not match URI\n");
+	printf(" --misbehave=MODE		have the hook fail required postconditions\n"
+		   "						(MODEs: no-hook, fail-async, no-token, no-socket)\n");
+	printf(" --no-hook				don't install OAuth hooks\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(" --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_scope = NULL;
+static const char *misbehave_mode = NULL;
+static char *token = NULL;
+
+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},
+		{0}
+	};
+
+	const char *conninfo;
+	PGconn	   *conn;
+	int			c;
+
+	while ((c = getopt_long(argc, argv, "h", long_options, NULL)) != -1)
+	{
+		switch (c)
+		{
+			case 'h':
+				usage(argv);
+				return 0;
+
+			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;
+
+			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);
+
+	/* Connect. (All the actual work is in the hook.) */
+	if (stress_async)
+	{
+		/*
+		 * Perform an asynchronous connection, busy-looping on PQconnectPoll()
+		 * without actually waiting on socket events. This stresses code paths
+		 * that rely on asynchronous work to be done before continuing with
+		 * the next step in the flow.
+		 */
+		PostgresPollingStatusType res;
+
+		conn = PQconnectStart(conninfo);
+
+		do
+		{
+			res = PQconnectPoll(conn);
+		} while (res != PGRES_POLLING_FAILED && res != PGRES_POLLING_OK);
+	}
+	else
+	{
+		/* Perform a standard synchronous connection. */
+		conn = PQconnectdb(conninfo);
+	}
+
+	if (PQstatus(conn) != CONNECTION_OK)
+	{
+		fprintf(stderr, "connection to database failed: %s\n",
+				PQerrorMessage(conn));
+		PQfinish(conn);
+		return 1;
+	}
+
+	printf("connection succeeded\n");
+	PQfinish(conn);
+	return 0;
+}
+
+/*
+ * PQauthDataHook implementation. Replaces the default client flow by handling
+ * PQAUTHDATA_OAUTH_BEARER_TOKEN.
+ */
+static int
+handle_auth_data(PGauthData type, PGconn *conn, void *data)
+{
+	PGoauthBearerRequest *req = data;
+
+	if (no_hook || (type != PQAUTHDATA_OAUTH_BEARER_TOKEN))
+		return 0;
+
+	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;
+		}
+	}
+
+	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". */
+		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/001_server.pl b/src/test/modules/oauth_validator/t/001_server.pl
new file mode 100644
index 00000000000..6fa59fbeb25
--- /dev/null
+++ b/src/test/modules/oauth_validator/t/001_server.pl
@@ -0,0 +1,594 @@
+
+#
+# Tests the libpq builtin OAuth flow, as well as server-side HBA and validator
+# setup.
+#
+# Copyright (c) 2021-2025, PostgreSQL Global Development Group
+#
+
+use strict;
+use warnings FATAL => 'all';
+
+use JSON::PP     qw(encode_json);
+use MIME::Base64 qw(encode_base64);
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+use FindBin;
+use lib $FindBin::RealBin;
+
+use OAuth::Server;
+
+if (!$ENV{PG_TEST_EXTRA} || $ENV{PG_TEST_EXTRA} !~ /\boauth\b/)
+{
+	plan skip_all =>
+	  'Potentially unsafe test oauth not enabled in PG_TEST_EXTRA';
+}
+
+if ($windows_os)
+{
+	plan skip_all => 'OAuth server-side tests are not supported on Windows';
+}
+
+if ($ENV{with_libcurl} ne 'yes')
+{
+	plan skip_all => 'client-side OAuth not supported by this build';
+}
+
+if ($ENV{with_python} ne 'yes')
+{
+	plan skip_all => 'OAuth tests require --with-python to run';
+}
+
+my $node = PostgreSQL::Test::Cluster->new('primary');
+$node->init;
+$node->append_conf('postgresql.conf', "log_connections = on\n");
+$node->append_conf('postgresql.conf',
+	"oauth_validator_libraries = 'validator'\n");
+$node->start;
+
+$node->safe_psql('postgres', 'CREATE USER test;');
+$node->safe_psql('postgres', 'CREATE USER testalt;');
+$node->safe_psql('postgres', 'CREATE USER testparam;');
+
+# Save a background connection for later configuration changes.
+my $bgconn = $node->background_psql('postgres');
+
+my $webserver = OAuth::Server->new();
+$webserver->run();
+
+END
+{
+	my $exit_code = $?;
+
+	$webserver->stop() if defined $webserver;    # might have been SKIP'd
+
+	$? = $exit_code;
+}
+
+my $port = $webserver->port();
+my $issuer = "http://localhost:$port";
+
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test      oauth issuer="$issuer"       scope="openid postgres"
+local all testalt   oauth issuer="$issuer/.well-known/oauth-authorization-server/alternate" scope="openid postgres alt"
+local all testparam oauth issuer="$issuer/param" scope="openid postgres"
+});
+$node->reload;
+
+my $log_start = $node->wait_for_log(qr/reloading configuration files/);
+
+# Check pg_hba_file_rules() support.
+my $contents = $bgconn->query_safe(
+	qq(SELECT rule_number, auth_method, options
+		 FROM pg_hba_file_rules
+		 ORDER BY rule_number;));
+is( $contents,
+	qq{1|oauth|\{issuer=$issuer,"scope=openid postgres",validator=validator\}
+2|oauth|\{issuer=$issuer/.well-known/oauth-authorization-server/alternate,"scope=openid postgres alt",validator=validator\}
+3|oauth|\{issuer=$issuer/param,"scope=openid postgres",validator=validator\}},
+	"pg_hba_file_rules recreates OAuth HBA settings");
+
+# To test against HTTP rather than HTTPS, we need to enable PGOAUTHDEBUG. But
+# first, check to make sure the client refuses such connections by default.
+$node->connect_fails(
+	"user=test dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635",
+	"HTTPS is required without debug mode",
+	expected_stderr =>
+	  qr@OAuth discovery URI "\Q$issuer\E/.well-known/openid-configuration" must use HTTPS@
+);
+
+$ENV{PGOAUTHDEBUG} = "UNSAFE";
+
+my $user = "test";
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635",
+	"connect as test",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@,
+	log_like => [
+		qr/oauth_validator: token="9243959234", role="$user"/,
+		qr/oauth_validator: issuer="\Q$issuer\E", scope="openid postgres"/,
+		qr/connection authenticated: identity="test" method=oauth/,
+		qr/connection authorized/,
+	]);
+
+# The /alternate issuer uses slightly different parameters, along with an
+# OAuth-style discovery document.
+$user = "testalt";
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer/alternate oauth_client_id=f02c6361-0636",
+	"connect as testalt",
+	expected_stderr =>
+	  qr@Visit https://example\.org/ and enter the code: postgresuser@,
+	log_like => [
+		qr/oauth_validator: token="9243959234-alt", role="$user"/,
+		qr|oauth_validator: issuer="\Q$issuer/.well-known/oauth-authorization-server/alternate\E", scope="openid postgres alt"|,
+		qr/connection authenticated: identity="testalt" method=oauth/,
+		qr/connection authorized/,
+	]);
+
+# The issuer linked by the server must match the client's oauth_issuer setting.
+$node->connect_fails(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0636",
+	"oauth_issuer must match discovery",
+	expected_stderr =>
+	  qr@server's discovery document at \Q$issuer/.well-known/oauth-authorization-server/alternate\E \(issuer "\Q$issuer/alternate\E"\) is incompatible with oauth_issuer \(\Q$issuer\E\)@
+);
+
+# Test require_auth settings against OAUTHBEARER.
+my @cases = (
+	{ require_auth => "oauth" },
+	{ require_auth => "oauth,scram-sha-256" },
+	{ require_auth => "password,oauth" },
+	{ require_auth => "none,oauth" },
+	{ require_auth => "!scram-sha-256" },
+	{ require_auth => "!none" },
+
+	{
+		require_auth => "!oauth",
+		failure => qr/server requested OAUTHBEARER authentication/
+	},
+	{
+		require_auth => "scram-sha-256",
+		failure => qr/server requested OAUTHBEARER authentication/
+	},
+	{
+		require_auth => "!password,!oauth",
+		failure => qr/server requested OAUTHBEARER authentication/
+	},
+	{
+		require_auth => "none",
+		failure => qr/server requested SASL authentication/
+	},
+	{
+		require_auth => "!oauth,!scram-sha-256",
+		failure => qr/server requested SASL authentication/
+	});
+
+$user = "test";
+foreach my $c (@cases)
+{
+	my $connstr =
+	  "user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635 require_auth=$c->{'require_auth'}";
+
+	if (defined $c->{'failure'})
+	{
+		$node->connect_fails(
+			$connstr,
+			"require_auth=$c->{'require_auth'} fails",
+			expected_stderr => $c->{'failure'});
+	}
+	else
+	{
+		$node->connect_ok(
+			$connstr,
+			"require_auth=$c->{'require_auth'} succeeds",
+			expected_stderr =>
+			  qr@Visit https://example\.com/ and enter the code: postgresuser@
+		);
+	}
+}
+
+# Make sure the client_id and secret are correctly encoded. $vschars contains
+# every allowed character for a client_id/_secret (the "VSCHAR" class).
+# $vschars_esc is additionally backslash-escaped for inclusion in a
+# single-quoted connection string.
+my $vschars =
+  " !\"#\$%&'()*+,-./0123456789:;<=>?\@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~";
+my $vschars_esc =
+  " !\"#\$%&\\'()*+,-./0123456789:;<=>?\@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\\\]^_`abcdefghijklmnopqrstuvwxyz{|}~";
+
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id='$vschars_esc'",
+	"escapable characters: client_id",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id='$vschars_esc' oauth_client_secret='$vschars_esc'",
+	"escapable characters: client_id and secret",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+#
+# Further tests rely on support for specific behaviors in oauth_server.py. To
+# trigger these behaviors, we ask for the special issuer .../param (which is set
+# up in HBA for the testparam user) and encode magic instructions into the
+# oauth_client_id.
+#
+
+my $common_connstr =
+  "user=testparam dbname=postgres oauth_issuer=$issuer/param ";
+my $base_connstr = $common_connstr;
+
+sub connstr
+{
+	my (%params) = @_;
+
+	my $json = encode_json(\%params);
+	my $encoded = encode_base64($json, "");
+
+	return "$base_connstr oauth_client_id=$encoded";
+}
+
+# Make sure the param system works end-to-end first.
+$node->connect_ok(
+	connstr(),
+	"connect to /param",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$node->connect_ok(
+	connstr(stage => 'token', retries => 1),
+	"token retry",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(stage => 'token', retries => 2),
+	"token retry (twice)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(stage => 'all', retries => 1, interval => 2),
+	"token retry (two second interval)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(stage => 'all', retries => 1, interval => JSON::PP::null),
+	"token retry (default interval)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$node->connect_ok(
+	connstr(stage => 'all', content_type => 'application/json;charset=utf-8'),
+	"content type with charset",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(
+		stage => 'all',
+		content_type => "application/json \t;\t charset=utf-8"),
+	"content type with charset (whitespace)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(stage => 'device', uri_spelling => "verification_url"),
+	"alternative spelling of verification_uri",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$node->connect_fails(
+	connstr(stage => 'device', huge_response => JSON::PP::true),
+	"bad device authz response: overlarge JSON",
+	expected_stderr =>
+	  qr/failed to obtain device authorization: response is too large/);
+$node->connect_fails(
+	connstr(stage => 'token', huge_response => JSON::PP::true),
+	"bad token response: overlarge JSON",
+	expected_stderr =>
+	  qr/failed to obtain access token: response is too large/);
+
+$node->connect_fails(
+	connstr(stage => 'device', content_type => 'text/plain'),
+	"bad device authz response: wrong content type",
+	expected_stderr =>
+	  qr/failed to parse device authorization: unexpected content type/);
+$node->connect_fails(
+	connstr(stage => 'token', content_type => 'text/plain'),
+	"bad token response: wrong content type",
+	expected_stderr =>
+	  qr/failed to parse access token response: unexpected content type/);
+$node->connect_fails(
+	connstr(stage => 'token', content_type => 'application/jsonx'),
+	"bad token response: wrong content type (correct prefix)",
+	expected_stderr =>
+	  qr/failed to parse access token response: unexpected content type/);
+
+$node->connect_fails(
+	connstr(
+		stage => 'all',
+		interval => ~0,
+		retries => 1,
+		retry_code => "slow_down"),
+	"bad token response: server overflows the device authz interval",
+	expected_stderr =>
+	  qr/failed to obtain access token: slow_down interval overflow/);
+
+$node->connect_fails(
+	connstr(stage => 'token', error_code => "invalid_grant"),
+	"bad token response: invalid_grant, no description",
+	expected_stderr => qr/failed to obtain access token: \(invalid_grant\)/);
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_grant",
+		error_desc => "grant expired"),
+	"bad token response: expired grant",
+	expected_stderr =>
+	  qr/failed to obtain access token: grant expired \(invalid_grant\)/);
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_client",
+		error_status => 401),
+	"bad token response: client authentication failure, default description",
+	expected_stderr =>
+	  qr/failed to obtain access token: provider requires client authentication, and no oauth_client_secret is set \(invalid_client\)/
+);
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_client",
+		error_status => 401,
+		error_desc => "authn failure"),
+	"bad token response: client authentication failure, provided description",
+	expected_stderr =>
+	  qr/failed to obtain access token: authn failure \(invalid_client\)/);
+
+$node->connect_fails(
+	connstr(stage => 'token', token => ""),
+	"server rejects access: empty token",
+	expected_stderr => qr/bearer authentication failed/);
+$node->connect_fails(
+	connstr(stage => 'token', token => "****"),
+	"server rejects access: invalid token contents",
+	expected_stderr => qr/bearer authentication failed/);
+
+# Test behavior of the oauth_client_secret.
+$base_connstr = "$common_connstr oauth_client_secret=''";
+
+$node->connect_ok(
+	connstr(stage => 'all', expected_secret => ''),
+	"empty oauth_client_secret",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$base_connstr = "$common_connstr oauth_client_secret='$vschars_esc'";
+
+$node->connect_ok(
+	connstr(stage => 'all', expected_secret => $vschars),
+	"nonempty oauth_client_secret",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_client",
+		error_status => 401),
+	"bad token response: client authentication failure, default description with oauth_client_secret",
+	expected_stderr =>
+	  qr/failed to obtain access token: provider rejected the oauth_client_secret \(invalid_client\)/
+);
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_client",
+		error_status => 401,
+		error_desc => "mutual TLS required for client"),
+	"bad token response: client authentication failure, provided description with oauth_client_secret",
+	expected_stderr =>
+	  qr/failed to obtain access token: mutual TLS required for client \(invalid_client\)/
+);
+
+# Stress test: make sure our builtin flow operates correctly even if the client
+# application isn't respecting PGRES_POLLING_READING/WRITING signals returned
+# from PQconnectPoll().
+$base_connstr =
+  "$common_connstr port=" . $node->port . " host=" . $node->host;
+my @cmd = (
+	"oauth_hook_client", "--no-hook", "--stress-async",
+	connstr(stage => 'all', retries => 1, interval => 1));
+
+note "running '" . join("' '", @cmd) . "'";
+my ($stdout, $stderr) = run_command(\@cmd);
+
+like($stdout, qr/connection succeeded/, "stress-async: stdout matches");
+unlike(
+	$stderr,
+	qr/connection to database failed/,
+	"stress-async: stderr matches");
+
+#
+# This section of tests reconfigures the validator module itself, rather than
+# the OAuth server.
+#
+
+# Searching the logs is easier if OAuth parameter discovery isn't cluttering
+# things up; hardcode the discovery URI. (Scope is hardcoded to empty to cover
+# that case as well.)
+$common_connstr =
+  "dbname=postgres oauth_issuer=$issuer/.well-known/openid-configuration oauth_scope='' oauth_client_id=f02c6361-0635";
+
+# Misbehaving validators must fail shut.
+$bgconn->query_safe("ALTER SYSTEM SET oauth_validator.authn_id TO ''");
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+$node->connect_fails(
+	"$common_connstr user=test",
+	"validator must set authn_id",
+	expected_stderr => qr/OAuth bearer authentication failed/,
+	log_like => [
+		qr/connection authenticated: identity=""/,
+		qr/DETAIL:\s+Validator provided no identity/,
+		qr/FATAL:\s+OAuth bearer authentication failed/,
+	]);
+
+# Even if a validator authenticates the user, if the token isn't considered
+# valid, the connection fails.
+$bgconn->query_safe(
+	"ALTER SYSTEM SET oauth_validator.authn_id TO 'test\@example.org'");
+$bgconn->query_safe(
+	"ALTER SYSTEM SET oauth_validator.authorize_tokens TO false");
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+$node->connect_fails(
+	"$common_connstr user=test",
+	"validator must authorize token explicitly",
+	expected_stderr => qr/OAuth bearer authentication failed/,
+	log_like => [
+		qr/connection authenticated: identity="test\@example\.org"/,
+		qr/DETAIL:\s+Validator failed to authorize the provided token/,
+		qr/FATAL:\s+OAuth bearer authentication failed/,
+	]);
+
+#
+# Test user mapping.
+#
+
+# Allow "[email protected]" to log in under the test role.
+unlink($node->data_dir . '/pg_ident.conf');
+$node->append_conf(
+	'pg_ident.conf', qq{
+oauthmap	user\@example.com	test
+});
+
+# test and testalt use the map; testparam uses ident delegation.
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test      oauth issuer="$issuer" scope="" map=oauthmap
+local all testalt   oauth issuer="$issuer" scope="" map=oauthmap
+local all testparam oauth issuer="$issuer" scope="" delegate_ident_mapping=1
+});
+
+# To start, have the validator use the role names as authn IDs.
+$bgconn->query_safe("ALTER SYSTEM RESET oauth_validator.authn_id");
+$bgconn->query_safe("ALTER SYSTEM RESET oauth_validator.authorize_tokens");
+
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+# The test and testalt roles should no longer map correctly.
+$node->connect_fails(
+	"$common_connstr user=test",
+	"mismatched username map (test)",
+	expected_stderr => qr/OAuth bearer authentication failed/);
+$node->connect_fails(
+	"$common_connstr user=testalt",
+	"mismatched username map (testalt)",
+	expected_stderr => qr/OAuth bearer authentication failed/);
+
+# Have the validator identify the end user as [email protected].
+$bgconn->query_safe(
+	"ALTER SYSTEM SET oauth_validator.authn_id TO 'user\@example.com'");
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+# Now the test role can be logged into. (testalt still can't be mapped.)
+$node->connect_ok(
+	"$common_connstr user=test",
+	"matched username map (test)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_fails(
+	"$common_connstr user=testalt",
+	"mismatched username map (testalt)",
+	expected_stderr => qr/OAuth bearer authentication failed/);
+
+# testparam ignores the map entirely.
+$node->connect_ok(
+	"$common_connstr user=testparam",
+	"delegated ident (testparam)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$bgconn->query_safe("ALTER SYSTEM RESET oauth_validator.authn_id");
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+#
+# Test multiple validators.
+#
+
+$node->append_conf('postgresql.conf',
+	"oauth_validator_libraries = 'validator, fail_validator'\n");
+
+# With multiple validators, every HBA line must explicitly declare one.
+my $result = $node->restart(fail_ok => 1);
+is($result, 0,
+	'restart fails without explicit validators in oauth HBA entries');
+
+$log_start = $node->wait_for_log(
+	qr/authentication method "oauth" requires argument "validator" to be set/,
+	$log_start);
+
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test    oauth validator=validator      issuer="$issuer"           scope="openid postgres"
+local all testalt oauth validator=fail_validator issuer="$issuer/.well-known/oauth-authorization-server/alternate" scope="openid postgres alt"
+});
+$node->restart;
+
+$log_start = $node->wait_for_log(qr/ready to accept connections/, $log_start);
+
+# The test user should work as before.
+$user = "test";
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635",
+	"validator is used for $user",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@,
+	log_like => [qr/connection authorized/]);
+
+# testalt should be routed through the fail_validator.
+$user = "testalt";
+$node->connect_fails(
+	"user=$user dbname=postgres oauth_issuer=$issuer/.well-known/oauth-authorization-server/alternate oauth_client_id=f02c6361-0636",
+	"fail_validator is used for $user",
+	expected_stderr => qr/FATAL:\s+fail_validator: sentinel error/);
+
+#
+# Test ABI compatibility magic marker
+#
+$node->append_conf('postgresql.conf',
+	"oauth_validator_libraries = 'magic_validator'\n");
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test    oauth validator=magic_validator      issuer="$issuer"           scope="openid postgres"
+});
+$node->restart;
+
+$log_start = $node->wait_for_log(qr/ready to accept connections/, $log_start);
+
+$node->connect_fails(
+	"user=test dbname=postgres oauth_issuer=$issuer/.well-known/oauth-authorization-server/alternate oauth_client_id=f02c6361-0636",
+	"magic_validator is used for $user",
+	expected_stderr =>
+	  qr/FATAL:\s+OAuth validator module "magic_validator": magic number mismatch/
+);
+$node->stop;
+
+done_testing();
diff --git a/src/test/modules/oauth_validator/t/002_client.pl b/src/test/modules/oauth_validator/t/002_client.pl
new file mode 100644
index 00000000000..ab83258d736
--- /dev/null
+++ b/src/test/modules/oauth_validator/t/002_client.pl
@@ -0,0 +1,154 @@
+#
+# Exercises the API for custom OAuth client flows, using the oauth_hook_client
+# test driver.
+#
+# Copyright (c) 2021-2025, PostgreSQL Global Development Group
+#
+
+use strict;
+use warnings FATAL => 'all';
+
+use JSON::PP     qw(encode_json);
+use MIME::Base64 qw(encode_base64);
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+if (!$ENV{PG_TEST_EXTRA} || $ENV{PG_TEST_EXTRA} !~ /\boauth\b/)
+{
+	plan skip_all =>
+	  'Potentially unsafe test oauth not enabled in PG_TEST_EXTRA';
+}
+
+#
+# Cluster Setup
+#
+
+my $node = PostgreSQL::Test::Cluster->new('primary');
+$node->init;
+$node->append_conf('postgresql.conf', "log_connections = on\n");
+$node->append_conf('postgresql.conf',
+	"oauth_validator_libraries = 'validator'\n");
+$node->start;
+
+$node->safe_psql('postgres', 'CREATE USER test;');
+
+# These tests don't use the builtin flow, and we don't have an authorization
+# server running, so the address used here shouldn't matter. Use an invalid IP
+# address, so if there's some cascade of errors that causes the client to
+# attempt a connection, we'll fail noisily.
+my $issuer = "https://256.256.256.256";
+my $scope = "openid postgres";
+
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test oauth issuer="$issuer" scope="$scope"
+});
+$node->reload;
+
+my ($log_start, $log_end);
+$log_start = $node->wait_for_log(qr/reloading configuration files/);
+
+$ENV{PGOAUTHDEBUG} = "UNSAFE";
+
+#
+# Tests
+#
+
+my $user = "test";
+my $base_connstr = $node->connstr() . " user=$user";
+my $common_connstr =
+  "$base_connstr oauth_issuer=$issuer oauth_client_id=myID";
+
+sub test
+{
+	my ($test_name, %params) = @_;
+
+	my $flags = [];
+	if (defined($params{flags}))
+	{
+		$flags = $params{flags};
+	}
+
+	my @cmd = ("oauth_hook_client", @{$flags}, $common_connstr);
+	note "running '" . join("' '", @cmd) . "'";
+
+	my ($stdout, $stderr) = run_command(\@cmd);
+
+	if (defined($params{expected_stdout}))
+	{
+		like($stdout, $params{expected_stdout}, "$test_name: stdout matches");
+	}
+
+	if (defined($params{expected_stderr}))
+	{
+		like($stderr, $params{expected_stderr}, "$test_name: stderr matches");
+	}
+	else
+	{
+		is($stderr, "", "$test_name: no stderr");
+	}
+}
+
+test(
+	"basic synchronous hook can provide a token",
+	flags => [
+		"--token", "my-token",
+		"--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"/, ]);
+
+if ($ENV{with_libcurl} ne 'yes')
+{
+	# libpq should help users out if no OAuth support is built in.
+	test(
+		"fails without custom hook installed",
+		flags => ["--no-hook"],
+		expected_stderr =>
+		  qr/no custom OAuth flows are available, and libpq was not built with libcurl support/
+	);
+}
+
+# connect_timeout should work if the flow doesn't respond.
+$common_connstr = "$common_connstr connect_timeout=1";
+test(
+	"connect_timeout interrupts hung client flow",
+	flags => ["--hang-forever"],
+	expected_stderr => qr/failed: timeout expired/);
+
+# Test various misbehaviors of the client hook.
+my @cases = (
+	{
+		flag => "--misbehave=no-hook",
+		expected_error =>
+		  qr/user-defined OAuth flow provided neither a token nor an async callback/,
+	},
+	{
+		flag => "--misbehave=fail-async",
+		expected_error => qr/user-defined OAuth flow failed/,
+	},
+	{
+		flag => "--misbehave=no-token",
+		expected_error => qr/user-defined OAuth flow did not provide a token/,
+	},
+	{
+		flag => "--misbehave=no-socket",
+		expected_error =>
+		  qr/user-defined OAuth flow did not provide a socket for polling/,
+	});
+
+foreach my $c (@cases)
+{
+	test(
+		"hook misbehavior: $c->{'flag'}",
+		flags => [ $c->{'flag'} ],
+		expected_stderr => $c->{'expected_error'});
+}
+
+done_testing();
diff --git a/src/test/modules/oauth_validator/t/OAuth/Server.pm b/src/test/modules/oauth_validator/t/OAuth/Server.pm
new file mode 100644
index 00000000000..655b2870b0b
--- /dev/null
+++ b/src/test/modules/oauth_validator/t/OAuth/Server.pm
@@ -0,0 +1,140 @@
+
+# Copyright (c) 2025, PostgreSQL Global Development Group
+
+=pod
+
+=head1 NAME
+
+OAuth::Server - runs a mock OAuth authorization server for testing
+
+=head1 SYNOPSIS
+
+  use OAuth::Server;
+
+  my $server = OAuth::Server->new();
+  $server->run;
+
+  my $port = $server->port;
+  my $issuer = "http://localhost:$port";
+
+  # test against $issuer...
+
+  $server->stop;
+
+=head1 DESCRIPTION
+
+This is glue API between the Perl tests and the Python authorization server
+daemon implemented in t/oauth_server.py. (Python has a fairly usable HTTP server
+in its standard library, so the implementation was ported from Perl.)
+
+This authorization server does not use TLS (it implements a nonstandard, unsafe
+issuer at "http://localhost:<port>"), so libpq in particular will need to set
+PGOAUTHDEBUG=UNSAFE to be able to talk to it.
+
+=cut
+
+package OAuth::Server;
+
+use warnings;
+use strict;
+use Scalar::Util;
+use Test::More;
+
+=pod
+
+=head1 METHODS
+
+=over
+
+=item OAuth::Server->new()
+
+Create a new OAuth Server object.
+
+=cut
+
+sub new
+{
+	my $class = shift;
+
+	my $self = {};
+	bless($self, $class);
+
+	return $self;
+}
+
+=pod
+
+=item $server->port()
+
+Returns the port in use by the server.
+
+=cut
+
+sub port
+{
+	my $self = shift;
+
+	return $self->{'port'};
+}
+
+=pod
+
+=item $server->run()
+
+Runs the authorization server daemon in t/oauth_server.py.
+
+=cut
+
+sub run
+{
+	my $self = shift;
+	my $port;
+
+	my $pid = open(my $read_fh, "-|", $ENV{PYTHON}, "t/oauth_server.py")
+	  or die "failed to start OAuth server: $!";
+
+	# Get the port number from the daemon. It closes stdout afterwards; that way
+	# we can slurp in the entire contents here rather than worrying about the
+	# number of bytes to read.
+	$port = do { local $/ = undef; <$read_fh> }
+	  // die "failed to read port number: $!";
+	chomp $port;
+	die "server did not advertise a valid port"
+	  unless Scalar::Util::looks_like_number($port);
+
+	$self->{'pid'} = $pid;
+	$self->{'port'} = $port;
+	$self->{'child'} = $read_fh;
+
+	note("OAuth provider (PID $pid) is listening on port $port\n");
+}
+
+=pod
+
+=item $server->stop()
+
+Sends SIGTERM to the authorization server and waits for it to exit.
+
+=cut
+
+sub stop
+{
+	my $self = shift;
+
+	note("Sending SIGTERM to OAuth provider PID: $self->{'pid'}\n");
+
+	kill(15, $self->{'pid'});
+	$self->{'pid'} = undef;
+
+	# Closing the popen() handle waits for the process to exit.
+	close($self->{'child'});
+	$self->{'child'} = undef;
+}
+
+=pod
+
+=back
+
+=cut
+
+1;
diff --git a/src/test/modules/oauth_validator/t/oauth_server.py b/src/test/modules/oauth_validator/t/oauth_server.py
new file mode 100755
index 00000000000..4faf3323d38
--- /dev/null
+++ b/src/test/modules/oauth_validator/t/oauth_server.py
@@ -0,0 +1,391 @@
+#! /usr/bin/env python3
+#
+# A mock OAuth authorization server, designed to be invoked from
+# OAuth/Server.pm. This listens on an ephemeral port number (printed to stdout
+# so that the Perl tests can contact it) and runs as a daemon until it is
+# signaled.
+#
+
+import base64
+import http.server
+import json
+import os
+import sys
+import time
+import urllib.parse
+from collections import defaultdict
+
+
+class OAuthHandler(http.server.BaseHTTPRequestHandler):
+    """
+    Core implementation of the authorization server. The API is
+    inheritance-based, with entry points at do_GET() and do_POST(). See the
+    documentation for BaseHTTPRequestHandler.
+    """
+
+    JsonObject = dict[str, object]  # TypeAlias is not available until 3.10
+
+    def _check_issuer(self):
+        """
+        Switches the behavior of the provider depending on the issuer URI.
+        """
+        self._alt_issuer = (
+            self.path.startswith("/alternate/")
+            or self.path == "/.well-known/oauth-authorization-server/alternate"
+        )
+        self._parameterized = self.path.startswith("/param/")
+
+        if self._alt_issuer:
+            # The /alternate issuer uses IETF-style .well-known URIs.
+            if self.path.startswith("/.well-known/"):
+                self.path = self.path.removesuffix("/alternate")
+            else:
+                self.path = self.path.removeprefix("/alternate")
+        elif self._parameterized:
+            self.path = self.path.removeprefix("/param")
+
+    def _check_authn(self):
+        """
+        Checks the expected value of the Authorization header, if any.
+        """
+        secret = self._get_param("expected_secret", None)
+        if secret is None:
+            return
+
+        assert "Authorization" in self.headers
+        method, creds = self.headers["Authorization"].split()
+
+        if method != "Basic":
+            raise RuntimeError(f"client used {method} auth; expected Basic")
+
+        username = urllib.parse.quote_plus(self.client_id)
+        password = urllib.parse.quote_plus(secret)
+        expected_creds = f"{username}:{password}"
+
+        if creds.encode() != base64.b64encode(expected_creds.encode()):
+            raise RuntimeError(
+                f"client sent '{creds}'; expected b64encode('{expected_creds}')"
+            )
+
+    def do_GET(self):
+        self._response_code = 200
+        self._check_issuer()
+
+        config_path = "/.well-known/openid-configuration"
+        if self._alt_issuer:
+            config_path = "/.well-known/oauth-authorization-server"
+
+        if self.path == config_path:
+            resp = self.config()
+        else:
+            self.send_error(404, "Not Found")
+            return
+
+        self._send_json(resp)
+
+    def _parse_params(self) -> dict[str, str]:
+        """
+        Parses apart the form-urlencoded request body and returns the resulting
+        dict. For use by do_POST().
+        """
+        size = int(self.headers["Content-Length"])
+        form = self.rfile.read(size)
+
+        assert self.headers["Content-Type"] == "application/x-www-form-urlencoded"
+        return urllib.parse.parse_qs(
+            form.decode("utf-8"),
+            strict_parsing=True,
+            keep_blank_values=True,
+            encoding="utf-8",
+            errors="strict",
+        )
+
+    @property
+    def client_id(self) -> str:
+        """
+        Returns the client_id sent in the POST body or the Authorization header.
+        self._parse_params() must have been called first.
+        """
+        if "client_id" in self._params:
+            return self._params["client_id"][0]
+
+        if "Authorization" not in self.headers:
+            raise RuntimeError("client did not send any client_id")
+
+        _, creds = self.headers["Authorization"].split()
+
+        decoded = base64.b64decode(creds).decode("utf-8")
+        username, _ = decoded.split(":", 1)
+
+        return urllib.parse.unquote_plus(username)
+
+    def do_POST(self):
+        self._response_code = 200
+        self._check_issuer()
+
+        self._params = self._parse_params()
+        if self._parameterized:
+            # Pull encoded test parameters out of the peer's client_id field.
+            # This is expected to be Base64-encoded JSON.
+            js = base64.b64decode(self.client_id)
+            self._test_params = json.loads(js)
+
+        self._check_authn()
+
+        if self.path == "/authorize":
+            resp = self.authorization()
+        elif self.path == "/token":
+            resp = self.token()
+        else:
+            self.send_error(404)
+            return
+
+        self._send_json(resp)
+
+    def _should_modify(self) -> bool:
+        """
+        Returns True if the client has requested a modification to this stage of
+        the exchange.
+        """
+        if not hasattr(self, "_test_params"):
+            return False
+
+        stage = self._test_params.get("stage")
+
+        return (
+            stage == "all"
+            or (
+                stage == "discovery"
+                and self.path == "/.well-known/openid-configuration"
+            )
+            or (stage == "device" and self.path == "/authorize")
+            or (stage == "token" and self.path == "/token")
+        )
+
+    def _get_param(self, name, default):
+        """
+        If the client has requested a modification to this stage (see
+        _should_modify()), this method searches the provided test parameters for
+        a key of the given name, and returns it if found. Otherwise the provided
+        default is returned.
+        """
+        if self._should_modify() and name in self._test_params:
+            return self._test_params[name]
+
+        return default
+
+    @property
+    def _content_type(self) -> str:
+        """
+        Returns "application/json" unless the test has requested something
+        different.
+        """
+        return self._get_param("content_type", "application/json")
+
+    @property
+    def _interval(self) -> int:
+        """
+        Returns 0 unless the test has requested something different.
+        """
+        return self._get_param("interval", 0)
+
+    @property
+    def _retry_code(self) -> str:
+        """
+        Returns "authorization_pending" unless the test has requested something
+        different.
+        """
+        return self._get_param("retry_code", "authorization_pending")
+
+    @property
+    def _uri_spelling(self) -> str:
+        """
+        Returns "verification_uri" unless the test has requested something
+        different.
+        """
+        return self._get_param("uri_spelling", "verification_uri")
+
+    @property
+    def _response_padding(self):
+        """
+        If the huge_response test parameter is set to True, returns a dict
+        containing a gigantic string value, which can then be folded into a JSON
+        response.
+        """
+        if not self._get_param("huge_response", False):
+            return dict()
+
+        return {"_pad_": "x" * 1024 * 1024}
+
+    @property
+    def _access_token(self):
+        """
+        The actual Bearer token sent back to the client on success. Tests may
+        override this with the "token" test parameter.
+        """
+        token = self._get_param("token", None)
+        if token is not None:
+            return token
+
+        token = "9243959234"
+        if self._alt_issuer:
+            token += "-alt"
+
+        return token
+
+    def _send_json(self, js: JsonObject) -> None:
+        """
+        Sends the provided JSON dict as an application/json response.
+        self._response_code can be modified to send JSON error responses.
+        """
+        resp = json.dumps(js).encode("ascii")
+        self.log_message("sending JSON response: %s", resp)
+
+        self.send_response(self._response_code)
+        self.send_header("Content-Type", self._content_type)
+        self.send_header("Content-Length", str(len(resp)))
+        self.end_headers()
+
+        self.wfile.write(resp)
+
+    def config(self) -> JsonObject:
+        port = self.server.socket.getsockname()[1]
+
+        issuer = f"http://localhost:{port}"
+        if self._alt_issuer:
+            issuer += "/alternate"
+        elif self._parameterized:
+            issuer += "/param"
+
+        return {
+            "issuer": issuer,
+            "token_endpoint": issuer + "/token",
+            "device_authorization_endpoint": issuer + "/authorize",
+            "response_types_supported": ["token"],
+            "subject_types_supported": ["public"],
+            "id_token_signing_alg_values_supported": ["RS256"],
+            "grant_types_supported": [
+                "authorization_code",
+                "urn:ietf:params:oauth:grant-type:device_code",
+            ],
+        }
+
+    @property
+    def _token_state(self):
+        """
+        A cached _TokenState object for the connected client (as determined by
+        the request's client_id), or a new one if it doesn't already exist.
+
+        This relies on the existence of a defaultdict attached to the server;
+        see main() below.
+        """
+        return self.server.token_state[self.client_id]
+
+    def _remove_token_state(self):
+        """
+        Removes any cached _TokenState for the current client_id. Call this
+        after the token exchange ends to get rid of unnecessary state.
+        """
+        if self.client_id in self.server.token_state:
+            del self.server.token_state[self.client_id]
+
+    def authorization(self) -> JsonObject:
+        uri = "https://example.com/"
+        if self._alt_issuer:
+            uri = "https://example.org/"
+
+        resp = {
+            "device_code": "postgres",
+            "user_code": "postgresuser",
+            self._uri_spelling: uri,
+            "expires_in": 5,
+            **self._response_padding,
+        }
+
+        interval = self._interval
+        if interval is not None:
+            resp["interval"] = interval
+            self._token_state.min_delay = interval
+        else:
+            self._token_state.min_delay = 5  # default
+
+        # Check the scope.
+        if "scope" in self._params:
+            assert self._params["scope"][0], "empty scopes should be omitted"
+
+        return resp
+
+    def token(self) -> JsonObject:
+        if err := self._get_param("error_code", None):
+            self._response_code = self._get_param("error_status", 400)
+
+            resp = {"error": err}
+            if desc := self._get_param("error_desc", ""):
+                resp["error_description"] = desc
+
+            return resp
+
+        if self._should_modify() and "retries" in self._test_params:
+            retries = self._test_params["retries"]
+
+            # Check to make sure the token interval is being respected.
+            now = time.monotonic()
+            if self._token_state.last_try is not None:
+                delay = now - self._token_state.last_try
+                assert (
+                    delay > self._token_state.min_delay
+                ), f"client waited only {delay} seconds between token requests (expected {self._token_state.min_delay})"
+
+            self._token_state.last_try = now
+
+            # If we haven't reached the required number of retries yet, return a
+            # "pending" response.
+            if self._token_state.retries < retries:
+                self._token_state.retries += 1
+
+                self._response_code = 400
+                return {"error": self._retry_code}
+
+        # Clean up any retry tracking state now that the exchange is ending.
+        self._remove_token_state()
+
+        return {
+            "access_token": self._access_token,
+            "token_type": "bearer",
+            **self._response_padding,
+        }
+
+
+def main():
+    """
+    Starts the authorization server on localhost. The ephemeral port in use will
+    be printed to stdout.
+    """
+
+    s = http.server.HTTPServer(("127.0.0.1", 0), OAuthHandler)
+
+    # Attach a "cache" dictionary to the server to allow the OAuthHandlers to
+    # track state across token requests. The use of defaultdict ensures that new
+    # entries will be created automatically.
+    class _TokenState:
+        retries = 0
+        min_delay = None
+        last_try = None
+
+    s.token_state = defaultdict(_TokenState)
+
+    # Give the parent the port number to contact (this is also the signal that
+    # we're ready to receive requests).
+    port = s.socket.getsockname()[1]
+    print(port)
+
+    # stdout is closed to allow the parent to just "read to the end".
+    stdout = sys.stdout.fileno()
+    sys.stdout.close()
+    os.close(stdout)
+
+    s.serve_forever()  # we expect our parent to send a termination signal
+
+
+if __name__ == "__main__":
+    main()
diff --git a/src/test/modules/oauth_validator/validator.c b/src/test/modules/oauth_validator/validator.c
new file mode 100644
index 00000000000..b2e5d182e1b
--- /dev/null
+++ b/src/test/modules/oauth_validator/validator.c
@@ -0,0 +1,143 @@
+/*-------------------------------------------------------------------------
+ *
+ * validator.c
+ *	  Test module for serverside OAuth token validation callbacks
+ *
+ * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/test/modules/oauth_validator/validator.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "fmgr.h"
+#include "libpq/oauth.h"
+#include "miscadmin.h"
+#include "utils/guc.h"
+#include "utils/memutils.h"
+
+PG_MODULE_MAGIC;
+
+static void validator_startup(ValidatorModuleState *state);
+static void validator_shutdown(ValidatorModuleState *state);
+static bool validate_token(const ValidatorModuleState *state,
+						   const char *token,
+						   const char *role,
+						   ValidatorModuleResult *result);
+
+/* Callback implementations (exercise all three) */
+static const OAuthValidatorCallbacks validator_callbacks = {
+	PG_OAUTH_VALIDATOR_MAGIC,
+
+	.startup_cb = validator_startup,
+	.shutdown_cb = validator_shutdown,
+	.validate_cb = validate_token
+};
+
+/* GUCs */
+static char *authn_id = NULL;
+static bool authorize_tokens = true;
+
+/*---
+ * Extension entry point. Sets up GUCs for use by tests:
+ *
+ * - oauth_validator.authn_id	Sets the user identifier to return during token
+ *								validation. Defaults to the username in the
+ *								startup packet.
+ *
+ * - oauth_validator.authorize_tokens
+ *								Sets whether to successfully validate incoming
+ *								tokens. Defaults to true.
+ */
+void
+_PG_init(void)
+{
+	DefineCustomStringVariable("oauth_validator.authn_id",
+							   "Authenticated identity to use for future connections",
+							   NULL,
+							   &authn_id,
+							   NULL,
+							   PGC_SIGHUP,
+							   0,
+							   NULL, NULL, NULL);
+	DefineCustomBoolVariable("oauth_validator.authorize_tokens",
+							 "Should tokens be marked valid?",
+							 NULL,
+							 &authorize_tokens,
+							 true,
+							 PGC_SIGHUP,
+							 0,
+							 NULL, NULL, NULL);
+
+	MarkGUCPrefixReserved("oauth_validator");
+}
+
+/*
+ * Validator module entry point.
+ */
+const OAuthValidatorCallbacks *
+_PG_oauth_validator_module_init(void)
+{
+	return &validator_callbacks;
+}
+
+#define PRIVATE_COOKIE ((void *) 13579)
+
+/*
+ * Startup callback, to set up private data for the validator.
+ */
+static void
+validator_startup(ValidatorModuleState *state)
+{
+	/*
+	 * Make sure the server is correctly setting sversion. (Real modules
+	 * should not do this; it would defeat upgrade compatibility.)
+	 */
+	if (state->sversion != PG_VERSION_NUM)
+		elog(ERROR, "oauth_validator: sversion set to %d", state->sversion);
+
+	state->private_data = PRIVATE_COOKIE;
+}
+
+/*
+ * Shutdown callback, to tear down the validator.
+ */
+static void
+validator_shutdown(ValidatorModuleState *state)
+{
+	/* Check to make sure our private state still exists. */
+	if (state->private_data != PRIVATE_COOKIE)
+		elog(PANIC, "oauth_validator: private state cookie changed to %p in shutdown",
+			 state->private_data);
+}
+
+/*
+ * Validator implementation. Logs the incoming data and authorizes the token by
+ * default; the behavior can be modified via the module's GUC settings.
+ */
+static bool
+validate_token(const ValidatorModuleState *state,
+			   const char *token, const char *role,
+			   ValidatorModuleResult *res)
+{
+	/* Check to make sure our private state still exists. */
+	if (state->private_data != PRIVATE_COOKIE)
+		elog(ERROR, "oauth_validator: private state cookie changed to %p in validate",
+			 state->private_data);
+
+	elog(LOG, "oauth_validator: token=\"%s\", role=\"%s\"", token, role);
+	elog(LOG, "oauth_validator: issuer=\"%s\", scope=\"%s\"",
+		 MyProcPort->hba->oauth_issuer,
+		 MyProcPort->hba->oauth_scope);
+
+	res->authorized = authorize_tokens;
+	if (authn_id)
+		res->authn_id = pstrdup(authn_id);
+	else
+		res->authn_id = pstrdup(role);
+
+	return true;
+}
diff --git a/src/test/perl/PostgreSQL/Test/Cluster.pm b/src/test/perl/PostgreSQL/Test/Cluster.pm
index f521ad0b12f..ab7d7452ede 100644
--- a/src/test/perl/PostgreSQL/Test/Cluster.pm
+++ b/src/test/perl/PostgreSQL/Test/Cluster.pm
@@ -2515,6 +2515,11 @@ instead of the default.
 
 If this regular expression is set, matches it with the output generated.
 
+=item expected_stderr => B<value>
+
+If this regular expression is set, matches it against the standard error
+stream; otherwise the stderr must be empty.
+
 =item log_like => [ qr/required message/ ]
 
 =item log_unlike => [ qr/prohibited message/ ]
@@ -2558,7 +2563,20 @@ sub connect_ok
 		like($stdout, $params{expected_stdout}, "$test_name: stdout matches");
 	}
 
-	is($stderr, "", "$test_name: no stderr");
+	if (defined($params{expected_stderr}))
+	{
+		if (like($stderr, $params{expected_stderr}, "$test_name: stderr matches")
+			&& ($ret != 0))
+		{
+			# In this case (failing test but matching stderr) we'll have
+			# swallowed the output needed to debug. Put it back into the logs.
+			diag("$test_name: full stderr:\n" . $stderr);
+		}
+	}
+	else
+	{
+		is($stderr, "", "$test_name: no stderr");
+	}
 
 	$self->log_check($test_name, $log_location, %params);
 }
diff --git a/src/tools/pgindent/pgindent b/src/tools/pgindent/pgindent
index d8acce7e929..7dccf4614aa 100755
--- a/src/tools/pgindent/pgindent
+++ b/src/tools/pgindent/pgindent
@@ -242,6 +242,14 @@ sub pre_indent
 	# Protect wrapping in CATALOG()
 	$source =~ s!^(CATALOG\(.*)$!/*$1*/!gm;
 
+	# Treat a CURL_IGNORE_DEPRECATION() as braces for the purposes of
+	# indentation. (The recursive regex comes from the perlre documentation; it
+	# matches balanced parentheses as group $1 and the contents as group $2.)
+	my $curlopen = '{ /* CURL_IGNORE_DEPRECATION */';
+	my $curlclose = '} /* CURL_IGNORE_DEPRECATION */';
+	$source =~
+	  s!^[ \t]+CURL_IGNORE_DEPRECATION(\(((?:(?>[^()]+)|(?1))*)\))!$curlopen$2$curlclose!gms;
+
 	return $source;
 }
 
@@ -256,6 +264,12 @@ sub post_indent
 	$source =~ s!^/\* Open extern "C" \*/$!{!gm;
 	$source =~ s!^/\* Close extern "C" \*/$!}!gm;
 
+	# Restore the CURL_IGNORE_DEPRECATION() macro, keeping in mind that our
+	# markers may have been re-indented.
+	$source =~
+	  s!{[ \t]+/\* CURL_IGNORE_DEPRECATION \*/!CURL_IGNORE_DEPRECATION(!gm;
+	$source =~ s!}[ \t]+/\* CURL_IGNORE_DEPRECATION \*/!)!gm;
+
 	## Comments
 
 	# Undo change of dash-protected block comments
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index bce4214503d..48f8184b061 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -371,6 +371,9 @@ CState
 CTECycleClause
 CTEMaterialize
 CTESearchClause
+CURL
+CURLM
+CURLoption
 CV
 CachedExpression
 CachedPlan
@@ -1724,6 +1727,7 @@ NumericDigit
 NumericSortSupport
 NumericSumAccum
 NumericVar
+OAuthValidatorCallbacks
 OM_uint32
 OP
 OSAPerGroupState
@@ -1832,6 +1836,7 @@ PGVerbosity
 PG_Locale_Strategy
 PG_Lock_Status
 PG_init_t
+PGauthData
 PGcancel
 PGcancelConn
 PGcmdQueueEntry
@@ -1839,7 +1844,9 @@ PGconn
 PGdataValue
 PGlobjfuncs
 PGnotify
+PGoauthBearerRequest
 PGpipelineStatus
+PGpromptOAuthDevice
 PGresAttDesc
 PGresAttValue
 PGresParamDesc
@@ -1952,6 +1959,7 @@ PQArgBlock
 PQEnvironmentOption
 PQExpBuffer
 PQExpBufferData
+PQauthDataHook_type
 PQcommMethods
 PQconninfoOption
 PQnoticeProcessor
@@ -3092,6 +3100,8 @@ VacuumRelation
 VacuumStmt
 ValidIOData
 ValidateIndexState
+ValidatorModuleState
+ValidatorModuleResult
 ValuesScan
 ValuesScanState
 Var
@@ -3489,6 +3499,7 @@ explain_get_index_name_hook_type
 f_smgr
 fasthash_state
 fd_set
+fe_oauth_state
 fe_scram_state
 fe_scram_state_enum
 fetch_range_request
-- 
2.39.3 (Apple Git-146)



  [application/octet-stream] v51-0002-cirrus-Temporarily-fix-libcurl-link-error.patch (1.3K, ../../[email protected]/3-v51-0002-cirrus-Temporarily-fix-libcurl-link-error.patch)
  download | inline diff:
From 9161ce3fb6e993686ec4cd4bccb0ca037f8a9316 Mon Sep 17 00:00:00 2001
From: Daniel Gustafsson <[email protected]>
Date: Mon, 17 Feb 2025 12:57:38 +0100
Subject: [PATCH v51 2/2] cirrus: Temporarily fix libcurl link error

On FreeBSD the ftp/curl port appears to be missing a minimum
version dependency on libssh2, so the following starts showing
up after upgrading to curl 8.11.1_1:

  libcurl.so.4: Undefined symbol "libssh2_session_callback_set2"

Awaiting an upgrade of the FreeBSD CI images to version 14, work
around the issue.

Author: Jacob Champion <[email protected]>
Reviewed-by: Daniel Gustafsson <[email protected]>
Discussion: https://postgr.es/m/CAOYmi+kZAka0sdxCOBxsQc2ozEZGZKHWU_9nrPXg3sG1NJ-zJw@mail.gmail.com
---
 .cirrus.tasks.yml | 1 +
 1 file changed, 1 insertion(+)

diff --git a/.cirrus.tasks.yml b/.cirrus.tasks.yml
index 2f5f5ef21a8..91b51142d2e 100644
--- a/.cirrus.tasks.yml
+++ b/.cirrus.tasks.yml
@@ -168,6 +168,7 @@ task:
     sysctl kern.corefile='/tmp/cores/%N.%P.core'
   setup_additional_packages_script: |
     pkg install -y curl
+    pkg upgrade -y libssh2 # XXX shouldn't be necessary. revisit w/ FreeBSD 14
 
   # NB: Intentionally build without -Dllvm. The freebsd image size is already
   # large enough to make VM startup slow, and even without llvm freebsd
-- 
2.39.3 (Apple Git-146)



^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-02-17 18:15  Jacob Champion <[email protected]>
  parent: Daniel Gustafsson <[email protected]>
  0 siblings, 1 reply; 101+ messages in thread

From: Jacob Champion @ 2025-02-17 18:15 UTC (permalink / raw)
  To: Daniel Gustafsson <[email protected]>; +Cc: Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

On Mon, Feb 17, 2025 at 4:03 AM Daniel Gustafsson <[email protected]> wrote:
> The attached v51 squashes your commits together, discarding the changes
> discussed here, and takes a stab at a commit message for these as this is
> getting very close to be able to go in.  There are no additional changes.

Awesome, thank you! It's been a little bit since I've re-run my
fuzzers, and a new Valgrind run would be a good idea, so I will just
keep throwing tests at it and review the new commit messages while
they run.

--Jacob






^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-02-17 23:51  Jacob Champion <[email protected]>
  parent: Jacob Champion <[email protected]>
  0 siblings, 1 reply; 101+ messages in thread

From: Jacob Champion @ 2025-02-17 23:51 UTC (permalink / raw)
  To: Daniel Gustafsson <[email protected]>; +Cc: Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

On Mon, Feb 17, 2025 at 10:15 AM Jacob Champion
<[email protected]> wrote:
> It's been a little bit since I've re-run my
> fuzzers, and a new Valgrind run would be a good idea, so I will just
> keep throwing tests at it

Fuzzers are happy so far.

Valgrind did find something! A mistake I made during parameter
discovery: setup_oauth_parameters() ensures that conn->oauth_issuer_id
is always set using the "issuer" connection option, but during the
second connection, I reassigned the pointer for it (and
conn->oauth_discovery_uri) and leaked the previous allocations.

v52-0002 fixes that. I've taken the opportunity to document that those
two parameters are designed to be unchangeable for the connection once
they've been assigned.

--

Reviews for the commit message:

> postgres cannot ship with one built-in.

s/postgres/Postgres/. Maybe a softening to "does not" ship with one?

> Each pg_hba entry can
> specify one, or more, validators or be left blank for the validator
> installed as default.

Each pg_hba entry can specify only one of the DBA-blessed validators, not more.

> This adds a requirement on libucurl

s/libucurl/libcurl/

And as discussed offlist, we should note that the builtin device flow
is not currently supported on Windows.

Thanks!
--Jacob


Attachments:

  [application/octet-stream] v52-0001-Add-support-for-OAUTHBEARER-SASL-mechanism.patch (324.0K, ../../CAOYmi+nP8AM9xm+xUW5kDz7XDF7MKBjuDnQ4LjMEm07tpwDgrg@mail.gmail.com/2-v52-0001-Add-support-for-OAUTHBEARER-SASL-mechanism.patch)
  download | inline diff:
From 24fa9c37b6c32b40981d5703b03e46b71d7f9300 Mon Sep 17 00:00:00 2001
From: Daniel Gustafsson <[email protected]>
Date: Mon, 17 Feb 2025 12:57:34 +0100
Subject: [PATCH v52 1/3] Add support for OAUTHBEARER SASL mechanism

This commit implements OAUTHBEARER, RFC 7628, and OAuth 2.0 Device
Authorization Grants, RFC 8628.  In order to use this there is a
new pg_hba auth method called oauth.  When speaking to a OAuth-
enabled server, it looks a bit like this:

  $ psql 'host=example.org oauth_issuer=... oauth_client_id=...'
  Visit https://oauth.example.org/login and enter the code: FPQ2-M4BG

Device authorization is currently the only supported flow so the
OAuth issuer must support that in order for users to authenticate.
Third-party clients may however extend this and provide their own
flows.

In order for validation to happen server side a new framework for
plugging in OAuth validation modules is added.  As validation is
implementation specific, with no default specified in the standard,
postgres cannot ship with one built-in.  Each pg_hba entry can
specify one, or more, validators or be left blank for the validator
installed as default.

This adds a requirement on libucurl for the client side support,
which is optional to build, but the server side has no additional
build requirements.  In order to run the tests, Python is required
as this adds a https server written in Python.  Tests are gated
behind PG_TEST_EXTRA as they open ports.

This patch has been a multi-year project with many contributors
involved on review and in-depth discussion: Michael Paquier,
Heikki Linnakangas, Zhihong Yu, Mahendrakar s, Andrey Chudnovsky
and Stephen Frost to name a few.  While Jacob Champion is the main
author there have been some levels of hacking by others. Daniel
Gustafsson contributed the validation module and various bits and
pieces; Thomas Munro wrote the client side support for kqueue.

Author: Jacob Champion <[email protected]>
Co-authored-by: Daniel Gustafsson <[email protected]>
Co-authored-by: Thomas Munro <[email protected]>
Reviewed-by: Daniel Gustafsson <[email protected]>
Reviewed-by: Peter Eisentraut <[email protected]>
Reviewed-by: Antonin Houska <[email protected]>
Reviewed-by: Kashif Zeeshan <[email protected]>
Discussion: https://postgr.es/m/[email protected]
---
 .cirrus.tasks.yml                             |   15 +-
 config/programs.m4                            |   65 +
 configure                                     |  332 ++
 configure.ac                                  |   41 +
 doc/src/sgml/client-auth.sgml                 |  252 ++
 doc/src/sgml/config.sgml                      |   26 +
 doc/src/sgml/filelist.sgml                    |    1 +
 doc/src/sgml/installation.sgml                |   27 +
 doc/src/sgml/libpq.sgml                       |  445 +++
 doc/src/sgml/oauth-validators.sgml            |  414 +++
 doc/src/sgml/postgres.sgml                    |    1 +
 doc/src/sgml/protocol.sgml                    |  133 +-
 doc/src/sgml/regress.sgml                     |   10 +
 meson.build                                   |  100 +
 meson_options.txt                             |    3 +
 src/Makefile.global.in                        |    1 +
 src/backend/libpq/Makefile                    |    1 +
 src/backend/libpq/auth-oauth.c                |  894 +++++
 src/backend/libpq/auth.c                      |   10 +-
 src/backend/libpq/hba.c                       |   64 +-
 src/backend/libpq/meson.build                 |    1 +
 src/backend/libpq/pg_hba.conf.sample          |    4 +-
 src/backend/utils/adt/hbafuncs.c              |   19 +
 src/backend/utils/misc/guc_tables.c           |   12 +
 src/backend/utils/misc/postgresql.conf.sample |    3 +
 src/include/common/oauth-common.h             |   19 +
 src/include/libpq/auth.h                      |    1 +
 src/include/libpq/hba.h                       |    7 +-
 src/include/libpq/oauth.h                     |  101 +
 src/include/pg_config.h.in                    |    9 +
 src/interfaces/libpq/Makefile                 |   11 +-
 src/interfaces/libpq/exports.txt              |    3 +
 src/interfaces/libpq/fe-auth-oauth-curl.c     | 2883 +++++++++++++++++
 src/interfaces/libpq/fe-auth-oauth.c          | 1153 +++++++
 src/interfaces/libpq/fe-auth-oauth.h          |   45 +
 src/interfaces/libpq/fe-auth.c                |   36 +-
 src/interfaces/libpq/fe-auth.h                |    3 +
 src/interfaces/libpq/fe-connect.c             |   48 +-
 src/interfaces/libpq/libpq-fe.h               |   85 +
 src/interfaces/libpq/libpq-int.h              |   13 +-
 src/interfaces/libpq/meson.build              |    5 +
 src/makefiles/meson.build                     |    1 +
 src/test/authentication/t/001_password.pl     |    8 +-
 src/test/modules/Makefile                     |    1 +
 src/test/modules/meson.build                  |    1 +
 src/test/modules/oauth_validator/.gitignore   |    4 +
 src/test/modules/oauth_validator/Makefile     |   40 +
 src/test/modules/oauth_validator/README       |   13 +
 .../modules/oauth_validator/fail_validator.c  |   47 +
 .../modules/oauth_validator/magic_validator.c |   48 +
 src/test/modules/oauth_validator/meson.build  |   85 +
 .../oauth_validator/oauth_hook_client.c       |  293 ++
 .../modules/oauth_validator/t/001_server.pl   |  594 ++++
 .../modules/oauth_validator/t/002_client.pl   |  154 +
 .../modules/oauth_validator/t/OAuth/Server.pm |  140 +
 .../modules/oauth_validator/t/oauth_server.py |  391 +++
 src/test/modules/oauth_validator/validator.c  |  143 +
 src/test/perl/PostgreSQL/Test/Cluster.pm      |   20 +-
 src/tools/pgindent/pgindent                   |   14 +
 src/tools/pgindent/typedefs.list              |   11 +
 60 files changed, 9265 insertions(+), 39 deletions(-)
 create mode 100644 doc/src/sgml/oauth-validators.sgml
 create mode 100644 src/backend/libpq/auth-oauth.c
 create mode 100644 src/include/common/oauth-common.h
 create mode 100644 src/include/libpq/oauth.h
 create mode 100644 src/interfaces/libpq/fe-auth-oauth-curl.c
 create mode 100644 src/interfaces/libpq/fe-auth-oauth.c
 create mode 100644 src/interfaces/libpq/fe-auth-oauth.h
 create mode 100644 src/test/modules/oauth_validator/.gitignore
 create mode 100644 src/test/modules/oauth_validator/Makefile
 create mode 100644 src/test/modules/oauth_validator/README
 create mode 100644 src/test/modules/oauth_validator/fail_validator.c
 create mode 100644 src/test/modules/oauth_validator/magic_validator.c
 create mode 100644 src/test/modules/oauth_validator/meson.build
 create mode 100644 src/test/modules/oauth_validator/oauth_hook_client.c
 create mode 100644 src/test/modules/oauth_validator/t/001_server.pl
 create mode 100644 src/test/modules/oauth_validator/t/002_client.pl
 create mode 100644 src/test/modules/oauth_validator/t/OAuth/Server.pm
 create mode 100755 src/test/modules/oauth_validator/t/oauth_server.py
 create mode 100644 src/test/modules/oauth_validator/validator.c

diff --git a/.cirrus.tasks.yml b/.cirrus.tasks.yml
index fffa438cec1..2f5f5ef21a8 100644
--- a/.cirrus.tasks.yml
+++ b/.cirrus.tasks.yml
@@ -23,7 +23,7 @@ env:
   MTEST_ARGS: --print-errorlogs --no-rebuild -C build
   PGCTLTIMEOUT: 120 # avoids spurious failures during parallel tests
   TEMP_CONFIG: ${CIRRUS_WORKING_DIR}/src/tools/ci/pg_ci_base.conf
-  PG_TEST_EXTRA: kerberos ldap ssl libpq_encryption load_balance
+  PG_TEST_EXTRA: kerberos ldap ssl libpq_encryption load_balance oauth
 
 
 # What files to preserve in case tests fail
@@ -167,7 +167,7 @@ task:
     chown root:postgres /tmp/cores
     sysctl kern.corefile='/tmp/cores/%N.%P.core'
   setup_additional_packages_script: |
-    #pkg install -y ...
+    pkg install -y curl
 
   # NB: Intentionally build without -Dllvm. The freebsd image size is already
   # large enough to make VM startup slow, and even without llvm freebsd
@@ -329,6 +329,7 @@ LINUX_CONFIGURE_FEATURES: &LINUX_CONFIGURE_FEATURES >-
   --with-gssapi
   --with-icu
   --with-ldap
+  --with-libcurl
   --with-libxml
   --with-libxslt
   --with-llvm
@@ -422,8 +423,10 @@ task:
     EOF
 
   setup_additional_packages_script: |
-    #apt-get update
-    #DEBIAN_FRONTEND=noninteractive apt-get -y install ...
+    apt-get update
+    DEBIAN_FRONTEND=noninteractive apt-get -y install \
+      libcurl4-openssl-dev \
+      libcurl4-openssl-dev:i386 \
 
   matrix:
     - name: Linux - Debian Bookworm - Autoconf
@@ -799,8 +802,8 @@ task:
     folder: $CCACHE_DIR
 
   setup_additional_packages_script: |
-    #apt-get update
-    #DEBIAN_FRONTEND=noninteractive apt-get -y install ...
+    apt-get update
+    DEBIAN_FRONTEND=noninteractive apt-get -y install libcurl4-openssl-dev
 
   ###
   # Test that code can be built with gcc/clang without warnings
diff --git a/config/programs.m4 b/config/programs.m4
index 7b55c2664a6..061b13376ac 100644
--- a/config/programs.m4
+++ b/config/programs.m4
@@ -274,3 +274,68 @@ AC_DEFUN([PGAC_CHECK_STRIP],
   AC_SUBST(STRIP_STATIC_LIB)
   AC_SUBST(STRIP_SHARED_LIB)
 ])# PGAC_CHECK_STRIP
+
+
+
+# PGAC_CHECK_LIBCURL
+# ------------------
+# Check for required libraries and headers, and test to see whether the current
+# installation of libcurl is thread-safe.
+
+AC_DEFUN([PGAC_CHECK_LIBCURL],
+[
+  AC_CHECK_HEADER(curl/curl.h, [],
+				  [AC_MSG_ERROR([header file <curl/curl.h> is required for --with-libcurl])])
+  AC_CHECK_LIB(curl, curl_multi_init, [],
+			   [AC_MSG_ERROR([library 'curl' does not provide curl_multi_init])])
+
+  # Check to see whether the current platform supports threadsafe Curl
+  # initialization.
+  AC_CACHE_CHECK([for curl_global_init thread safety], [pgac_cv__libcurl_threadsafe_init],
+  [AC_RUN_IFELSE([AC_LANG_PROGRAM([
+#include <curl/curl.h>
+],[
+    curl_version_info_data *info;
+
+    if (curl_global_init(CURL_GLOBAL_ALL))
+        return -1;
+
+    info = curl_version_info(CURLVERSION_NOW);
+#ifdef CURL_VERSION_THREADSAFE
+    if (info->features & CURL_VERSION_THREADSAFE)
+        return 0;
+#endif
+
+    return 1;
+])],
+  [pgac_cv__libcurl_threadsafe_init=yes],
+  [pgac_cv__libcurl_threadsafe_init=no],
+  [pgac_cv__libcurl_threadsafe_init=unknown])])
+  if test x"$pgac_cv__libcurl_threadsafe_init" = xyes ; then
+    AC_DEFINE(HAVE_THREADSAFE_CURL_GLOBAL_INIT, 1,
+              [Define to 1 if curl_global_init() is guaranteed to be thread-safe.])
+  fi
+
+  # Warn if a thread-friendly DNS resolver isn't built.
+  AC_CACHE_CHECK([for curl support for asynchronous DNS], [pgac_cv__libcurl_async_dns],
+  [AC_RUN_IFELSE([AC_LANG_PROGRAM([
+#include <curl/curl.h>
+],[
+    curl_version_info_data *info;
+
+    if (curl_global_init(CURL_GLOBAL_ALL))
+        return -1;
+
+    info = curl_version_info(CURLVERSION_NOW);
+    return (info->features & CURL_VERSION_ASYNCHDNS) ? 0 : 1;
+])],
+  [pgac_cv__libcurl_async_dns=yes],
+  [pgac_cv__libcurl_async_dns=no],
+  [pgac_cv__libcurl_async_dns=unknown])])
+  if test x"$pgac_cv__libcurl_async_dns" != xyes ; then
+    AC_MSG_WARN([
+*** The installed version of libcurl does not support asynchronous DNS
+*** lookups. Connection timeouts will not be honored during DNS resolution,
+*** which may lead to hangs in client programs.])
+  fi
+])# PGAC_CHECK_LIBCURL
diff --git a/configure b/configure
index 0ffcaeb4367..93fddd69981 100755
--- a/configure
+++ b/configure
@@ -708,6 +708,9 @@ XML2_LIBS
 XML2_CFLAGS
 XML2_CONFIG
 with_libxml
+LIBCURL_LIBS
+LIBCURL_CFLAGS
+with_libcurl
 with_uuid
 with_readline
 with_systemd
@@ -864,6 +867,7 @@ with_readline
 with_libedit_preferred
 with_uuid
 with_ossp_uuid
+with_libcurl
 with_libxml
 with_libxslt
 with_system_tzdata
@@ -894,6 +898,8 @@ PKG_CONFIG_PATH
 PKG_CONFIG_LIBDIR
 ICU_CFLAGS
 ICU_LIBS
+LIBCURL_CFLAGS
+LIBCURL_LIBS
 XML2_CONFIG
 XML2_CFLAGS
 XML2_LIBS
@@ -1574,6 +1580,7 @@ Optional Packages:
                           prefer BSD Libedit over GNU Readline
   --with-uuid=LIB         build contrib/uuid-ossp using LIB (bsd,e2fs,ossp)
   --with-ossp-uuid        obsolete spelling of --with-uuid=ossp
+  --with-libcurl          build with libcurl support
   --with-libxml           build with XML support
   --with-libxslt          use XSLT support when building contrib/xml2
   --with-system-tzdata=DIR
@@ -1607,6 +1614,10 @@ Some influential environment variables:
               path overriding pkg-config's built-in search path
   ICU_CFLAGS  C compiler flags for ICU, overriding pkg-config
   ICU_LIBS    linker flags for ICU, overriding pkg-config
+  LIBCURL_CFLAGS
+              C compiler flags for LIBCURL, overriding pkg-config
+  LIBCURL_LIBS
+              linker flags for LIBCURL, overriding pkg-config
   XML2_CONFIG path to xml2-config utility
   XML2_CFLAGS C compiler flags for XML2, overriding pkg-config
   XML2_LIBS   linker flags for XML2, overriding pkg-config
@@ -8762,6 +8773,157 @@ fi
 
 
 
+#
+# libcurl
+#
+{ $as_echo "$as_me:${as_lineno-$LINENO}: checking whether to build with libcurl support" >&5
+$as_echo_n "checking whether to build with libcurl support... " >&6; }
+
+
+
+# Check whether --with-libcurl was given.
+if test "${with_libcurl+set}" = set; then :
+  withval=$with_libcurl;
+  case $withval in
+    yes)
+
+$as_echo "#define USE_LIBCURL 1" >>confdefs.h
+
+      ;;
+    no)
+      :
+      ;;
+    *)
+      as_fn_error $? "no argument expected for --with-libcurl option" "$LINENO" 5
+      ;;
+  esac
+
+else
+  with_libcurl=no
+
+fi
+
+
+{ $as_echo "$as_me:${as_lineno-$LINENO}: result: $with_libcurl" >&5
+$as_echo "$with_libcurl" >&6; }
+
+
+if test "$with_libcurl" = yes ; then
+  # Check for libcurl 7.61.0 or higher (corresponding to RHEL8 and the ability
+  # to explicitly set TLS 1.3 ciphersuites).
+
+pkg_failed=no
+{ $as_echo "$as_me:${as_lineno-$LINENO}: checking for libcurl >= 7.61.0" >&5
+$as_echo_n "checking for libcurl >= 7.61.0... " >&6; }
+
+if test -n "$LIBCURL_CFLAGS"; then
+    pkg_cv_LIBCURL_CFLAGS="$LIBCURL_CFLAGS"
+ elif test -n "$PKG_CONFIG"; then
+    if test -n "$PKG_CONFIG" && \
+    { { $as_echo "$as_me:${as_lineno-$LINENO}: \$PKG_CONFIG --exists --print-errors \"libcurl >= 7.61.0\""; } >&5
+  ($PKG_CONFIG --exists --print-errors "libcurl >= 7.61.0") 2>&5
+  ac_status=$?
+  $as_echo "$as_me:${as_lineno-$LINENO}: \$? = $ac_status" >&5
+  test $ac_status = 0; }; then
+  pkg_cv_LIBCURL_CFLAGS=`$PKG_CONFIG --cflags "libcurl >= 7.61.0" 2>/dev/null`
+		      test "x$?" != "x0" && pkg_failed=yes
+else
+  pkg_failed=yes
+fi
+ else
+    pkg_failed=untried
+fi
+if test -n "$LIBCURL_LIBS"; then
+    pkg_cv_LIBCURL_LIBS="$LIBCURL_LIBS"
+ elif test -n "$PKG_CONFIG"; then
+    if test -n "$PKG_CONFIG" && \
+    { { $as_echo "$as_me:${as_lineno-$LINENO}: \$PKG_CONFIG --exists --print-errors \"libcurl >= 7.61.0\""; } >&5
+  ($PKG_CONFIG --exists --print-errors "libcurl >= 7.61.0") 2>&5
+  ac_status=$?
+  $as_echo "$as_me:${as_lineno-$LINENO}: \$? = $ac_status" >&5
+  test $ac_status = 0; }; then
+  pkg_cv_LIBCURL_LIBS=`$PKG_CONFIG --libs "libcurl >= 7.61.0" 2>/dev/null`
+		      test "x$?" != "x0" && pkg_failed=yes
+else
+  pkg_failed=yes
+fi
+ else
+    pkg_failed=untried
+fi
+
+
+
+if test $pkg_failed = yes; then
+        { $as_echo "$as_me:${as_lineno-$LINENO}: result: no" >&5
+$as_echo "no" >&6; }
+
+if $PKG_CONFIG --atleast-pkgconfig-version 0.20; then
+        _pkg_short_errors_supported=yes
+else
+        _pkg_short_errors_supported=no
+fi
+        if test $_pkg_short_errors_supported = yes; then
+	        LIBCURL_PKG_ERRORS=`$PKG_CONFIG --short-errors --print-errors --cflags --libs "libcurl >= 7.61.0" 2>&1`
+        else
+	        LIBCURL_PKG_ERRORS=`$PKG_CONFIG --print-errors --cflags --libs "libcurl >= 7.61.0" 2>&1`
+        fi
+	# Put the nasty error message in config.log where it belongs
+	echo "$LIBCURL_PKG_ERRORS" >&5
+
+	as_fn_error $? "Package requirements (libcurl >= 7.61.0) were not met:
+
+$LIBCURL_PKG_ERRORS
+
+Consider adjusting the PKG_CONFIG_PATH environment variable if you
+installed software in a non-standard prefix.
+
+Alternatively, you may set the environment variables LIBCURL_CFLAGS
+and LIBCURL_LIBS to avoid the need to call pkg-config.
+See the pkg-config man page for more details." "$LINENO" 5
+elif test $pkg_failed = untried; then
+        { $as_echo "$as_me:${as_lineno-$LINENO}: result: no" >&5
+$as_echo "no" >&6; }
+	{ { $as_echo "$as_me:${as_lineno-$LINENO}: error: in \`$ac_pwd':" >&5
+$as_echo "$as_me: error: in \`$ac_pwd':" >&2;}
+as_fn_error $? "The pkg-config script could not be found or is too old.  Make sure it
+is in your PATH or set the PKG_CONFIG environment variable to the full
+path to pkg-config.
+
+Alternatively, you may set the environment variables LIBCURL_CFLAGS
+and LIBCURL_LIBS to avoid the need to call pkg-config.
+See the pkg-config man page for more details.
+
+To get pkg-config, see <http://pkg-config.freedesktop.org/>.
+See \`config.log' for more details" "$LINENO" 5; }
+else
+	LIBCURL_CFLAGS=$pkg_cv_LIBCURL_CFLAGS
+	LIBCURL_LIBS=$pkg_cv_LIBCURL_LIBS
+        { $as_echo "$as_me:${as_lineno-$LINENO}: result: yes" >&5
+$as_echo "yes" >&6; }
+
+fi
+
+  # We only care about -I, -D, and -L switches;
+  # note that -lcurl will be added by PGAC_CHECK_LIBCURL below.
+  for pgac_option in $LIBCURL_CFLAGS; do
+    case $pgac_option in
+      -I*|-D*) CPPFLAGS="$CPPFLAGS $pgac_option";;
+    esac
+  done
+  for pgac_option in $LIBCURL_LIBS; do
+    case $pgac_option in
+      -L*) LDFLAGS="$LDFLAGS $pgac_option";;
+    esac
+  done
+
+  # OAuth requires python for testing
+  if test "$with_python" != yes; then
+    { $as_echo "$as_me:${as_lineno-$LINENO}: WARNING: *** OAuth support tests require --with-python to run" >&5
+$as_echo "$as_me: WARNING: *** OAuth support tests require --with-python to run" >&2;}
+  fi
+fi
+
+
 #
 # XML
 #
@@ -12216,6 +12378,176 @@ fi
 
 fi
 
+# XXX libcurl must link after libgssapi_krb5 on FreeBSD to avoid segfaults
+# during gss_acquire_cred(). This is possibly related to Curl's Heimdal
+# dependency on that platform?
+if test "$with_libcurl" = yes ; then
+
+  ac_fn_c_check_header_mongrel "$LINENO" "curl/curl.h" "ac_cv_header_curl_curl_h" "$ac_includes_default"
+if test "x$ac_cv_header_curl_curl_h" = xyes; then :
+
+else
+  as_fn_error $? "header file <curl/curl.h> is required for --with-libcurl" "$LINENO" 5
+fi
+
+
+  { $as_echo "$as_me:${as_lineno-$LINENO}: checking for curl_multi_init in -lcurl" >&5
+$as_echo_n "checking for curl_multi_init in -lcurl... " >&6; }
+if ${ac_cv_lib_curl_curl_multi_init+:} false; then :
+  $as_echo_n "(cached) " >&6
+else
+  ac_check_lib_save_LIBS=$LIBS
+LIBS="-lcurl  $LIBS"
+cat confdefs.h - <<_ACEOF >conftest.$ac_ext
+/* end confdefs.h.  */
+
+/* Override any GCC internal prototype to avoid an error.
+   Use char because int might match the return type of a GCC
+   builtin and then its argument prototype would still apply.  */
+#ifdef __cplusplus
+extern "C"
+#endif
+char curl_multi_init ();
+int
+main ()
+{
+return curl_multi_init ();
+  ;
+  return 0;
+}
+_ACEOF
+if ac_fn_c_try_link "$LINENO"; then :
+  ac_cv_lib_curl_curl_multi_init=yes
+else
+  ac_cv_lib_curl_curl_multi_init=no
+fi
+rm -f core conftest.err conftest.$ac_objext \
+    conftest$ac_exeext conftest.$ac_ext
+LIBS=$ac_check_lib_save_LIBS
+fi
+{ $as_echo "$as_me:${as_lineno-$LINENO}: result: $ac_cv_lib_curl_curl_multi_init" >&5
+$as_echo "$ac_cv_lib_curl_curl_multi_init" >&6; }
+if test "x$ac_cv_lib_curl_curl_multi_init" = xyes; then :
+  cat >>confdefs.h <<_ACEOF
+#define HAVE_LIBCURL 1
+_ACEOF
+
+  LIBS="-lcurl $LIBS"
+
+else
+  as_fn_error $? "library 'curl' does not provide curl_multi_init" "$LINENO" 5
+fi
+
+
+  # Check to see whether the current platform supports threadsafe Curl
+  # initialization.
+  { $as_echo "$as_me:${as_lineno-$LINENO}: checking for curl_global_init thread safety" >&5
+$as_echo_n "checking for curl_global_init thread safety... " >&6; }
+if ${pgac_cv__libcurl_threadsafe_init+:} false; then :
+  $as_echo_n "(cached) " >&6
+else
+  if test "$cross_compiling" = yes; then :
+  pgac_cv__libcurl_threadsafe_init=unknown
+else
+  cat confdefs.h - <<_ACEOF >conftest.$ac_ext
+/* end confdefs.h.  */
+
+#include <curl/curl.h>
+
+int
+main ()
+{
+
+    curl_version_info_data *info;
+
+    if (curl_global_init(CURL_GLOBAL_ALL))
+        return -1;
+
+    info = curl_version_info(CURLVERSION_NOW);
+#ifdef CURL_VERSION_THREADSAFE
+    if (info->features & CURL_VERSION_THREADSAFE)
+        return 0;
+#endif
+
+    return 1;
+
+  ;
+  return 0;
+}
+_ACEOF
+if ac_fn_c_try_run "$LINENO"; then :
+  pgac_cv__libcurl_threadsafe_init=yes
+else
+  pgac_cv__libcurl_threadsafe_init=no
+fi
+rm -f core *.core core.conftest.* gmon.out bb.out conftest$ac_exeext \
+  conftest.$ac_objext conftest.beam conftest.$ac_ext
+fi
+
+fi
+{ $as_echo "$as_me:${as_lineno-$LINENO}: result: $pgac_cv__libcurl_threadsafe_init" >&5
+$as_echo "$pgac_cv__libcurl_threadsafe_init" >&6; }
+  if test x"$pgac_cv__libcurl_threadsafe_init" = xyes ; then
+
+$as_echo "#define HAVE_THREADSAFE_CURL_GLOBAL_INIT 1" >>confdefs.h
+
+  fi
+
+  # Warn if a thread-friendly DNS resolver isn't built.
+  { $as_echo "$as_me:${as_lineno-$LINENO}: checking for curl support for asynchronous DNS" >&5
+$as_echo_n "checking for curl support for asynchronous DNS... " >&6; }
+if ${pgac_cv__libcurl_async_dns+:} false; then :
+  $as_echo_n "(cached) " >&6
+else
+  if test "$cross_compiling" = yes; then :
+  pgac_cv__libcurl_async_dns=unknown
+else
+  cat confdefs.h - <<_ACEOF >conftest.$ac_ext
+/* end confdefs.h.  */
+
+#include <curl/curl.h>
+
+int
+main ()
+{
+
+    curl_version_info_data *info;
+
+    if (curl_global_init(CURL_GLOBAL_ALL))
+        return -1;
+
+    info = curl_version_info(CURLVERSION_NOW);
+    return (info->features & CURL_VERSION_ASYNCHDNS) ? 0 : 1;
+
+  ;
+  return 0;
+}
+_ACEOF
+if ac_fn_c_try_run "$LINENO"; then :
+  pgac_cv__libcurl_async_dns=yes
+else
+  pgac_cv__libcurl_async_dns=no
+fi
+rm -f core *.core core.conftest.* gmon.out bb.out conftest$ac_exeext \
+  conftest.$ac_objext conftest.beam conftest.$ac_ext
+fi
+
+fi
+{ $as_echo "$as_me:${as_lineno-$LINENO}: result: $pgac_cv__libcurl_async_dns" >&5
+$as_echo "$pgac_cv__libcurl_async_dns" >&6; }
+  if test x"$pgac_cv__libcurl_async_dns" != xyes ; then
+    { $as_echo "$as_me:${as_lineno-$LINENO}: WARNING:
+*** The installed version of libcurl does not support asynchronous DNS
+*** lookups. Connection timeouts will not be honored during DNS resolution,
+*** which may lead to hangs in client programs." >&5
+$as_echo "$as_me: WARNING:
+*** The installed version of libcurl does not support asynchronous DNS
+*** lookups. Connection timeouts will not be honored during DNS resolution,
+*** which may lead to hangs in client programs." >&2;}
+  fi
+
+fi
+
 if test "$with_gssapi" = yes ; then
   if test "$PORTNAME" != "win32"; then
     { $as_echo "$as_me:${as_lineno-$LINENO}: checking for library containing gss_store_cred_into" >&5
diff --git a/configure.ac b/configure.ac
index f56681e0d91..b6d02f5ecc7 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1007,6 +1007,40 @@ fi
 AC_SUBST(with_uuid)
 
 
+#
+# libcurl
+#
+AC_MSG_CHECKING([whether to build with libcurl support])
+PGAC_ARG_BOOL(with, libcurl, no, [build with libcurl support],
+              [AC_DEFINE([USE_LIBCURL], 1, [Define to 1 to build with libcurl support. (--with-libcurl)])])
+AC_MSG_RESULT([$with_libcurl])
+AC_SUBST(with_libcurl)
+
+if test "$with_libcurl" = yes ; then
+  # Check for libcurl 7.61.0 or higher (corresponding to RHEL8 and the ability
+  # to explicitly set TLS 1.3 ciphersuites).
+  PKG_CHECK_MODULES(LIBCURL, [libcurl >= 7.61.0])
+
+  # We only care about -I, -D, and -L switches;
+  # note that -lcurl will be added by PGAC_CHECK_LIBCURL below.
+  for pgac_option in $LIBCURL_CFLAGS; do
+    case $pgac_option in
+      -I*|-D*) CPPFLAGS="$CPPFLAGS $pgac_option";;
+    esac
+  done
+  for pgac_option in $LIBCURL_LIBS; do
+    case $pgac_option in
+      -L*) LDFLAGS="$LDFLAGS $pgac_option";;
+    esac
+  done
+
+  # OAuth requires python for testing
+  if test "$with_python" != yes; then
+    AC_MSG_WARN([*** OAuth support tests require --with-python to run])
+  fi
+fi
+
+
 #
 # XML
 #
@@ -1294,6 +1328,13 @@ failure.  It is possible the compiler isn't looking in the proper directory.
 Use --without-zlib to disable zlib support.])])
 fi
 
+# XXX libcurl must link after libgssapi_krb5 on FreeBSD to avoid segfaults
+# during gss_acquire_cred(). This is possibly related to Curl's Heimdal
+# dependency on that platform?
+if test "$with_libcurl" = yes ; then
+  PGAC_CHECK_LIBCURL
+fi
+
 if test "$with_gssapi" = yes ; then
   if test "$PORTNAME" != "win32"; then
     AC_SEARCH_LIBS(gss_store_cred_into, [gssapi_krb5 gss 'gssapi -lkrb5 -lcrypto'], [],
diff --git a/doc/src/sgml/client-auth.sgml b/doc/src/sgml/client-auth.sgml
index 782b49c85ac..832b616a7bb 100644
--- a/doc/src/sgml/client-auth.sgml
+++ b/doc/src/sgml/client-auth.sgml
@@ -656,6 +656,16 @@ include_dir         <replaceable>directory</replaceable>
          </para>
         </listitem>
        </varlistentry>
+
+       <varlistentry>
+        <term><literal>oauth</literal></term>
+        <listitem>
+         <para>
+          Authorize and optionally authenticate using a third-party OAuth 2.0
+          identity provider. See <xref linkend="auth-oauth"/> for details.
+         </para>
+        </listitem>
+       </varlistentry>
       </variablelist>
 
       </para>
@@ -1143,6 +1153,12 @@ omicron         bryanh                  guest1
       only on OpenBSD).
      </para>
     </listitem>
+    <listitem>
+     <para>
+      <link linkend="auth-oauth">OAuth authorization/authentication</link>,
+      which relies on an external OAuth 2.0 identity provider.
+     </para>
+    </listitem>
    </itemizedlist>
   </para>
 
@@ -2329,6 +2345,242 @@ host ... radius radiusservers="server1,server2" radiussecrets="""secret one"",""
    </note>
   </sect1>
 
+  <sect1 id="auth-oauth">
+   <title>OAuth Authorization/Authentication</title>
+
+   <indexterm zone="auth-oauth">
+    <primary>OAuth Authorization/Authentication</primary>
+   </indexterm>
+
+   <para>
+    OAuth 2.0 is an industry-standard framework, defined in
+    <ulink url="https://datatracker.ietf.org/doc/html/rfc6749">RFC 6749</ulink>,
+    to enable third-party applications to obtain limited access to a protected
+    resource.
+
+    OAuth client support has to be enabled when <productname>PostgreSQL</productname>
+    is built, see <xref linkend="installation"/> for more information.
+   </para>
+
+   <para>
+    This documentation uses the following terminology when discussing the OAuth
+    ecosystem:
+
+    <variablelist>
+
+     <varlistentry>
+      <term>Resource Owner (or End User)</term>
+      <listitem>
+       <para>
+        The user or system who owns protected resources and can grant access to
+        them. This documentation also uses the term <emphasis>end user</emphasis>
+        when the resource owner is a person. When you use
+        <application>psql</application> to connect to the database using OAuth,
+        you are the resource owner/end user.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term>Client</term>
+      <listitem>
+       <para>
+        The system which accesses the protected resources using access
+        tokens. Applications using libpq, such as <application>psql</application>,
+        are the OAuth clients when connecting to a
+        <productname>PostgreSQL</productname> cluster.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term>Resource Server</term>
+      <listitem>
+       <para>
+        The system hosting the protected resources which are
+        accessed by the client. The <productname>PostgreSQL</productname>
+        cluster being connected to is the resource server.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term>Provider</term>
+      <listitem>
+       <para>
+        The organization, product vendor, or other entity which develops and/or
+        administers the OAuth authorization servers and clients for a given application.
+        Different providers typically choose different implementation details
+        for their OAuth systems; a client of one provider is not generally
+        guaranteed to have access to the servers of another.
+       </para>
+       <para>
+        This use of the term "provider" is not standard, but it seems to be in
+        wide use colloquially. (It should not be confused with OpenID's similar
+        term "Identity Provider". While the implementation of OAuth in
+        <productname>PostgreSQL</productname> is intended to be interoperable
+        and compatible with OpenID Connect/OIDC, it is not itself an OIDC client
+        and does not require its use.)
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term>Authorization Server</term>
+      <listitem>
+       <para>
+        The system which receives requests from, and issues access tokens to,
+        the client after the authenticated resource owner has given approval.
+        <productname>PostgreSQL</productname> does not provide an authorization
+        server; it is the responsibility of the OAuth provider.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term id="auth-oauth-issuer">Issuer</term>
+      <listitem>
+       <para>
+        An identifier for an authorization server, printed as an
+        <literal>https://</literal> URL, which provides a trusted "namespace"
+        for OAuth clients and applications. The issuer identifier allows a
+        single authorization server to talk to the clients of mutually
+        untrusting entities, as long as they maintain separate issuers.
+       </para>
+      </listitem>
+     </varlistentry>
+
+    </variablelist>
+
+    <note>
+     <para>
+      For small deployments, there may not be a meaningful distinction between
+      the "provider", "authorization server", and "issuer". However, for more
+      complicated setups, there may be a one-to-many (or many-to-many)
+      relationship: a provider may rent out multiple issuer identifiers to
+      separate tenants, then provide multiple authorization servers, possibly
+      with different supported feature sets, to interact with their clients.
+     </para>
+    </note>
+   </para>
+
+   <para>
+    <productname>PostgreSQL</productname> supports bearer tokens, defined in
+    <ulink url="https://datatracker.ietf.org/doc/html/rfc6750">RFC 6750</ulink>,
+    which are a type of access token used with OAuth 2.0 where the token is an
+    opaque string.  The format of the access token is implementation specific
+    and is chosen by each authorization server.
+   </para>
+
+   <para>
+    The following configuration options are supported for OAuth:
+    <variablelist>
+     <varlistentry>
+      <term><literal>issuer</literal></term>
+      <listitem>
+       <para>
+        An HTTPS URL which is either the exact
+        <link linkend="auth-oauth-issuer">issuer identifier</link> of the
+        authorization server, as defined by its discovery document, or a
+        well-known URI that points directly to that discovery document. This
+        parameter is required.
+       </para>
+       <para>
+        When an OAuth client connects to the server, a URL for the discovery
+        document will be constructed using the issuer identifier. By default,
+        this URL uses the conventions of OpenID Connect Discovery: the path
+        <literal>/.well-known/openid-configuration</literal> will be appended
+        to the end of the issuer identifier. Alternatively, if the
+        <literal>issuer</literal> contains a <literal>/.well-known/</literal>
+        path segment, that URL will be provided to the client as-is.
+       </para>
+       <warning>
+        <para>
+         The OAuth client in libpq requires the server's issuer setting to
+         exactly match the issuer identifier which is provided in the discovery
+         document, which must in turn match the client's
+         <xref linkend="libpq-connect-oauth-issuer"/> setting. No variations in
+         case or formatting are permitted.
+        </para>
+       </warning>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term><literal>scope</literal></term>
+      <listitem>
+       <para>
+        A space-separated list of the OAuth scopes needed for the server to
+        both authorize the client and authenticate the user.  Appropriate values
+        are determined by the authorization server and the OAuth validation
+        module used (see <xref linkend="oauth-validators" /> for more
+        information on validators).  This parameter is required.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term><literal>validator</literal></term>
+      <listitem>
+       <para>
+        The library to use for validating bearer tokens. If given, the name must
+        exactly match one of the libraries listed in
+        <xref linkend="guc-oauth-validator-libraries" />.  This parameter is
+        optional unless <literal>oauth_validator_libraries</literal> contains
+        more than one library, in which case it is required.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term><literal>map</literal></term>
+      <listitem>
+       <para>
+        Allows for mapping between OAuth identity provider and database user
+        names.  See <xref linkend="auth-username-maps"/> for details.  If a
+        map is not specified, the user name associated with the token (as
+        determined by the OAuth validator) must exactly match the role name
+        being requested.  This parameter is optional.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term id="auth-oauth-delegate-ident-mapping" xreflabel="delegate_ident_mapping">
+       <literal>delegate_ident_mapping</literal>
+      </term>
+      <listitem>
+       <para>
+        An advanced option which is not intended for common use.
+       </para>
+       <para>
+        When set to <literal>1</literal>, standard user mapping with
+        <filename>pg_ident.conf</filename> is skipped, and the OAuth validator
+        takes full responsibility for mapping end user identities to database
+        roles.  If the validator authorizes the token, the server trusts that
+        the user is allowed to connect under the requested role, and the
+        connection is allowed to proceed regardless of the authentication
+        status of the user.
+       </para>
+       <para>
+        This parameter is incompatible with <literal>map</literal>.
+       </para>
+       <warning>
+        <para>
+         <literal>delegate_ident_mapping</literal> provides additional
+         flexibility in the design of the authentication system, but it also
+         requires careful implementation of the OAuth validator, which must
+         determine whether the provided token carries sufficient end-user
+         privileges in addition to the <link linkend="oauth-validators">standard
+         checks</link> required of all validators.  Use with caution.
+        </para>
+       </warning>
+      </listitem>
+     </varlistentry>
+    </variablelist>
+   </para>
+  </sect1>
+
   <sect1 id="client-authentication-problems">
    <title>Authentication Problems</title>
 
diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml
index 336630ce417..c4dfa8ba039 100644
--- a/doc/src/sgml/config.sgml
+++ b/doc/src/sgml/config.sgml
@@ -1209,6 +1209,32 @@ include_dir 'conf.d'
        </para>
       </listitem>
      </varlistentry>
+
+     <varlistentry id="guc-oauth-validator-libraries" xreflabel="oauth_validator_libraries">
+      <term><varname>oauth_validator_libraries</varname> (<type>string</type>)
+      <indexterm>
+       <primary><varname>oauth_validator_libraries</varname> configuration parameter</primary>
+      </indexterm>
+      </term>
+      <listitem>
+       <para>
+        The library/libraries to use for validating OAuth connection tokens. If
+        only one validator library is provided, it will be used by default for
+        any OAuth connections; otherwise, all
+        <link linkend="auth-oauth"><literal>oauth</literal> HBA entries</link>
+        must explicitly set a <literal>validator</literal> chosen from this
+        list. If set to an empty string (the default), OAuth connections will be
+        refused. This parameter can only be set in the
+        <filename>postgresql.conf</filename> file.
+       </para>
+       <para>
+        Validator modules must be implemented/obtained separately;
+        <productname>PostgreSQL</productname> does not ship with any default
+        implementations. For more information on implementing OAuth validators,
+        see <xref linkend="oauth-validators" />.
+       </para>
+      </listitem>
+     </varlistentry>
      </variablelist>
      </sect2>
 
diff --git a/doc/src/sgml/filelist.sgml b/doc/src/sgml/filelist.sgml
index 66e6dccd4c9..25fb99cee69 100644
--- a/doc/src/sgml/filelist.sgml
+++ b/doc/src/sgml/filelist.sgml
@@ -111,6 +111,7 @@
 <!ENTITY generic-wal SYSTEM "generic-wal.sgml">
 <!ENTITY custom-rmgr SYSTEM "custom-rmgr.sgml">
 <!ENTITY backup-manifest SYSTEM "backup-manifest.sgml">
+<!ENTITY oauth-validators SYSTEM "oauth-validators.sgml">
 
 <!-- contrib information -->
 <!ENTITY contrib         SYSTEM "contrib.sgml">
diff --git a/doc/src/sgml/installation.sgml b/doc/src/sgml/installation.sgml
index 3f0a7e9c069..3c95c15a1e4 100644
--- a/doc/src/sgml/installation.sgml
+++ b/doc/src/sgml/installation.sgml
@@ -1143,6 +1143,19 @@ build-postgresql:
        </listitem>
       </varlistentry>
 
+      <varlistentry id="configure-option-with-libcurl">
+       <term><option>--with-libcurl</option></term>
+       <listitem>
+        <para>
+         Build with libcurl support for OAuth 2.0 client flows.
+         Libcurl version 7.61.0 or later is required for this feature.
+         Building with this will check for the required header files
+         and libraries to make sure that your <productname>curl</productname>
+         installation is sufficient before proceeding.
+        </para>
+       </listitem>
+      </varlistentry>
+
       <varlistentry id="configure-option-with-libxml">
        <term><option>--with-libxml</option></term>
        <listitem>
@@ -2584,6 +2597,20 @@ ninja install
       </listitem>
      </varlistentry>
 
+     <varlistentry id="configure-with-libcurl-meson">
+      <term><option>-Dlibcurl={ auto | enabled | disabled }</option></term>
+      <listitem>
+       <para>
+        Build with libcurl support for OAuth 2.0 client flows.
+        Libcurl version 7.61.0 or later is required for this feature.
+        Building with this will check for the required header files
+        and libraries to make sure that your <productname>Curl</productname>
+        installation is sufficient before proceeding. The default for this
+        option is auto.
+       </para>
+      </listitem>
+     </varlistentry>
+
      <varlistentry id="configure-with-libxml-meson">
       <term><option>-Dlibxml={ auto | enabled | disabled }</option></term>
       <listitem>
diff --git a/doc/src/sgml/libpq.sgml b/doc/src/sgml/libpq.sgml
index c49e975b082..ddb3596df83 100644
--- a/doc/src/sgml/libpq.sgml
+++ b/doc/src/sgml/libpq.sgml
@@ -1385,6 +1385,15 @@ postgresql://%2Fvar%2Flib%2Fpostgresql/dbname
           </listitem>
          </varlistentry>
 
+         <varlistentry>
+          <term><literal>oauth</literal></term>
+          <listitem>
+           <para>
+            The server must request an OAuth bearer token from the client.
+           </para>
+          </listitem>
+         </varlistentry>
+
          <varlistentry>
           <term><literal>none</literal></term>
           <listitem>
@@ -2373,6 +2382,107 @@ postgresql://%2Fvar%2Flib%2Fpostgresql/dbname
        </para>
       </listitem>
      </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-issuer" xreflabel="oauth_issuer">
+      <term><literal>oauth_issuer</literal></term>
+      <listitem>
+       <para>
+        The HTTPS URL of a trusted issuer to contact if the server requests an
+        OAuth token for the connection. This parameter is required for all OAuth
+        connections; it should exactly match the <literal>issuer</literal>
+        setting in <link linkend="auth-oauth">the server's HBA configuration</link>.
+       </para>
+       <para>
+        As part of the standard authentication handshake, <application>libpq</application>
+        will ask the server for a <emphasis>discovery document:</emphasis> a URL
+        providing a set of OAuth configuration parameters. The server must
+        provide a URL that is directly constructed from the components of the
+        <literal>oauth_issuer</literal>, and this value must exactly match the
+        issuer identifier that is declared in the discovery document itself, or
+        the connection will fail. This is required to prevent a class of
+        <ulink url="https://mailarchive.ietf.org/arch/msg/oauth/JIVxFBGsJBVtm7ljwJhPUm3Fr-w/">
+        "mix-up attacks"</ulink> on OAuth clients.
+       </para>
+       <para>
+        You may also explicitly set <literal>oauth_issuer</literal> to the
+        <literal>/.well-known/</literal> URI used for OAuth discovery. In this
+        case, if the server asks for a different URL, the connection will fail,
+        but a <link linkend="libpq-oauth-authdata-hooks">custom OAuth flow</link>
+        may be able to speed up the standard handshake by using previously
+        cached tokens. (In this case, it is recommended that
+        <xref linkend="libpq-connect-oauth-scope"/> be set as well, since the
+        client will not have a chance to ask the server for a correct scope
+        setting, and the default scopes for a token may not be sufficient to
+        connect.) <application>libpq</application> currently supports the
+        following well-known endpoints:
+        <itemizedlist spacing="compact">
+         <listitem><para><literal>/.well-known/openid-configuration</literal></para></listitem>
+         <listitem><para><literal>/.well-known/oauth-authorization-server</literal></para></listitem>
+        </itemizedlist>
+       </para>
+       <warning>
+        <para>
+         Issuers are highly privileged during the OAuth connection handshake. As
+         a rule of thumb, if you would not trust the operator of a URL to handle
+         access to your servers, or to impersonate you directly, that URL should
+         not be trusted as an <literal>oauth_issuer</literal>.
+        </para>
+       </warning>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-client-id" xreflabel="oauth_client_id">
+      <term><literal>oauth_client_id</literal></term>
+      <listitem>
+       <para>
+        An OAuth 2.0 client identifier, as issued by the authorization server.
+        If the <productname>PostgreSQL</productname> server
+        <link linkend="auth-oauth">requests an OAuth token</link> for the
+        connection (and if no <link linkend="libpq-oauth-authdata-hooks">custom
+        OAuth hook</link> is installed to provide one), then this parameter must
+        be set; otherwise, the connection will fail.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-client-secret" xreflabel="oauth_client_secret">
+      <term><literal>oauth_client_secret</literal></term>
+      <listitem>
+       <para>
+        The client password, if any, to use when contacting the OAuth
+        authorization server. Whether this parameter is required or not is
+        determined by the OAuth provider; "public" clients generally do not use
+        a secret, whereas "confidential" clients generally do.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-scope" xreflabel="oauth_scope">
+      <term><literal>oauth_scope</literal></term>
+      <listitem>
+       <para>
+        The scope of the access request sent to the authorization server,
+        specified as a (possibly empty) space-separated list of OAuth scope
+        identifiers. This parameter is optional and intended for advanced usage.
+       </para>
+       <para>
+        Usually the client will obtain appropriate scope settings from the
+        <productname>PostgreSQL</productname> server. If this parameter is used,
+        the server's requested scope list will be ignored. This can prevent a
+        less-trusted server from requesting inappropriate access scopes from the
+        end user. However, if the client's scope setting does not contain the
+        server's required scopes, the server is likely to reject the issued
+        token, and the connection will fail.
+       </para>
+       <para>
+        The meaning of an empty scope list is provider-dependent. An OAuth
+        authorization server may choose to issue a token with "default scope",
+        whatever that happens to be, or it may reject the token request
+        entirely.
+       </para>
+      </listitem>
+     </varlistentry>
+
     </variablelist>
    </para>
   </sect2>
@@ -10020,6 +10130,329 @@ void PQinitSSL(int do_ssl);
 
  </sect1>
 
+ <sect1 id="libpq-oauth">
+  <title>OAuth Support</title>
+
+  <para>
+   libpq implements support for the OAuth v2 Device Authorization client flow,
+   documented in
+   <ulink url="https://datatracker.ietf.org/doc/html/rfc8628">RFC 8628</ulink>,
+   which it will attempt to use by default if the server
+   <link linkend="auth-oauth">requests a bearer token</link> during
+   authentication. This flow can be utilized even if the system running the
+   client application does not have a usable web browser, for example when
+   running a client via <application>SSH</application>. Client applications may implement their own flows
+   instead; see <xref linkend="libpq-oauth-authdata-hooks"/>.
+  </para>
+  <para>
+   The builtin flow will, by default, print a URL to visit and a user code to
+   enter there:
+<programlisting>
+$ psql 'dbname=postgres oauth_issuer=https://example.com oauth_client_id=...'
+Visit https://example.com/device and enter the code: ABCD-EFGH
+</programlisting>
+   (This prompt may be
+   <link linkend="libpq-oauth-authdata-prompt-oauth-device">customized</link>.)
+   The user will then log into their OAuth provider, which will ask whether
+   to allow libpq and the server to perform actions on their behalf. It is always
+   a good idea to carefully review the URL and permissions displayed, to ensure
+   they match expectations, before continuing. Permissions should not be given
+   to untrusted third parties.
+  </para>
+  <para>
+   For an OAuth client flow to be usable, the connection string must at minimum
+   contain <xref linkend="libpq-connect-oauth-issuer"/> and
+   <xref linkend="libpq-connect-oauth-client-id"/>. (These settings are
+   determined by your organization's OAuth provider.) The builtin flow
+   additionally requires the OAuth authorization server to publish a device
+   authorization endpoint.
+  </para>
+
+  <note>
+   <para>
+    The builtin Device Authorization flow is not currently supported on Windows.
+    Custom client flows may still be implemented.
+   </para>
+  </note>
+
+  <sect2 id="libpq-oauth-authdata-hooks">
+   <title>Authdata Hooks</title>
+
+   <para>
+    The behavior of the OAuth flow may be modified or replaced by a client using
+    the following hook API:
+
+    <variablelist>
+     <varlistentry id="libpq-PQsetAuthDataHook">
+      <term><function>PQsetAuthDataHook</function><indexterm><primary>PQsetAuthDataHook</primary></indexterm></term>
+
+      <listitem>
+       <para>
+        Sets the <symbol>PGauthDataHook</symbol>, overriding
+        <application>libpq</application>'s handling of one or more aspects of
+        its OAuth client flow.
+<synopsis>
+void PQsetAuthDataHook(PQauthDataHook_type hook);
+</synopsis>
+        If <replaceable>hook</replaceable> is <literal>NULL</literal>, the
+        default handler will be reinstalled. Otherwise, the application passes
+        a pointer to a callback function with the signature:
+<programlisting>
+int hook_fn(PGauthData type, PGconn *conn, void *data);
+</programlisting>
+        which <application>libpq</application> will call when an action is
+        required of the application. <replaceable>type</replaceable> describes
+        the request being made, <replaceable>conn</replaceable> is the
+        connection handle being authenticated, and <replaceable>data</replaceable>
+        points to request-specific metadata. The contents of this pointer are
+        determined by <replaceable>type</replaceable>; see
+        <xref linkend="libpq-oauth-authdata-hooks-types"/> for the supported
+        list.
+       </para>
+       <para>
+        Hooks can be chained together to allow cooperative and/or fallback
+        behavior. In general, a hook implementation should examine the incoming
+        <replaceable>type</replaceable> (and, potentially, the request metadata
+        and/or the settings for the particular <replaceable>conn</replaceable>
+        in use) to decide whether or not to handle a specific piece of authdata.
+        If not, it should delegate to the previous hook in the chain
+        (retrievable via <function>PQgetAuthDataHook</function>).
+       </para>
+       <para>
+        Success is indicated by returning an integer greater than zero.
+        Returning a negative integer signals an error condition and abandons the
+        connection attempt. (A zero value is reserved for the default
+        implementation.)
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="libpq-PQgetAuthDataHook">
+      <term><function>PQgetAuthDataHook</function><indexterm><primary>PQgetAuthDataHook</primary></indexterm></term>
+
+      <listitem>
+       <para>
+        Retrieves the current value of <symbol>PGauthDataHook</symbol>.
+<synopsis>
+PQauthDataHook_type PQgetAuthDataHook(void);
+</synopsis>
+        At initialization time (before the first call to
+        <function>PQsetAuthDataHook</function>), this function will return
+        <symbol>PQdefaultAuthDataHook</symbol>.
+       </para>
+      </listitem>
+     </varlistentry>
+    </variablelist>
+   </para>
+
+   <sect3 id="libpq-oauth-authdata-hooks-types">
+    <title>Hook Types</title>
+    <para>
+     The following <symbol>PGauthData</symbol> types and their corresponding
+     <replaceable>data</replaceable> structures are defined:
+
+     <variablelist>
+      <varlistentry id="libpq-oauth-authdata-prompt-oauth-device">
+       <term>
+        <symbol>PQAUTHDATA_PROMPT_OAUTH_DEVICE</symbol>
+        <indexterm><primary>PQAUTHDATA_PROMPT_OAUTH_DEVICE</primary></indexterm>
+       </term>
+       <listitem>
+        <para>
+         Replaces the default user prompt during the builtin device
+         authorization client flow. <replaceable>data</replaceable> points to
+         an instance of <symbol>PGpromptOAuthDevice</symbol>:
+<synopsis>
+typedef struct _PGpromptOAuthDevice
+{
+    const char *verification_uri;   /* verification URI to visit */
+    const char *user_code;          /* user code to enter */
+    const char *verification_uri_complete;  /* optional combination of URI and
+                                             * code, or NULL */
+    int         expires_in;         /* seconds until user code expires */
+} PGpromptOAuthDevice;
+</synopsis>
+        </para>
+        <para>
+         The OAuth Device Authorization flow included in <application>libpq</application>
+         requires the end user to visit a URL with a browser, then enter a code
+         which permits <application>libpq</application> to connect to the server
+         on their behalf. The default prompt simply prints the
+         <literal>verification_uri</literal> and <literal>user_code</literal>
+         on standard error. Replacement implementations may display this
+         information using any preferred method, for example with a GUI.
+        </para>
+        <para>
+         This callback is only invoked during the builtin device
+         authorization flow. If the application installs a
+         <link linkend="libpq-oauth-authdata-oauth-bearer-token">custom OAuth
+         flow</link>, this authdata type will not be used.
+        </para>
+        <para>
+         If a non-NULL <structfield>verification_uri_complete</structfield> is
+         provided, it may optionally be used for non-textual verification (for
+         example, by displaying a QR code). The URL and user code should still
+         be displayed to the end user in this case, because the code will be
+         manually confirmed by the provider, and the URL lets users continue
+         even if they can't use the non-textual method. For more information,
+         see section 3.3.1 in
+         <ulink url="https://datatracker.ietf.org/doc/html/rfc8628#section-3.3.1">RFC 8628</ulink>.
+        </para>
+       </listitem>
+      </varlistentry>
+
+      <varlistentry id="libpq-oauth-authdata-oauth-bearer-token">
+       <term>
+        <symbol>PQAUTHDATA_OAUTH_BEARER_TOKEN</symbol>
+        <indexterm><primary>PQAUTHDATA_OAUTH_BEARER_TOKEN</primary></indexterm>
+       </term>
+       <listitem>
+        <para>
+         Replaces the entire OAuth flow with a custom implementation. The hook
+         should either directly return a Bearer token for the current
+         user/issuer/scope combination, if one is available without blocking, or
+         else set up an asynchronous callback to retrieve one.
+        </para>
+        <para>
+         <replaceable>data</replaceable> points to an instance
+         of <symbol>PGoauthBearerRequest</symbol>, which should be filled in
+         by the implementation:
+<synopsis>
+typedef struct _PGoauthBearerRequest
+{
+    /* Hook inputs (constant across all calls) */
+    const char *const openid_configuration; /* OIDC discovery URL */
+    const char *const scope;                /* required scope(s), or NULL */
+
+    /* Hook outputs */
+
+    /* Callback implementing a custom asynchronous OAuth flow. */
+    PostgresPollingStatusType (*async) (PGconn *conn,
+                                        struct _PGoauthBearerRequest *request,
+                                        SOCKTYPE *altsock);
+
+    /* Callback to clean up custom allocations. */
+    void        (*cleanup) (PGconn *conn, struct _PGoauthBearerRequest *request);
+
+    char       *token;   /* acquired Bearer token */
+    void       *user;    /* hook-defined allocated data */
+} PGoauthBearerRequest;
+</synopsis>
+        </para>
+        <para>
+         Two pieces of information are provided to the hook by
+         <application>libpq</application>:
+         <replaceable>openid_configuration</replaceable> contains the URL of an
+         OAuth discovery document describing the authorization server's
+         supported flows, and <replaceable>scope</replaceable> contains a
+         (possibly empty) space-separated list of OAuth scopes which are
+         required to access the server. Either or both may be
+         <literal>NULL</literal> to indicate that the information was not
+         discoverable. (In this case, implementations may be able to establish
+         the requirements using some other preconfigured knowledge, or they may
+         choose to fail.)
+        </para>
+        <para>
+         The final output of the hook is <replaceable>token</replaceable>, which
+         must point to a valid Bearer token for use on the connection. (This
+         token should be issued by the
+         <xref linkend="libpq-connect-oauth-issuer"/> and hold the requested
+         scopes, or the connection will be rejected by the server's validator
+         module.) The allocated token string must remain valid until
+         <application>libpq</application> is finished connecting; the hook
+         should set a <replaceable>cleanup</replaceable> callback which will be
+         called when <application>libpq</application> no longer requires it.
+        </para>
+        <para>
+         If an implementation cannot immediately produce a
+         <replaceable>token</replaceable> during the initial call to the hook,
+         it should set the <replaceable>async</replaceable> callback to handle
+         nonblocking communication with the authorization server.
+         <footnote>
+          <para>
+           Performing blocking operations during the
+           <symbol>PQAUTHDATA_OAUTH_BEARER_TOKEN</symbol> hook callback will
+           interfere with nonblocking connection APIs such as
+           <function>PQconnectPoll</function> and prevent concurrent connections
+           from making progress. Applications which only ever use the
+           synchronous connection primitives, such as
+           <function>PQconnectdb</function>, may synchronously retrieve a token
+           during the hook instead of implementing the
+           <replaceable>async</replaceable> callback, but they will necessarily
+           be limited to one connection at a time.
+          </para>
+         </footnote>
+         This will be called to begin the flow immediately upon return from the
+         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
+         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>
+         after setting <replaceable>token</replaceable> when the flow is
+         complete, or <symbol>PGRES_POLLING_FAILED</symbol> to indicate failure.
+        </para>
+        <para>
+         Implementations may wish to store additional data for bookkeeping
+         across calls to the <replaceable>async</replaceable> and
+         <replaceable>cleanup</replaceable> callbacks. The
+         <replaceable>user</replaceable> pointer is provided for this purpose;
+         <application>libpq</application> will not touch its contents and the
+         application may use it at its convenience. (Remember to free any
+         allocations during token cleanup.)
+        </para>
+       </listitem>
+      </varlistentry>
+     </variablelist>
+    </para>
+   </sect3>
+  </sect2>
+
+  <sect2 id="libpq-oauth-debugging">
+   <title>Debugging and Developer Settings</title>
+
+   <para>
+    A "dangerous debugging mode" may be enabled by setting the environment
+    variable <envar>PGOAUTHDEBUG=UNSAFE</envar>. This functionality is provided
+    for ease of local development and testing only. It does several things that
+    you will not want a production system to do:
+
+    <itemizedlist spacing="compact">
+     <listitem>
+      <para>
+       permits the use of unencrypted HTTP during the OAuth provider exchange
+      </para>
+     </listitem>
+     <listitem>
+      <para>
+       allows the system's trusted CA list to be completely replaced using the
+       <envar>PGOAUTHCAFILE</envar> environment variable
+      </para>
+     </listitem>
+     <listitem>
+      <para>
+       prints HTTP traffic (containing several critical secrets) to standard
+       error during the OAuth flow
+      </para>
+     </listitem>
+     <listitem>
+      <para>
+       permits the use of zero-second retry intervals, which can cause the
+       client to busy-loop and pointlessly consume CPU
+      </para>
+     </listitem>
+    </itemizedlist>
+   </para>
+   <warning>
+    <para>
+     Do not share the output of the OAuth flow traffic with third parties. It
+     contains secrets that can be used to attack your clients and servers.
+    </para>
+   </warning>
+  </sect2>
+ </sect1>
+
 
  <sect1 id="libpq-threading">
   <title>Behavior in Threaded Programs</title>
@@ -10092,6 +10525,18 @@ int PQisthreadsafe();
    <application>libpq</application> source code for a way to do cooperative
    locking between <application>libpq</application> and your application.
   </para>
+
+  <para>
+   Similarly, if you are using <productname>Curl</productname> inside your application,
+   <emphasis>and</emphasis> you do not already
+   <ulink url="https://curl.se/libcurl/c/curl_global_init.html">initialize
+   libcurl globally</ulink> before starting new threads, you will need to
+   cooperatively lock (again via <function>PQregisterThreadLock</function>)
+   around any code that may initialize libcurl. This restriction is lifted for
+   more recent versions of <productname>Curl</productname> that are built to support thread-safe
+   initialization; those builds can be identified by the advertisement of a
+   <literal>threadsafe</literal> feature in their version metadata.
+  </para>
  </sect1>
 
 
diff --git a/doc/src/sgml/oauth-validators.sgml b/doc/src/sgml/oauth-validators.sgml
new file mode 100644
index 00000000000..356f11d3bd8
--- /dev/null
+++ b/doc/src/sgml/oauth-validators.sgml
@@ -0,0 +1,414 @@
+<!-- doc/src/sgml/oauth-validators.sgml -->
+
+<chapter id="oauth-validators">
+ <title>OAuth Validator Modules</title>
+ <indexterm zone="oauth-validators">
+  <primary>OAuth Validators</primary>
+ </indexterm>
+ <para>
+  <productname>PostgreSQL</productname> provides infrastructure for creating
+  custom modules to perform server-side validation of OAuth bearer tokens.
+  Because OAuth implementations vary so wildly, and bearer token validation is
+  heavily dependent on the issuing party, the server cannot check the token
+  itself; validator modules provide the integration layer between the server
+  and the OAuth provider in use.
+ </para>
+ <para>
+  OAuth validator modules must at least consist of an initialization function
+  (see <xref linkend="oauth-validator-init"/>) and the required callback for
+  performing validation (see <xref linkend="oauth-validator-callback-validate"/>).
+ </para>
+ <warning>
+  <para>
+   Since a misbehaving validator might let unauthorized users into the database,
+   correct implementation is crucial for server safety. See
+   <xref linkend="oauth-validator-design"/> for design considerations.
+  </para>
+ </warning>
+
+ <sect1 id="oauth-validator-design">
+  <title>Safely Designing a Validator Module</title>
+  <warning>
+   <para>
+    Read and understand the entirety of this section before implementing a
+    validator module. A malfunctioning validator is potentially worse than no
+    authentication at all, both because of the false sense of security it
+    provides, and because it may contribute to attacks against other pieces of
+    an OAuth ecosystem.
+   </para>
+  </warning>
+
+  <sect2 id="oauth-validator-design-responsibilities">
+   <title>Validator Responsibilities</title>
+   <para>
+    Although different modules may take very different approaches to token
+    validation, implementations generally need to perform three separate
+    actions:
+   </para>
+   <variablelist>
+    <varlistentry>
+     <term>Validate the Token</term>
+     <listitem>
+      <para>
+       The validator must first ensure that the presented token is in fact a
+       valid Bearer token for use in client authentication. The correct way to
+       do this depends on the provider, but it generally involves either
+       cryptographic operations to prove that the token was created by a trusted
+       party (offline validation), or the presentation of the token to that
+       trusted party so that it can perform validation for you (online
+       validation).
+      </para>
+      <para>
+       Online validation, usually implemented via
+       <ulink url="https://datatracker.ietf.org/doc/html/rfc7662">OAuth Token
+       Introspection</ulink>, requires fewer steps of a validator module and
+       allows central revocation of a token in the event that it is stolen
+       or misissued. However, it does require the module to make at least one
+       network call per authentication attempt (all of which must complete
+       within the configured <xref linkend="guc-authentication-timeout"/>).
+       Additionally, your provider may not provide introspection endpoints for
+       use by external resource servers.
+      </para>
+      <para>
+       Offline validation is much more involved, typically requiring a validator
+       to maintain a list of trusted signing keys for a provider and then
+       check the token's cryptographic signature along with its contents.
+       Implementations must follow the provider's instructions to the letter,
+       including any verification of issuer ("where is this token from?"),
+       audience ("who is this token for?"), and validity period ("when can this
+       token be used?"). Since there is no communication between the module and
+       the provider, tokens cannot be centrally revoked using this method;
+       offline validator implementations may wish to place restrictions on the
+       maximum length of a token's validity period.
+      </para>
+      <para>
+       If the token cannot be validated, the module should immediately fail.
+       Further authentication/authorization is pointless if the bearer token
+       wasn't issued by a trusted party.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Authorize the Client</term>
+     <listitem>
+      <para>
+       Next the validator must ensure that the end user has given the client
+       permission to access the server on their behalf. This generally involves
+       checking the scopes that have been assigned to the token, to make sure
+       that they cover database access for the current HBA parameters.
+      </para>
+      <para>
+       The purpose of this step is to prevent an OAuth client from obtaining a
+       token under false pretenses. If the validator requires all tokens to
+       carry scopes that cover database access, the provider should then loudly
+       prompt the user to grant that access during the flow. This gives them the
+       opportunity to reject the request if the client isn't supposed to be
+       using their credentials to connect to databases.
+      </para>
+      <para>
+       While it is possible to establish client authorization without explicit
+       scopes by using out-of-band knowledge of the deployed architecture, doing
+       so removes the user from the loop, which prevents them from catching
+       deployment mistakes and allows any such mistakes to be exploited
+       silently. Access to the database must be tightly restricted to only
+       trusted clients
+       <footnote>
+        <para>
+         That is, "trusted" in the sense that the OAuth client and the
+         <productname>PostgreSQL</productname> server are controlled by the same
+         entity. Notably, the Device Authorization client flow supported by
+         libpq does not usually meet this bar, since it's designed for use by
+         public/untrusted clients.
+        </para>
+       </footnote>
+       if users are not prompted for additional scopes.
+      </para>
+      <para>
+       Even if authorization fails, a module may choose to continue to pull
+       authentication information from the token for use in auditing and
+       debugging.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Authenticate the End User</term>
+     <listitem>
+      <para>
+       Finally, the validator should determine a user identifier for the token,
+       either by asking the provider for this information or by extracting it
+       from the token itself, and return that identifier to the server (which
+       will then make a final authorization decision using the HBA
+       configuration). This identifier will be available within the session via
+       <link linkend="functions-info-session-table"><function>system_user</function></link>
+       and recorded in the server logs if <xref linkend="guc-log-connections"/>
+       is enabled.
+      </para>
+      <para>
+       Different providers may record a variety of different authentication
+       information for an end user, typically referred to as
+       <emphasis>claims</emphasis>. Providers usually document which of these
+       claims are trustworthy enough to use for authorization decisions and
+       which are not. (For instance, it would probably not be wise to use an
+       end user's full name as the identifier for authentication, since many
+       providers allow users to change their display names arbitrarily.)
+       Ultimately, the choice of which claim (or combination of claims) to use
+       comes down to the provider implementation and application requirements.
+      </para>
+      <para>
+       Note that anonymous/pseudonymous login is possible as well, by enabling
+       usermap delegation; see
+       <xref linkend="oauth-validator-design-usermap-delegation"/>.
+      </para>
+     </listitem>
+    </varlistentry>
+   </variablelist>
+  </sect2>
+
+  <sect2 id="oauth-validator-design-guidelines">
+   <title>General Coding Guidelines</title>
+   <para>
+    Developers should keep the following in mind when implementing token
+    validation:
+   </para>
+   <variablelist>
+    <varlistentry>
+     <term>Token Confidentiality</term>
+     <listitem>
+      <para>
+       Modules should not write tokens, or pieces of tokens, into the server
+       log. This is true even if the module considers the token invalid; an
+       attacker who confuses a client into communicating with the wrong provider
+       should not be able to retrieve that (otherwise valid) token from the
+       disk.
+      </para>
+      <para>
+       Implementations that send tokens over the network (for example, to
+       perform online token validation with a provider) must authenticate the
+       peer and ensure that strong transport security is in use.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Logging</term>
+     <listitem>
+      <para>
+       Modules may use the same <link linkend="error-message-reporting">logging
+       facilities</link> as standard extensions; however, the rules for emitting
+       log entries to the client are subtly different during the authentication
+       phase of the connection. Generally speaking, modules should log
+       verification problems at the <symbol>COMMERROR</symbol> level and return
+       normally, instead of using <symbol>ERROR</symbol>/<symbol>FATAL</symbol>
+       to unwind the stack, to avoid leaking information to unauthenticated
+       clients.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Interruptibility</term>
+     <listitem>
+      <para>
+       Modules must remain interruptible by signals so that the server can
+       correctly handle authentication timeouts and shutdown signals from
+       <application>pg_ctl</application>. For example, a module receiving
+       <symbol>EINTR</symbol>/<symbol>EAGAIN</symbol> from a blocking call
+       should call <function>CHECK_FOR_INTERRUPTS()</function> before retrying.
+       The same should be done during any long-running loops. Failure to follow
+       this guidance may result in unresponsive backend sessions.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Testing</term>
+     <listitem>
+      <para>
+       The breadth of testing an OAuth system is well beyond the scope of this
+       documentation, but at minimum, negative testing should be considered
+       mandatory. It's trivial to design a module that lets authorized users in;
+       the whole point of the system is to keep unauthorized users out.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Documentation</term>
+     <listitem>
+      <para>
+       Validator implementations should document the contents and format of the
+       authenticated ID that is reported to the server for each end user, since
+       DBAs may need to use this information to construct pg_ident maps. (For
+       instance, is it an email address? an organizational ID number? a UUID?)
+       They should also document whether or not it is safe to use the module in
+       <symbol>delegate_ident_mapping=1</symbol> mode, and what additional
+       configuration is required in order to do so.
+      </para>
+     </listitem>
+    </varlistentry>
+   </variablelist>
+  </sect2>
+
+  <sect2 id="oauth-validator-design-usermap-delegation">
+   <title>Authorizing Users (Usermap Delegation)</title>
+   <para>
+    The standard deliverable of a validation module is the user identifier,
+    which the server will then compare to any configured
+    <link linkend="auth-username-maps"><filename>pg_ident.conf</filename>
+    mappings</link> and determine whether the end user is authorized to connect.
+    However, OAuth is itself an authorization framework, and tokens may carry
+    information about user privileges. For example, a token may be associated
+    with the organizational groups that a user belongs to, or list the roles
+    that a user may assume, and duplicating that knowledge into local usermaps
+    for every server may not be desirable.
+   </para>
+   <para>
+    To bypass username mapping entirely, and have the validator module assume
+    the additional responsibility of authorizing user connections, the HBA may
+    be configured with <xref linkend="auth-oauth-delegate-ident-mapping"/>.
+    The module may then use token scopes or an equivalent method to decide
+    whether the user is allowed to connect under their desired role. The user
+    identifier will still be recorded by the server, but it plays no part in
+    determining whether to continue the connection.
+   </para>
+   <para>
+    Using this scheme, authentication itself is optional. As long as the module
+    reports that the connection is authorized, login will continue even if there
+    is no recorded user identifier at all. This makes it possible to implement
+    anonymous or pseudonymous access to the database, where the third-party
+    provider performs all necessary authentication but does not provide any
+    user-identifying information to the server. (Some providers may create an
+    anonymized ID number that can be recorded instead, for later auditing.)
+   </para>
+   <para>
+    Usermap delegation provides the most architectural flexibility, but it turns
+    the validator module into a single point of failure for connection
+    authorization. Use with caution.
+   </para>
+  </sect2>
+ </sect1>
+
+ <sect1 id="oauth-validator-init">
+  <title>Initialization Functions</title>
+  <indexterm zone="oauth-validator-init">
+   <primary>_PG_oauth_validator_module_init</primary>
+  </indexterm>
+  <para>
+   OAuth validator modules are dynamically loaded from the shared
+   libraries listed in <xref linkend="guc-oauth-validator-libraries"/>.
+   Modules are loaded on demand when requested from a login in progress.
+   The normal library search path is used to locate the library. To
+   provide the validator callbacks and to indicate that the library is an OAuth
+   validator module a function named
+   <function>_PG_oauth_validator_module_init</function> must be provided. The
+   return value of the function must be a pointer to a struct of type
+   <structname>OAuthValidatorCallbacks</structname>, which contains a magic
+   number and pointers to the module's token validation functions. The returned
+   pointer must be of server lifetime, which is typically achieved by defining
+   it as a <literal>static const</literal> variable in global scope.
+<programlisting>
+typedef struct OAuthValidatorCallbacks
+{
+    uint32        magic;            /* must be set to PG_OAUTH_VALIDATOR_MAGIC */
+
+    ValidatorStartupCB startup_cb;
+    ValidatorShutdownCB shutdown_cb;
+    ValidatorValidateCB validate_cb;
+} OAuthValidatorCallbacks;
+
+typedef const OAuthValidatorCallbacks *(*OAuthValidatorModuleInit) (void);
+</programlisting>
+
+   Only the <function>validate_cb</function> callback is required, the others
+   are optional.
+  </para>
+ </sect1>
+
+ <sect1 id="oauth-validator-callbacks">
+  <title>OAuth Validator Callbacks</title>
+  <para>
+   OAuth validator modules implement their functionality by defining a set of
+   callbacks. The server will call them as required to process the
+   authentication request from the user.
+  </para>
+
+  <sect2 id="oauth-validator-callback-startup">
+   <title>Startup Callback</title>
+   <para>
+    The <function>startup_cb</function> callback is executed directly after
+    loading the module. This callback can be used to set up local state and
+    perform additional initialization if required. If the validator module
+    has state it can use <structfield>state->private_data</structfield> to
+    store it.
+
+<programlisting>
+typedef void (*ValidatorStartupCB) (ValidatorModuleState *state);
+</programlisting>
+   </para>
+  </sect2>
+
+  <sect2 id="oauth-validator-callback-validate">
+   <title>Validate Callback</title>
+   <para>
+    The <function>validate_cb</function> callback is executed during the OAuth
+    exchange when a user attempts to authenticate using OAuth.  Any state set in
+    previous calls will be available in <structfield>state->private_data</structfield>.
+
+<programlisting>
+typedef bool (*ValidatorValidateCB) (const ValidatorModuleState *state,
+                                     const char *token, const char *role,
+                                     ValidatorModuleResult *result);
+</programlisting>
+
+    <replaceable>token</replaceable> will contain the bearer token to validate.
+    <application>PostgreSQL</application> has ensured that the token is well-formed syntactically, but no
+    other validation has been performed.  <replaceable>role</replaceable> will
+    contain the role the user has requested to log in as.  The callback must
+    set output parameters in the <literal>result</literal> struct, which is
+    defined as below:
+
+<programlisting>
+typedef struct ValidatorModuleResult
+{
+    bool        authorized;
+    char       *authn_id;
+} ValidatorModuleResult;
+</programlisting>
+
+    The connection will only proceed if the module sets
+    <structfield>result->authorized</structfield> to <literal>true</literal>.  To
+    authenticate the user, the authenticated user name (as determined using the
+    token) shall be palloc'd and returned in the <structfield>result->authn_id</structfield>
+    field.  Alternatively, <structfield>result->authn_id</structfield> may be set to
+    NULL if the token is valid but the associated user identity cannot be
+    determined.
+   </para>
+   <para>
+    A validator may return <literal>false</literal> to signal an internal error,
+    in which case any result parameters are ignored and the connection fails.
+    Otherwise the validator should return <literal>true</literal> to indicate
+    that it has processed the token and made an authorization decision.
+   </para>
+   <para>
+    The behavior after <function>validate_cb</function> returns depends on the
+    specific HBA setup.  Normally, the <structfield>result->authn_id</structfield> user
+    name must exactly match the role that the user is logging in as.  (This
+    behavior may be modified with a usermap.)  But when authenticating against
+    an HBA rule with <literal>delegate_ident_mapping</literal> turned on,
+    <productname>PostgreSQL</productname> will not perform any checks on the value of
+    <structfield>result->authn_id</structfield> at all; in this case it is up to the
+    validator to ensure that the token carries enough privileges for the user to
+    log in under the indicated <replaceable>role</replaceable>.
+   </para>
+  </sect2>
+
+  <sect2 id="oauth-validator-callback-shutdown">
+   <title>Shutdown Callback</title>
+   <para>
+    The <function>shutdown_cb</function> callback is executed when the backend
+    process associated with the connection exits. If the validator module has
+    any allocated state, this callback should free it to avoid resource leaks.
+<programlisting>
+typedef void (*ValidatorShutdownCB) (ValidatorModuleState *state);
+</programlisting>
+   </para>
+  </sect2>
+
+ </sect1>
+</chapter>
diff --git a/doc/src/sgml/postgres.sgml b/doc/src/sgml/postgres.sgml
index 7be25c58507..af476c82fcc 100644
--- a/doc/src/sgml/postgres.sgml
+++ b/doc/src/sgml/postgres.sgml
@@ -229,6 +229,7 @@ break is not needed in a wider output rendering.
   &logicaldecoding;
   &replication-origins;
   &archive-modules;
+  &oauth-validators;
 
  </part>
 
diff --git a/doc/src/sgml/protocol.sgml b/doc/src/sgml/protocol.sgml
index fb5dec1172e..3bd9e68e6ce 100644
--- a/doc/src/sgml/protocol.sgml
+++ b/doc/src/sgml/protocol.sgml
@@ -1688,11 +1688,11 @@ SELCT 1/0;<!-- this typo is intentional -->
 
   <para>
    <firstterm>SASL</firstterm> is a framework for authentication in connection-oriented
-   protocols. At the moment, <productname>PostgreSQL</productname> implements two SASL
-   authentication mechanisms, SCRAM-SHA-256 and SCRAM-SHA-256-PLUS. More
-   might be added in the future. The below steps illustrate how SASL
-   authentication is performed in general, while the next subsection gives
-   more details on SCRAM-SHA-256 and SCRAM-SHA-256-PLUS.
+   protocols. At the moment, <productname>PostgreSQL</productname> implements three
+   SASL authentication mechanisms: SCRAM-SHA-256, SCRAM-SHA-256-PLUS, and
+   OAUTHBEARER. More might be added in the future. The below steps illustrate how SASL
+   authentication is performed in general, while the next subsections give
+   more details on particular mechanisms.
   </para>
 
   <procedure>
@@ -1727,7 +1727,7 @@ SELCT 1/0;<!-- this typo is intentional -->
    <step id="sasl-auth-end">
     <para>
      Finally, when the authentication exchange is completed successfully, the
-     server sends an AuthenticationSASLFinal message, followed
+     server sends an optional AuthenticationSASLFinal message, followed
      immediately by an AuthenticationOk message. The AuthenticationSASLFinal
      contains additional server-to-client data, whose content is particular to the
      selected authentication mechanism. If the authentication mechanism doesn't
@@ -1746,9 +1746,9 @@ SELCT 1/0;<!-- this typo is intentional -->
    <title>SCRAM-SHA-256 Authentication</title>
 
    <para>
-    The implemented SASL mechanisms at the moment
-    are <literal>SCRAM-SHA-256</literal> and its variant with channel
-    binding <literal>SCRAM-SHA-256-PLUS</literal>. They are described in
+    <literal>SCRAM-SHA-256</literal>, and its variant with channel
+    binding <literal>SCRAM-SHA-256-PLUS</literal>, are password-based
+    authentication mechanisms. They are described in
     detail in <ulink url="https://datatracker.ietf.org/doc/html/rfc7677">RFC 7677</ulink>
     and <ulink url="https://datatracker.ietf.org/doc/html/rfc5802">RFC 5802</ulink>.
    </para>
@@ -1850,6 +1850,121 @@ SELCT 1/0;<!-- this typo is intentional -->
     </step>
    </procedure>
   </sect2>
+
+  <sect2 id="sasl-oauthbearer">
+   <title>OAUTHBEARER Authentication</title>
+
+   <para>
+    <literal>OAUTHBEARER</literal> is a token-based mechanism for federated
+    authentication. It is described in detail in
+    <ulink url="https://datatracker.ietf.org/doc/html/rfc7628">RFC 7628</ulink>.
+   </para>
+
+   <para>
+    A typical exchange differs depending on whether or not the client already
+    has a bearer token cached for the current user. If it does not, the exchange
+    will take place over two connections: the first "discovery" connection to
+    obtain OAuth metadata from the server, and the second connection to send
+    the token after the client has obtained it. (libpq does not currently
+    implement a caching method as part of its builtin flow, so it uses the
+    two-connection exchange.)
+   </para>
+
+   <para>
+    This mechanism is client-initiated, like SCRAM. The client initial response
+    consists of the standard "GS2" header used by SCRAM, followed by a list of
+    <literal>key=value</literal> pairs. The only key currently supported by
+    the server is <literal>auth</literal>, which contains the bearer token.
+    <literal>OAUTHBEARER</literal> additionally specifies three optional
+    components of the client initial response (the <literal>authzid</literal> of
+    the GS2 header, and the <structfield>host</structfield> and
+    <structfield>port</structfield> keys) which are currently ignored by the
+    server.
+   </para>
+
+   <para>
+    <literal>OAUTHBEARER</literal> does not support channel binding, and there
+    is no "OAUTHBEARER-PLUS" mechanism. This mechanism does not make use of
+    server data during a successful authentication, so the
+    AuthenticationSASLFinal message is not used in the exchange.
+   </para>
+
+   <procedure>
+    <title>Example</title>
+    <step>
+     <para>
+      During the first exchange, the server sends an AuthenticationSASL message
+      with the <literal>OAUTHBEARER</literal> mechanism advertised.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      The client responds by sending a SASLInitialResponse message which
+      indicates the <literal>OAUTHBEARER</literal> mechanism. Assuming the
+      client does not already have a valid bearer token for the current user,
+      the <structfield>auth</structfield> field is empty, indicating a discovery
+      connection.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      Server sends an AuthenticationSASLContinue message containing an error
+      <literal>status</literal> alongside a well-known URI and scopes that the
+      client should use to conduct an OAuth flow.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      Client sends a SASLResponse message containing the empty set (a single
+      <literal>0x01</literal> byte) to finish its half of the discovery
+      exchange.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      Server sends an ErrorMessage to fail the first exchange.
+     </para>
+     <para>
+      At this point, the client conducts one of many possible OAuth flows to
+      obtain a bearer token, using any metadata that it has been configured with
+      in addition to that provided by the server. (This description is left
+      deliberately vague; <literal>OAUTHBEARER</literal> does not specify or
+      mandate any particular method for obtaining a token.)
+     </para>
+     <para>
+      Once it has a token, the client reconnects to the server for the final
+      exchange:
+     </para>
+    </step>
+
+    <step>
+     <para>
+      The server once again sends an AuthenticationSASL message with the
+      <literal>OAUTHBEARER</literal> mechanism advertised.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      The client responds by sending a SASLInitialResponse message, but this
+      time the <structfield>auth</structfield> field in the message contains the
+      bearer token that was obtained during the client flow.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      The server validates the token according to the instructions of the
+      token provider. If the client is authorized to connect, it sends an
+      AuthenticationOk message to end the SASL exchange.
+     </para>
+    </step>
+   </procedure>
+  </sect2>
  </sect1>
 
  <sect1 id="protocol-replication">
diff --git a/doc/src/sgml/regress.sgml b/doc/src/sgml/regress.sgml
index 7c474559bdf..0e5e8e8f309 100644
--- a/doc/src/sgml/regress.sgml
+++ b/doc/src/sgml/regress.sgml
@@ -347,6 +347,16 @@ make check-world PG_TEST_EXTRA='kerberos ldap ssl load_balance libpq_encryption'
       </para>
      </listitem>
     </varlistentry>
+
+    <varlistentry>
+     <term><literal>oauth</literal></term>
+     <listitem>
+      <para>
+       Runs the test suite under <filename>src/test/modules/oauth_validator</filename>.
+       This opens TCP/IP listen sockets for a test-server running HTTPS.
+      </para>
+     </listitem>
+    </varlistentry>
    </variablelist>
 
    Tests for features that are not supported by the current build
diff --git a/meson.build b/meson.build
index 7dd7110318d..574f992ed49 100644
--- a/meson.build
+++ b/meson.build
@@ -855,6 +855,101 @@ endif
 
 
 
+###############################################################
+# Library: libcurl
+###############################################################
+
+libcurlopt = get_option('libcurl')
+if not libcurlopt.disabled()
+  # Check for libcurl 7.61.0 or higher (corresponding to RHEL8 and the ability
+  # to explicitly set TLS 1.3 ciphersuites).
+  libcurl = dependency('libcurl', version: '>= 7.61.0', required: libcurlopt)
+  if libcurl.found()
+    cdata.set('USE_LIBCURL', 1)
+
+    # Check to see whether the current platform supports thread-safe Curl
+    # initialization.
+    libcurl_threadsafe_init = false
+
+    if not meson.is_cross_build()
+      r = cc.run('''
+        #include <curl/curl.h>
+
+        int main(void)
+        {
+            curl_version_info_data *info;
+
+            if (curl_global_init(CURL_GLOBAL_ALL))
+                return -1;
+
+            info = curl_version_info(CURLVERSION_NOW);
+        #ifdef CURL_VERSION_THREADSAFE
+            if (info->features & CURL_VERSION_THREADSAFE)
+                return 0;
+        #endif
+
+            return 1;
+        }''',
+        name: 'test for curl_global_init thread safety',
+        dependencies: libcurl,
+      )
+
+      assert(r.compiled())
+      if r.returncode() == 0
+        libcurl_threadsafe_init = true
+        message('curl_global_init is thread-safe')
+      elif r.returncode() == 1
+        message('curl_global_init is not thread-safe')
+      else
+        message('curl_global_init failed; assuming not thread-safe')
+      endif
+    endif
+
+    if libcurl_threadsafe_init
+      cdata.set('HAVE_THREADSAFE_CURL_GLOBAL_INIT', 1)
+    endif
+
+    # Warn if a thread-friendly DNS resolver isn't built.
+    libcurl_async_dns = false
+
+    if not meson.is_cross_build()
+      r = cc.run('''
+        #include <curl/curl.h>
+
+        int main(void)
+        {
+            curl_version_info_data *info;
+
+            if (curl_global_init(CURL_GLOBAL_ALL))
+                return -1;
+
+            info = curl_version_info(CURLVERSION_NOW);
+            return (info->features & CURL_VERSION_ASYNCHDNS) ? 0 : 1;
+        }''',
+        name: 'test for curl support for asynchronous DNS',
+        dependencies: libcurl,
+      )
+
+      assert(r.compiled())
+      if r.returncode() == 0
+        libcurl_async_dns = true
+      endif
+    endif
+
+    if not libcurl_async_dns
+      warning('''
+*** The installed version of libcurl does not support asynchronous DNS
+*** lookups. Connection timeouts will not be honored during DNS resolution,
+*** which may lead to hangs in client programs.''')
+    endif
+  endif
+
+else
+  libcurl = not_found_dep
+endif
+
+
+
 ###############################################################
 # Library: libxml
 ###############################################################
@@ -3045,6 +3140,10 @@ libpq_deps += [
 
   gssapi,
   ldap_r,
+  # XXX libcurl must link after libgssapi_krb5 on FreeBSD to avoid segfaults
+  # during gss_acquire_cred(). This is possibly related to Curl's Heimdal
+  # dependency on that platform?
+  libcurl,
   libintl,
   ssl,
 ]
@@ -3721,6 +3820,7 @@ if meson.version().version_compare('>=0.57')
       'gss': gssapi,
       'icu': icu,
       'ldap': ldap,
+      'libcurl': libcurl,
       'libxml': libxml,
       'libxslt': libxslt,
       'llvm': llvm,
diff --git a/meson_options.txt b/meson_options.txt
index d9c7ddccbc4..702c4517145 100644
--- a/meson_options.txt
+++ b/meson_options.txt
@@ -100,6 +100,9 @@ option('icu', type: 'feature', value: 'auto',
 option('ldap', type: 'feature', value: 'auto',
   description: 'LDAP support')
 
+option('libcurl', type : 'feature', value: 'auto',
+  description: 'libcurl support')
+
 option('libedit_preferred', type: 'boolean', value: false,
   description: 'Prefer BSD Libedit over GNU Readline')
 
diff --git a/src/Makefile.global.in b/src/Makefile.global.in
index bbe11e75bf0..3b620bac5ac 100644
--- a/src/Makefile.global.in
+++ b/src/Makefile.global.in
@@ -190,6 +190,7 @@ with_systemd	= @with_systemd@
 with_gssapi	= @with_gssapi@
 with_krb_srvnam	= @with_krb_srvnam@
 with_ldap	= @with_ldap@
+with_libcurl	= @with_libcurl@
 with_libxml	= @with_libxml@
 with_libxslt	= @with_libxslt@
 with_llvm	= @with_llvm@
diff --git a/src/backend/libpq/Makefile b/src/backend/libpq/Makefile
index 6d385fd6a45..98eb2a8242d 100644
--- a/src/backend/libpq/Makefile
+++ b/src/backend/libpq/Makefile
@@ -15,6 +15,7 @@ include $(top_builddir)/src/Makefile.global
 # be-fsstubs is here for historical reasons, probably belongs elsewhere
 
 OBJS = \
+	auth-oauth.o \
 	auth-sasl.o \
 	auth-scram.o \
 	auth.o \
diff --git a/src/backend/libpq/auth-oauth.c b/src/backend/libpq/auth-oauth.c
new file mode 100644
index 00000000000..27f7af7be00
--- /dev/null
+++ b/src/backend/libpq/auth-oauth.c
@@ -0,0 +1,894 @@
+/*-------------------------------------------------------------------------
+ *
+ * auth-oauth.c
+ *	  Server-side implementation of the SASL OAUTHBEARER mechanism.
+ *
+ * See the following RFC for more details:
+ * - RFC 7628: https://datatracker.ietf.org/doc/html/rfc7628
+ *
+ * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/backend/libpq/auth-oauth.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include <unistd.h>
+#include <fcntl.h>
+
+#include "common/oauth-common.h"
+#include "fmgr.h"
+#include "lib/stringinfo.h"
+#include "libpq/auth.h"
+#include "libpq/hba.h"
+#include "libpq/oauth.h"
+#include "libpq/sasl.h"
+#include "storage/fd.h"
+#include "storage/ipc.h"
+#include "utils/json.h"
+#include "utils/varlena.h"
+
+/* GUC */
+char	   *oauth_validator_libraries_string = NULL;
+
+static void oauth_get_mechanisms(Port *port, StringInfo buf);
+static void *oauth_init(Port *port, const char *selected_mech, const char *shadow_pass);
+static int	oauth_exchange(void *opaq, const char *input, int inputlen,
+						   char **output, int *outputlen, const char **logdetail);
+
+static void load_validator_library(const char *libname);
+static void shutdown_validator_library(void *arg);
+
+static ValidatorModuleState *validator_module_state;
+static const OAuthValidatorCallbacks *ValidatorCallbacks;
+
+/* Mechanism declaration */
+const pg_be_sasl_mech pg_be_oauth_mech = {
+	.get_mechanisms = oauth_get_mechanisms,
+	.init = oauth_init,
+	.exchange = oauth_exchange,
+
+	.max_message_length = PG_MAX_AUTH_TOKEN_LENGTH,
+};
+
+/* Valid states for the oauth_exchange() machine. */
+enum oauth_state
+{
+	OAUTH_STATE_INIT = 0,
+	OAUTH_STATE_ERROR,
+	OAUTH_STATE_FINISHED,
+};
+
+/* Mechanism callback state. */
+struct oauth_ctx
+{
+	enum oauth_state state;
+	Port	   *port;
+	const char *issuer;
+	const char *scope;
+};
+
+static char *sanitize_char(char c);
+static char *parse_kvpairs_for_auth(char **input);
+static void generate_error_response(struct oauth_ctx *ctx, char **output, int *outputlen);
+static bool validate(Port *port, const char *auth);
+
+/* Constants seen in an OAUTHBEARER client initial response. */
+#define KVSEP 0x01				/* separator byte for key/value pairs */
+#define AUTH_KEY "auth"			/* key containing the Authorization header */
+#define BEARER_SCHEME "Bearer " /* required header scheme (case-insensitive!) */
+
+/*
+ * Retrieves the OAUTHBEARER mechanism list (currently a single item).
+ *
+ * For a full description of the API, see libpq/sasl.h.
+ */
+static void
+oauth_get_mechanisms(Port *port, StringInfo buf)
+{
+	/* Only OAUTHBEARER is supported. */
+	appendStringInfoString(buf, OAUTHBEARER_NAME);
+	appendStringInfoChar(buf, '\0');
+}
+
+/*
+ * Initializes mechanism state and loads the configured validator module.
+ *
+ * For a full description of the API, see libpq/sasl.h.
+ */
+static void *
+oauth_init(Port *port, const char *selected_mech, const char *shadow_pass)
+{
+	struct oauth_ctx *ctx;
+
+	if (strcmp(selected_mech, OAUTHBEARER_NAME) != 0)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("client selected an invalid SASL authentication mechanism"));
+
+	ctx = palloc0(sizeof(*ctx));
+
+	ctx->state = OAUTH_STATE_INIT;
+	ctx->port = port;
+
+	Assert(port->hba);
+	ctx->issuer = port->hba->oauth_issuer;
+	ctx->scope = port->hba->oauth_scope;
+
+	load_validator_library(port->hba->oauth_validator);
+
+	return ctx;
+}
+
+/*
+ * Implements the OAUTHBEARER SASL exchange (RFC 7628, Sec. 3.2). This pulls
+ * apart the client initial response and validates the Bearer token. It also
+ * handles the dummy error response for a failed handshake, as described in
+ * Sec. 3.2.3.
+ *
+ * For a full description of the API, see libpq/sasl.h.
+ */
+static int
+oauth_exchange(void *opaq, const char *input, int inputlen,
+			   char **output, int *outputlen, const char **logdetail)
+{
+	char	   *input_copy;
+	char	   *p;
+	char		cbind_flag;
+	char	   *auth;
+	int			status;
+
+	struct oauth_ctx *ctx = opaq;
+
+	*output = NULL;
+	*outputlen = -1;
+
+	/*
+	 * If the client didn't include an "Initial Client Response" in the
+	 * SASLInitialResponse message, send an empty challenge, to which the
+	 * client will respond with the same data that usually comes in the
+	 * Initial Client Response.
+	 */
+	if (input == NULL)
+	{
+		Assert(ctx->state == OAUTH_STATE_INIT);
+
+		*output = pstrdup("");
+		*outputlen = 0;
+		return PG_SASL_EXCHANGE_CONTINUE;
+	}
+
+	/*
+	 * Check that the input length agrees with the string length of the input.
+	 */
+	if (inputlen == 0)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("The message is empty."));
+	if (inputlen != strlen(input))
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message length does not match input length."));
+
+	switch (ctx->state)
+	{
+		case OAUTH_STATE_INIT:
+			/* Handle this case below. */
+			break;
+
+		case OAUTH_STATE_ERROR:
+
+			/*
+			 * Only one response is valid for the client during authentication
+			 * failure: a single kvsep.
+			 */
+			if (inputlen != 1 || *input != KVSEP)
+				ereport(ERROR,
+						errcode(ERRCODE_PROTOCOL_VIOLATION),
+						errmsg("malformed OAUTHBEARER message"),
+						errdetail("Client did not send a kvsep response."));
+
+			/* The (failed) handshake is now complete. */
+			ctx->state = OAUTH_STATE_FINISHED;
+			return PG_SASL_EXCHANGE_FAILURE;
+
+		default:
+			elog(ERROR, "invalid OAUTHBEARER exchange state");
+			return PG_SASL_EXCHANGE_FAILURE;
+	}
+
+	/* Handle the client's initial message. */
+	p = input_copy = pstrdup(input);
+
+	/*
+	 * OAUTHBEARER does not currently define a channel binding (so there is no
+	 * OAUTHBEARER-PLUS, and we do not accept a 'p' specifier). We accept a
+	 * 'y' specifier purely for the remote chance that a future specification
+	 * could define one; then future clients can still interoperate with this
+	 * server implementation. 'n' is the expected case.
+	 */
+	cbind_flag = *p;
+	switch (cbind_flag)
+	{
+		case 'p':
+			ereport(ERROR,
+					errcode(ERRCODE_PROTOCOL_VIOLATION),
+					errmsg("malformed OAUTHBEARER message"),
+					errdetail("The server does not support channel binding for OAuth, but the client message includes channel binding data."));
+			break;
+
+		case 'y':				/* fall through */
+		case 'n':
+			p++;
+			if (*p != ',')
+				ereport(ERROR,
+						errcode(ERRCODE_PROTOCOL_VIOLATION),
+						errmsg("malformed OAUTHBEARER message"),
+						errdetail("Comma expected, but found character \"%s\".",
+								  sanitize_char(*p)));
+			p++;
+			break;
+
+		default:
+			ereport(ERROR,
+					errcode(ERRCODE_PROTOCOL_VIOLATION),
+					errmsg("malformed OAUTHBEARER message"),
+					errdetail("Unexpected channel-binding flag \"%s\".",
+							  sanitize_char(cbind_flag)));
+	}
+
+	/*
+	 * Forbid optional authzid (authorization identity).  We don't support it.
+	 */
+	if (*p == 'a')
+		ereport(ERROR,
+				errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				errmsg("client uses authorization identity, but it is not supported"));
+	if (*p != ',')
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Unexpected attribute \"%s\" in client-first-message.",
+						  sanitize_char(*p)));
+	p++;
+
+	/* All remaining fields are separated by the RFC's kvsep (\x01). */
+	if (*p != KVSEP)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Key-value separator expected, but found character \"%s\".",
+						  sanitize_char(*p)));
+	p++;
+
+	auth = parse_kvpairs_for_auth(&p);
+	if (!auth)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message does not contain an auth value."));
+
+	/* We should be at the end of our message. */
+	if (*p)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message contains additional data after the final terminator."));
+
+	if (!validate(ctx->port, auth))
+	{
+		generate_error_response(ctx, output, outputlen);
+
+		ctx->state = OAUTH_STATE_ERROR;
+		status = PG_SASL_EXCHANGE_CONTINUE;
+	}
+	else
+	{
+		ctx->state = OAUTH_STATE_FINISHED;
+		status = PG_SASL_EXCHANGE_SUCCESS;
+	}
+
+	/* Don't let extra copies of the bearer token hang around. */
+	explicit_bzero(input_copy, inputlen);
+
+	return status;
+}
+
+/*
+ * Convert an arbitrary byte to printable form.  For error messages.
+ *
+ * If it's a printable ASCII character, print it as a single character.
+ * otherwise, print it in hex.
+ *
+ * The returned pointer points to a static buffer.
+ */
+static char *
+sanitize_char(char c)
+{
+	static char buf[5];
+
+	if (c >= 0x21 && c <= 0x7E)
+		snprintf(buf, sizeof(buf), "'%c'", c);
+	else
+		snprintf(buf, sizeof(buf), "0x%02x", (unsigned char) c);
+	return buf;
+}
+
+/*
+ * Performs syntactic validation of a key and value from the initial client
+ * response. (Semantic validation of interesting values must be performed
+ * later.)
+ */
+static void
+validate_kvpair(const char *key, const char *val)
+{
+	/*-----
+	 * From Sec 3.1:
+	 *     key            = 1*(ALPHA)
+	 */
+	static const char *key_allowed_set =
+		"abcdefghijklmnopqrstuvwxyz"
+		"ABCDEFGHIJKLMNOPQRSTUVWXYZ";
+
+	size_t		span;
+
+	if (!key[0])
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message contains an empty key name."));
+
+	span = strspn(key, key_allowed_set);
+	if (key[span] != '\0')
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message contains an invalid key name."));
+
+	/*-----
+	 * From Sec 3.1:
+	 *     value          = *(VCHAR / SP / HTAB / CR / LF )
+	 *
+	 * The VCHAR (visible character) class is large; a loop is more
+	 * straightforward than strspn().
+	 */
+	for (; *val; ++val)
+	{
+		if (0x21 <= *val && *val <= 0x7E)
+			continue;			/* VCHAR */
+
+		switch (*val)
+		{
+			case ' ':
+			case '\t':
+			case '\r':
+			case '\n':
+				continue;		/* SP, HTAB, CR, LF */
+
+			default:
+				ereport(ERROR,
+						errcode(ERRCODE_PROTOCOL_VIOLATION),
+						errmsg("malformed OAUTHBEARER message"),
+						errdetail("Message contains an invalid value."));
+		}
+	}
+}
+
+/*
+ * Consumes all kvpairs in an OAUTHBEARER exchange message. If the "auth" key is
+ * found, its value is returned.
+ */
+static char *
+parse_kvpairs_for_auth(char **input)
+{
+	char	   *pos = *input;
+	char	   *auth = NULL;
+
+	/*----
+	 * The relevant ABNF, from Sec. 3.1:
+	 *
+	 *     kvsep          = %x01
+	 *     key            = 1*(ALPHA)
+	 *     value          = *(VCHAR / SP / HTAB / CR / LF )
+	 *     kvpair         = key "=" value kvsep
+	 *   ;;gs2-header     = See RFC 5801
+	 *     client-resp    = (gs2-header kvsep *kvpair kvsep) / kvsep
+	 *
+	 * By the time we reach this code, the gs2-header and initial kvsep have
+	 * already been validated. We start at the beginning of the first kvpair.
+	 */
+
+	while (*pos)
+	{
+		char	   *end;
+		char	   *sep;
+		char	   *key;
+		char	   *value;
+
+		/*
+		 * Find the end of this kvpair. Note that input is null-terminated by
+		 * the SASL code, so the strchr() is bounded.
+		 */
+		end = strchr(pos, KVSEP);
+		if (!end)
+			ereport(ERROR,
+					errcode(ERRCODE_PROTOCOL_VIOLATION),
+					errmsg("malformed OAUTHBEARER message"),
+					errdetail("Message contains an unterminated key/value pair."));
+		*end = '\0';
+
+		if (pos == end)
+		{
+			/* Empty kvpair, signifying the end of the list. */
+			*input = pos + 1;
+			return auth;
+		}
+
+		/*
+		 * Find the end of the key name.
+		 */
+		sep = strchr(pos, '=');
+		if (!sep)
+			ereport(ERROR,
+					errcode(ERRCODE_PROTOCOL_VIOLATION),
+					errmsg("malformed OAUTHBEARER message"),
+					errdetail("Message contains a key without a value."));
+		*sep = '\0';
+
+		/* Both key and value are now safely terminated. */
+		key = pos;
+		value = sep + 1;
+		validate_kvpair(key, value);
+
+		if (strcmp(key, AUTH_KEY) == 0)
+		{
+			if (auth)
+				ereport(ERROR,
+						errcode(ERRCODE_PROTOCOL_VIOLATION),
+						errmsg("malformed OAUTHBEARER message"),
+						errdetail("Message contains multiple auth values."));
+
+			auth = value;
+		}
+		else
+		{
+			/*
+			 * The RFC also defines the host and port keys, but they are not
+			 * required for OAUTHBEARER and we do not use them. Also, per Sec.
+			 * 3.1, any key/value pairs we don't recognize must be ignored.
+			 */
+		}
+
+		/* Move to the next pair. */
+		pos = end + 1;
+	}
+
+	ereport(ERROR,
+			errcode(ERRCODE_PROTOCOL_VIOLATION),
+			errmsg("malformed OAUTHBEARER message"),
+			errdetail("Message did not contain a final terminator."));
+
+	pg_unreachable();
+	return NULL;
+}
+
+/*
+ * Builds the JSON response for failed authentication (RFC 7628, Sec. 3.2.2).
+ * This contains the required scopes for entry and a pointer to the OAuth/OpenID
+ * discovery document, which the client may use to conduct its OAuth flow.
+ */
+static void
+generate_error_response(struct oauth_ctx *ctx, char **output, int *outputlen)
+{
+	StringInfoData buf;
+	StringInfoData issuer;
+
+	/*
+	 * The admin needs to set an issuer and scope for OAuth to work. There's
+	 * not really a way to hide this from the user, either, because we can't
+	 * choose a "default" issuer, so be honest in the failure message. (In
+	 * practice such configurations are rejected during HBA parsing.)
+	 */
+	if (!ctx->issuer || !ctx->scope)
+		ereport(FATAL,
+				errcode(ERRCODE_INTERNAL_ERROR),
+				errmsg("OAuth is not properly configured for this user"),
+				errdetail_log("The issuer and scope parameters must be set in pg_hba.conf."));
+
+	/*
+	 * Build a default .well-known URI based on our issuer, unless the HBA has
+	 * already provided one.
+	 */
+	initStringInfo(&issuer);
+	appendStringInfoString(&issuer, ctx->issuer);
+	if (strstr(ctx->issuer, "/.well-known/") == NULL)
+		appendStringInfoString(&issuer, "/.well-known/openid-configuration");
+
+	initStringInfo(&buf);
+
+	/*
+	 * Escaping the string here is belt-and-suspenders defensive programming
+	 * since escapable characters aren't valid in either the issuer URI or the
+	 * scope list, but the HBA doesn't enforce that yet.
+	 */
+	appendStringInfoString(&buf, "{ \"status\": \"invalid_token\", ");
+
+	appendStringInfoString(&buf, "\"openid-configuration\": ");
+	escape_json(&buf, issuer.data);
+	pfree(issuer.data);
+
+	appendStringInfoString(&buf, ", \"scope\": ");
+	escape_json(&buf, ctx->scope);
+
+	appendStringInfoString(&buf, " }");
+
+	*output = buf.data;
+	*outputlen = buf.len;
+}
+
+/*-----
+ * Validates the provided Authorization header and returns the token from
+ * within it. NULL is returned on validation failure.
+ *
+ * Only Bearer tokens are accepted. The ABNF is defined in RFC 6750, Sec.
+ * 2.1:
+ *
+ *      b64token    = 1*( ALPHA / DIGIT /
+ *                        "-" / "." / "_" / "~" / "+" / "/" ) *"="
+ *      credentials = "Bearer" 1*SP b64token
+ *
+ * The "credentials" construction is what we receive in our auth value.
+ *
+ * Since that spec is subordinate to HTTP (i.e. the HTTP Authorization
+ * header format; RFC 9110 Sec. 11), the "Bearer" scheme string must be
+ * compared case-insensitively. (This is not mentioned in RFC 6750, but the
+ * OAUTHBEARER spec points it out: RFC 7628 Sec. 4.)
+ *
+ * Invalid formats are technically a protocol violation, but we shouldn't
+ * reflect any information about the sensitive Bearer token back to the
+ * client; log at COMMERROR instead.
+ */
+static const char *
+validate_token_format(const char *header)
+{
+	size_t		span;
+	const char *token;
+	static const char *const b64token_allowed_set =
+		"abcdefghijklmnopqrstuvwxyz"
+		"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+		"0123456789-._~+/";
+
+	/* Missing auth headers should be handled by the caller. */
+	Assert(header);
+
+	if (header[0] == '\0')
+	{
+		/*
+		 * A completely empty auth header represents a query for
+		 * authentication parameters. The client expects it to fail; there's
+		 * no need to make any extra noise in the logs.
+		 *
+		 * TODO: should we find a way to return STATUS_EOF at the top level,
+		 * to suppress the authentication error entirely?
+		 */
+		return NULL;
+	}
+
+	if (pg_strncasecmp(header, BEARER_SCHEME, strlen(BEARER_SCHEME)))
+	{
+		ereport(COMMERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAuth bearer token"),
+				errdetail_log("Client response indicated a non-Bearer authentication scheme."));
+		return NULL;
+	}
+
+	/* Pull the bearer token out of the auth value. */
+	token = header + strlen(BEARER_SCHEME);
+
+	/* Swallow any additional spaces. */
+	while (*token == ' ')
+		token++;
+
+	/* Tokens must not be empty. */
+	if (!*token)
+	{
+		ereport(COMMERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAuth bearer token"),
+				errdetail_log("Bearer token is empty."));
+		return NULL;
+	}
+
+	/*
+	 * Make sure the token contains only allowed characters. Tokens may end
+	 * with any number of '=' characters.
+	 */
+	span = strspn(token, b64token_allowed_set);
+	while (token[span] == '=')
+		span++;
+
+	if (token[span] != '\0')
+	{
+		/*
+		 * This error message could be more helpful by printing the
+		 * problematic character(s), but that'd be a bit like printing a piece
+		 * of someone's password into the logs.
+		 */
+		ereport(COMMERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAuth bearer token"),
+				errdetail_log("Bearer token is not in the correct format."));
+		return NULL;
+	}
+
+	return token;
+}
+
+/*
+ * Checks that the "auth" kvpair in the client response contains a syntactically
+ * valid Bearer token, then passes it along to the loaded validator module for
+ * authorization. Returns true if validation succeeds.
+ */
+static bool
+validate(Port *port, const char *auth)
+{
+	int			map_status;
+	ValidatorModuleResult *ret;
+	const char *token;
+	bool		status;
+
+	/* Ensure that we have a correct token to validate */
+	if (!(token = validate_token_format(auth)))
+		return false;
+
+	/*
+	 * Ensure that we have a validation library loaded, this should always be
+	 * the case and an error here is indicative of a bug.
+	 */
+	if (!ValidatorCallbacks || !ValidatorCallbacks->validate_cb)
+		ereport(FATAL,
+				errcode(ERRCODE_INTERNAL_ERROR),
+				errmsg("validation of OAuth token requested without a validator loaded"));
+
+	/* Call the validation function from the validator module */
+	ret = palloc0(sizeof(ValidatorModuleResult));
+	if (!ValidatorCallbacks->validate_cb(validator_module_state, token,
+										 port->user_name, ret))
+	{
+		ereport(WARNING,
+				errcode(ERRCODE_INTERNAL_ERROR),
+				errmsg("internal error in OAuth validator module"));
+		return false;
+	}
+
+	/*
+	 * Log any authentication results even if the token isn't authorized; it
+	 * might be useful for auditing or troubleshooting.
+	 */
+	if (ret->authn_id)
+		set_authn_id(port, ret->authn_id);
+
+	if (!ret->authorized)
+	{
+		ereport(LOG,
+				errmsg("OAuth bearer authentication failed for user \"%s\"",
+					   port->user_name),
+				errdetail_log("Validator failed to authorize the provided token."));
+
+		status = false;
+		goto cleanup;
+	}
+
+	if (port->hba->oauth_skip_usermap)
+	{
+		/*
+		 * If the validator is our authorization authority, we're done.
+		 * Authentication may or may not have been performed depending on the
+		 * validator implementation; all that matters is that the validator
+		 * says the user can log in with the target role.
+		 */
+		status = true;
+		goto cleanup;
+	}
+
+	/* Make sure the validator authenticated the user. */
+	if (ret->authn_id == NULL || ret->authn_id[0] == '\0')
+	{
+		ereport(LOG,
+				errmsg("OAuth bearer authentication failed for user \"%s\"",
+					   port->user_name),
+				errdetail_log("Validator provided no identity."));
+
+		status = false;
+		goto cleanup;
+	}
+
+	/* Finally, check the user map. */
+	map_status = check_usermap(port->hba->usermap, port->user_name,
+							   MyClientConnectionInfo.authn_id, false);
+	status = (map_status == STATUS_OK);
+
+cleanup:
+
+	/*
+	 * Clear and free the validation result from the validator module once
+	 * we're done with it.
+	 */
+	if (ret->authn_id != NULL)
+		pfree(ret->authn_id);
+	pfree(ret);
+
+	return status;
+}
+
+/*
+ * load_validator_library
+ *
+ * Load the configured validator library in order to perform token validation.
+ * There is no built-in fallback since validation is implementation specific. If
+ * no validator library is configured, or if it fails to load, then error out
+ * since token validation won't be possible.
+ */
+static void
+load_validator_library(const char *libname)
+{
+	OAuthValidatorModuleInit validator_init;
+	MemoryContextCallback *mcb;
+
+	/*
+	 * The presence, and validity, of libname has already been established by
+	 * check_oauth_validator so we don't need to perform more than Assert
+	 * level checking here.
+	 */
+	Assert(libname && *libname);
+
+	validator_init = (OAuthValidatorModuleInit)
+		load_external_function(libname, "_PG_oauth_validator_module_init",
+							   false, NULL);
+
+	/*
+	 * The validator init function is required since it will set the callbacks
+	 * for the validator library.
+	 */
+	if (validator_init == NULL)
+		ereport(ERROR,
+				errmsg("%s module \"%s\" must define the symbol %s",
+					   "OAuth validator", libname, "_PG_oauth_validator_module_init"));
+
+	ValidatorCallbacks = (*validator_init) ();
+	Assert(ValidatorCallbacks);
+
+	/*
+	 * Check the magic number, to protect against break-glass scenarios where
+	 * the ABI must change within a major version. load_external_function()
+	 * already checks for compatibility across major versions.
+	 */
+	if (ValidatorCallbacks->magic != PG_OAUTH_VALIDATOR_MAGIC)
+		ereport(ERROR,
+				errmsg("%s module \"%s\": magic number mismatch",
+					   "OAuth validator", libname),
+				errdetail("Server has magic number 0x%08X, module has 0x%08X.",
+						  PG_OAUTH_VALIDATOR_MAGIC, ValidatorCallbacks->magic));
+
+	/*
+	 * Make sure all required callbacks are present in the ValidatorCallbacks
+	 * structure. Right now only the validation callback is required.
+	 */
+	if (ValidatorCallbacks->validate_cb == NULL)
+		ereport(ERROR,
+				errmsg("%s module \"%s\" must provide a %s callback",
+					   "OAuth validator", libname, "validate_cb"));
+
+	/* Allocate memory for validator library private state data */
+	validator_module_state = (ValidatorModuleState *) palloc0(sizeof(ValidatorModuleState));
+	validator_module_state->sversion = PG_VERSION_NUM;
+
+	if (ValidatorCallbacks->startup_cb != NULL)
+		ValidatorCallbacks->startup_cb(validator_module_state);
+
+	/* Shut down the library before cleaning up its state. */
+	mcb = palloc0(sizeof(*mcb));
+	mcb->func = shutdown_validator_library;
+
+	MemoryContextRegisterResetCallback(CurrentMemoryContext, mcb);
+}
+
+/*
+ * Call the validator module's shutdown callback, if one is provided. This is
+ * invoked during memory context reset.
+ */
+static void
+shutdown_validator_library(void *arg)
+{
+	if (ValidatorCallbacks->shutdown_cb != NULL)
+		ValidatorCallbacks->shutdown_cb(validator_module_state);
+}
+
+/*
+ * Ensure an OAuth validator named in the HBA is permitted by the configuration.
+ *
+ * If the validator is currently unset and exactly one library is declared in
+ * oauth_validator_libraries, then that library will be used as the validator.
+ * Otherwise the name must be present in the list of oauth_validator_libraries.
+ */
+bool
+check_oauth_validator(HbaLine *hbaline, int elevel, char **err_msg)
+{
+	int			line_num = hbaline->linenumber;
+	const char *file_name = hbaline->sourcefile;
+	char	   *rawstring;
+	List	   *elemlist = NIL;
+
+	*err_msg = NULL;
+
+	if (oauth_validator_libraries_string[0] == '\0')
+	{
+		ereport(elevel,
+				errcode(ERRCODE_CONFIG_FILE_ERROR),
+				errmsg("oauth_validator_libraries must be set for authentication method %s",
+					   "oauth"),
+				errcontext("line %d of configuration file \"%s\"",
+						   line_num, file_name));
+		*err_msg = psprintf("oauth_validator_libraries must be set for authentication method %s",
+							"oauth");
+		return false;
+	}
+
+	/* SplitDirectoriesString needs a modifiable copy */
+	rawstring = pstrdup(oauth_validator_libraries_string);
+
+	if (!SplitDirectoriesString(rawstring, ',', &elemlist))
+	{
+		/* syntax error in list */
+		ereport(elevel,
+				errcode(ERRCODE_CONFIG_FILE_ERROR),
+				errmsg("invalid list syntax in parameter \"%s\"",
+					   "oauth_validator_libraries"));
+		*err_msg = psprintf("invalid list syntax in parameter \"%s\"",
+							"oauth_validator_libraries");
+		goto done;
+	}
+
+	if (!hbaline->oauth_validator)
+	{
+		if (elemlist->length == 1)
+		{
+			hbaline->oauth_validator = pstrdup(linitial(elemlist));
+			goto done;
+		}
+
+		ereport(elevel,
+				errcode(ERRCODE_CONFIG_FILE_ERROR),
+				errmsg("authentication method \"oauth\" requires argument \"validator\" to be set when oauth_validator_libraries contains multiple options"),
+				errcontext("line %d of configuration file \"%s\"",
+						   line_num, file_name));
+		*err_msg = "authentication method \"oauth\" requires argument \"validator\" to be set when oauth_validator_libraries contains multiple options";
+		goto done;
+	}
+
+	foreach_ptr(char, allowed, elemlist)
+	{
+		if (strcmp(allowed, hbaline->oauth_validator) == 0)
+			goto done;
+	}
+
+	ereport(elevel,
+			errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+			errmsg("validator \"%s\" is not permitted by %s",
+				   hbaline->oauth_validator, "oauth_validator_libraries"),
+			errcontext("line %d of configuration file \"%s\"",
+					   line_num, file_name));
+	*err_msg = psprintf("validator \"%s\" is not permitted by %s",
+						hbaline->oauth_validator, "oauth_validator_libraries");
+
+done:
+	list_free_deep(elemlist);
+	pfree(rawstring);
+
+	return (*err_msg == NULL);
+}
diff --git a/src/backend/libpq/auth.c b/src/backend/libpq/auth.c
index d6ef32cc823..0f65014e64f 100644
--- a/src/backend/libpq/auth.c
+++ b/src/backend/libpq/auth.c
@@ -29,6 +29,7 @@
 #include "libpq/auth.h"
 #include "libpq/crypt.h"
 #include "libpq/libpq.h"
+#include "libpq/oauth.h"
 #include "libpq/pqformat.h"
 #include "libpq/sasl.h"
 #include "libpq/scram.h"
@@ -45,7 +46,6 @@
  */
 static void auth_failed(Port *port, int status, const char *logdetail);
 static char *recv_password_packet(Port *port);
-static void set_authn_id(Port *port, const char *id);
 
 
 /*----------------------------------------------------------------
@@ -289,6 +289,9 @@ auth_failed(Port *port, int status, const char *logdetail)
 		case uaRADIUS:
 			errstr = gettext_noop("RADIUS authentication failed for user \"%s\"");
 			break;
+		case uaOAuth:
+			errstr = gettext_noop("OAuth bearer authentication failed for user \"%s\"");
+			break;
 		default:
 			errstr = gettext_noop("authentication failed for user \"%s\": invalid authentication method");
 			break;
@@ -324,7 +327,7 @@ auth_failed(Port *port, int status, const char *logdetail)
  * lifetime of MyClientConnectionInfo, so it is safe to pass a string that is
  * managed by an external library.
  */
-static void
+void
 set_authn_id(Port *port, const char *id)
 {
 	Assert(id);
@@ -611,6 +614,9 @@ ClientAuthentication(Port *port)
 		case uaTrust:
 			status = STATUS_OK;
 			break;
+		case uaOAuth:
+			status = CheckSASLAuth(&pg_be_oauth_mech, port, NULL, NULL);
+			break;
 	}
 
 	if ((status == STATUS_OK && port->hba->clientcert == clientCertFull)
diff --git a/src/backend/libpq/hba.c b/src/backend/libpq/hba.c
index 510c9ffc6d7..332fad27835 100644
--- a/src/backend/libpq/hba.c
+++ b/src/backend/libpq/hba.c
@@ -32,6 +32,7 @@
 #include "libpq/hba.h"
 #include "libpq/ifaddr.h"
 #include "libpq/libpq-be.h"
+#include "libpq/oauth.h"
 #include "postmaster/postmaster.h"
 #include "regex/regex.h"
 #include "replication/walsender.h"
@@ -114,7 +115,8 @@ static const char *const UserAuthName[] =
 	"ldap",
 	"cert",
 	"radius",
-	"peer"
+	"peer",
+	"oauth",
 };
 
 /*
@@ -1747,6 +1749,8 @@ parse_hba_line(TokenizedAuthLine *tok_line, int elevel)
 #endif
 	else if (strcmp(token->string, "radius") == 0)
 		parsedline->auth_method = uaRADIUS;
+	else if (strcmp(token->string, "oauth") == 0)
+		parsedline->auth_method = uaOAuth;
 	else
 	{
 		ereport(elevel,
@@ -2039,6 +2043,36 @@ parse_hba_line(TokenizedAuthLine *tok_line, int elevel)
 		parsedline->clientcert = clientCertFull;
 	}
 
+	/*
+	 * Enforce proper configuration of OAuth authentication.
+	 */
+	if (parsedline->auth_method == uaOAuth)
+	{
+		MANDATORY_AUTH_ARG(parsedline->oauth_scope, "scope", "oauth");
+		MANDATORY_AUTH_ARG(parsedline->oauth_issuer, "issuer", "oauth");
+
+		/* Ensure a validator library is set and permitted by the config. */
+		if (!check_oauth_validator(parsedline, elevel, err_msg))
+			return NULL;
+
+		/*
+		 * Supplying a usermap combined with the option to skip usermapping is
+		 * nonsensical and indicates a configuration error.
+		 */
+		if (parsedline->oauth_skip_usermap && parsedline->usermap != NULL)
+		{
+			ereport(elevel,
+					errcode(ERRCODE_CONFIG_FILE_ERROR),
+			/* translator: strings are replaced with hba options */
+					errmsg("%s cannot be used in combination with %s",
+						   "map", "delegate_ident_mapping"),
+					errcontext("line %d of configuration file \"%s\"",
+							   line_num, file_name));
+			*err_msg = "map cannot be used in combination with delegate_ident_mapping";
+			return NULL;
+		}
+	}
+
 	return parsedline;
 }
 
@@ -2066,8 +2100,9 @@ parse_hba_auth_opt(char *name, char *val, HbaLine *hbaline,
 			hbaline->auth_method != uaPeer &&
 			hbaline->auth_method != uaGSS &&
 			hbaline->auth_method != uaSSPI &&
-			hbaline->auth_method != uaCert)
-			INVALID_AUTH_OPTION("map", gettext_noop("ident, peer, gssapi, sspi, and cert"));
+			hbaline->auth_method != uaCert &&
+			hbaline->auth_method != uaOAuth)
+			INVALID_AUTH_OPTION("map", gettext_noop("ident, peer, gssapi, sspi, cert, and oauth"));
 		hbaline->usermap = pstrdup(val);
 	}
 	else if (strcmp(name, "clientcert") == 0)
@@ -2450,6 +2485,29 @@ parse_hba_auth_opt(char *name, char *val, HbaLine *hbaline,
 		hbaline->radiusidentifiers = parsed_identifiers;
 		hbaline->radiusidentifiers_s = pstrdup(val);
 	}
+	else if (strcmp(name, "issuer") == 0)
+	{
+		REQUIRE_AUTH_OPTION(uaOAuth, "issuer", "oauth");
+		hbaline->oauth_issuer = pstrdup(val);
+	}
+	else if (strcmp(name, "scope") == 0)
+	{
+		REQUIRE_AUTH_OPTION(uaOAuth, "scope", "oauth");
+		hbaline->oauth_scope = pstrdup(val);
+	}
+	else if (strcmp(name, "validator") == 0)
+	{
+		REQUIRE_AUTH_OPTION(uaOAuth, "validator", "oauth");
+		hbaline->oauth_validator = pstrdup(val);
+	}
+	else if (strcmp(name, "delegate_ident_mapping") == 0)
+	{
+		REQUIRE_AUTH_OPTION(uaOAuth, "delegate_ident_mapping", "oauth");
+		if (strcmp(val, "1") == 0)
+			hbaline->oauth_skip_usermap = true;
+		else
+			hbaline->oauth_skip_usermap = false;
+	}
 	else
 	{
 		ereport(elevel,
diff --git a/src/backend/libpq/meson.build b/src/backend/libpq/meson.build
index 0f0421037e4..31aa2faae1e 100644
--- a/src/backend/libpq/meson.build
+++ b/src/backend/libpq/meson.build
@@ -1,6 +1,7 @@
 # Copyright (c) 2022-2025, PostgreSQL Global Development Group
 
 backend_sources += files(
+  'auth-oauth.c',
   'auth-sasl.c',
   'auth-scram.c',
   'auth.c',
diff --git a/src/backend/libpq/pg_hba.conf.sample b/src/backend/libpq/pg_hba.conf.sample
index bad13497a34..b64c8dea97c 100644
--- a/src/backend/libpq/pg_hba.conf.sample
+++ b/src/backend/libpq/pg_hba.conf.sample
@@ -53,8 +53,8 @@
 # directly connected to.
 #
 # METHOD can be "trust", "reject", "md5", "password", "scram-sha-256",
-# "gss", "sspi", "ident", "peer", "pam", "ldap", "radius" or "cert".
-# Note that "password" sends passwords in clear text; "md5" or
+# "gss", "sspi", "ident", "peer", "pam", "oauth", "ldap", "radius" or
+# "cert".  Note that "password" sends passwords in clear text; "md5" or
 # "scram-sha-256" are preferred since they send encrypted passwords.
 #
 # OPTIONS are a set of options for the authentication in the format
diff --git a/src/backend/utils/adt/hbafuncs.c b/src/backend/utils/adt/hbafuncs.c
index 03c38e8c451..b62c3d944cf 100644
--- a/src/backend/utils/adt/hbafuncs.c
+++ b/src/backend/utils/adt/hbafuncs.c
@@ -152,6 +152,25 @@ get_hba_options(HbaLine *hba)
 				CStringGetTextDatum(psprintf("radiusports=%s", hba->radiusports_s));
 	}
 
+	if (hba->auth_method == uaOAuth)
+	{
+		if (hba->oauth_issuer)
+			options[noptions++] =
+				CStringGetTextDatum(psprintf("issuer=%s", hba->oauth_issuer));
+
+		if (hba->oauth_scope)
+			options[noptions++] =
+				CStringGetTextDatum(psprintf("scope=%s", hba->oauth_scope));
+
+		if (hba->oauth_validator)
+			options[noptions++] =
+				CStringGetTextDatum(psprintf("validator=%s", hba->oauth_validator));
+
+		if (hba->oauth_skip_usermap)
+			options[noptions++] =
+				CStringGetTextDatum(psprintf("delegate_ident_mapping=true"));
+	}
+
 	/* If you add more options, consider increasing MAX_HBA_OPTIONS. */
 	Assert(noptions <= MAX_HBA_OPTIONS);
 
diff --git a/src/backend/utils/misc/guc_tables.c b/src/backend/utils/misc/guc_tables.c
index cce73314609..515091a3844 100644
--- a/src/backend/utils/misc/guc_tables.c
+++ b/src/backend/utils/misc/guc_tables.c
@@ -49,6 +49,7 @@
 #include "jit/jit.h"
 #include "libpq/auth.h"
 #include "libpq/libpq.h"
+#include "libpq/oauth.h"
 #include "libpq/scram.h"
 #include "nodes/queryjumble.h"
 #include "optimizer/cost.h"
@@ -4861,6 +4862,17 @@ struct config_string ConfigureNamesString[] =
 		check_restrict_nonsystem_relation_kind, assign_restrict_nonsystem_relation_kind, NULL
 	},
 
+	{
+		{"oauth_validator_libraries", PGC_SIGHUP, CONN_AUTH_AUTH,
+			gettext_noop("Lists libraries that may be called to validate OAuth v2 bearer tokens."),
+			NULL,
+			GUC_LIST_INPUT | GUC_LIST_QUOTE | GUC_SUPERUSER_ONLY
+		},
+		&oauth_validator_libraries_string,
+		"",
+		NULL, NULL, NULL
+	},
+
 	/* End-of-list marker */
 	{
 		{NULL, 0, 0, NULL, NULL}, NULL, NULL, NULL, NULL, NULL
diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample
index d472987ed46..ccefd214143 100644
--- a/src/backend/utils/misc/postgresql.conf.sample
+++ b/src/backend/utils/misc/postgresql.conf.sample
@@ -121,6 +121,9 @@
 #ssl_passphrase_command = ''
 #ssl_passphrase_command_supports_reload = off
 
+# OAuth
+#oauth_validator_libraries = ''	# comma-separated list of trusted validator modules
+
 
 #------------------------------------------------------------------------------
 # RESOURCE USAGE (except WAL)
diff --git a/src/include/common/oauth-common.h b/src/include/common/oauth-common.h
new file mode 100644
index 00000000000..5fb559d84b2
--- /dev/null
+++ b/src/include/common/oauth-common.h
@@ -0,0 +1,19 @@
+/*-------------------------------------------------------------------------
+ *
+ * oauth-common.h
+ *		Declarations for helper functions used for OAuth/OIDC authentication
+ *
+ * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/include/common/oauth-common.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef OAUTH_COMMON_H
+#define OAUTH_COMMON_H
+
+/* Name of SASL mechanism per IANA */
+#define OAUTHBEARER_NAME "OAUTHBEARER"
+
+#endif							/* OAUTH_COMMON_H */
diff --git a/src/include/libpq/auth.h b/src/include/libpq/auth.h
index 902c5f6de32..25b5742068f 100644
--- a/src/include/libpq/auth.h
+++ b/src/include/libpq/auth.h
@@ -39,6 +39,7 @@ extern PGDLLIMPORT bool pg_gss_accept_delegation;
 extern void ClientAuthentication(Port *port);
 extern void sendAuthRequest(Port *port, AuthRequest areq, const char *extradata,
 							int extralen);
+extern void set_authn_id(Port *port, const char *id);
 
 /* Hook for plugins to get control in ClientAuthentication() */
 typedef void (*ClientAuthentication_hook_type) (Port *, int);
diff --git a/src/include/libpq/hba.h b/src/include/libpq/hba.h
index b20d0051f7d..3657f182db3 100644
--- a/src/include/libpq/hba.h
+++ b/src/include/libpq/hba.h
@@ -39,7 +39,8 @@ typedef enum UserAuth
 	uaCert,
 	uaRADIUS,
 	uaPeer,
-#define USER_AUTH_LAST uaPeer	/* Must be last value of this enum */
+	uaOAuth,
+#define USER_AUTH_LAST uaOAuth	/* Must be last value of this enum */
 } UserAuth;
 
 /*
@@ -135,6 +136,10 @@ typedef struct HbaLine
 	char	   *radiusidentifiers_s;
 	List	   *radiusports;
 	char	   *radiusports_s;
+	char	   *oauth_issuer;
+	char	   *oauth_scope;
+	char	   *oauth_validator;
+	bool		oauth_skip_usermap;
 } HbaLine;
 
 typedef struct IdentLine
diff --git a/src/include/libpq/oauth.h b/src/include/libpq/oauth.h
new file mode 100644
index 00000000000..2f01b669633
--- /dev/null
+++ b/src/include/libpq/oauth.h
@@ -0,0 +1,101 @@
+/*-------------------------------------------------------------------------
+ *
+ * oauth.h
+ *	  Interface to libpq/auth-oauth.c
+ *
+ * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/include/libpq/oauth.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PG_OAUTH_H
+#define PG_OAUTH_H
+
+#include "libpq/libpq-be.h"
+#include "libpq/sasl.h"
+
+extern PGDLLIMPORT char *oauth_validator_libraries_string;
+
+typedef struct ValidatorModuleState
+{
+	/* Holds the server's PG_VERSION_NUM. Reserved for future extensibility. */
+	int			sversion;
+
+	/*
+	 * Private data pointer for use by a validator module. This can be used to
+	 * store state for the module that will be passed to each of its
+	 * callbacks.
+	 */
+	void	   *private_data;
+} ValidatorModuleState;
+
+typedef struct ValidatorModuleResult
+{
+	/*
+	 * Should be set to true if the token carries sufficient permissions for
+	 * the bearer to connect.
+	 */
+	bool		authorized;
+
+	/*
+	 * If the token authenticates the user, this should be set to a palloc'd
+	 * string containing the SYSTEM_USER to use for HBA mapping. Consider
+	 * setting this even if result->authorized is false so that DBAs may use
+	 * the logs to match end users to token failures.
+	 *
+	 * This is required if the module is not configured for ident mapping
+	 * delegation. See the validator module documentation for details.
+	 */
+	char	   *authn_id;
+} ValidatorModuleResult;
+
+/*
+ * Validator module callbacks
+ *
+ * These callback functions should be defined by validator modules and returned
+ * via _PG_oauth_validator_module_init().  ValidatorValidateCB is the only
+ * required callback. For more information about the purpose of each callback,
+ * refer to the OAuth validator modules documentation.
+ */
+typedef void (*ValidatorStartupCB) (ValidatorModuleState *state);
+typedef void (*ValidatorShutdownCB) (ValidatorModuleState *state);
+typedef bool (*ValidatorValidateCB) (const ValidatorModuleState *state,
+									 const char *token, const char *role,
+									 ValidatorModuleResult *result);
+
+/*
+ * Identifies the compiled ABI version of the validator module. Since the server
+ * already enforces the PG_MODULE_MAGIC number for modules across major
+ * versions, this is reserved for emergency use within a stable release line.
+ * May it never need to change.
+ */
+#define PG_OAUTH_VALIDATOR_MAGIC 0x20250207
+
+typedef struct OAuthValidatorCallbacks
+{
+	uint32		magic;			/* must be set to PG_OAUTH_VALIDATOR_MAGIC */
+
+	ValidatorStartupCB startup_cb;
+	ValidatorShutdownCB shutdown_cb;
+	ValidatorValidateCB validate_cb;
+} OAuthValidatorCallbacks;
+
+/*
+ * Type of the shared library symbol _PG_oauth_validator_module_init which is
+ * required for all validator modules.  This function will be invoked during
+ * module loading.
+ */
+typedef const OAuthValidatorCallbacks *(*OAuthValidatorModuleInit) (void);
+extern PGDLLEXPORT const OAuthValidatorCallbacks *_PG_oauth_validator_module_init(void);
+
+/* Implementation */
+extern const pg_be_sasl_mech pg_be_oauth_mech;
+
+/*
+ * Ensure a validator named in the HBA is permitted by the configuration.
+ */
+extern bool check_oauth_validator(HbaLine *hba, int elevel, char **err_msg);
+
+#endif							/* PG_OAUTH_H */
diff --git a/src/include/pg_config.h.in b/src/include/pg_config.h.in
index 07b2f798abd..db6454090d2 100644
--- a/src/include/pg_config.h.in
+++ b/src/include/pg_config.h.in
@@ -229,6 +229,9 @@
 /* Define to 1 if you have the `crypto' library (-lcrypto). */
 #undef HAVE_LIBCRYPTO
 
+/* Define to 1 if you have the `curl' library (-lcurl). */
+#undef HAVE_LIBCURL
+
 /* Define to 1 if you have the `ldap' library (-lldap). */
 #undef HAVE_LIBLDAP
 
@@ -442,6 +445,9 @@
 /* Define to 1 if you have the <termios.h> header file. */
 #undef HAVE_TERMIOS_H
 
+/* Define to 1 if curl_global_init() is guaranteed to be thread-safe. */
+#undef HAVE_THREADSAFE_CURL_GLOBAL_INIT
+
 /* Define to 1 if your compiler understands `typeof' or something similar. */
 #undef HAVE_TYPEOF
 
@@ -663,6 +669,9 @@
 /* Define to 1 to build with LDAP support. (--with-ldap) */
 #undef USE_LDAP
 
+/* Define to 1 to build with libcurl support. (--with-libcurl) */
+#undef USE_LIBCURL
+
 /* Define to 1 to build with XML support. (--with-libxml) */
 #undef USE_LIBXML
 
diff --git a/src/interfaces/libpq/Makefile b/src/interfaces/libpq/Makefile
index 701810a272a..90b0b65db6f 100644
--- a/src/interfaces/libpq/Makefile
+++ b/src/interfaces/libpq/Makefile
@@ -31,6 +31,7 @@ endif
 
 OBJS = \
 	$(WIN32RES) \
+	fe-auth-oauth.o \
 	fe-auth-scram.o \
 	fe-cancel.o \
 	fe-connect.o \
@@ -63,6 +64,10 @@ OBJS += \
 	fe-secure-gssapi.o
 endif
 
+ifeq ($(with_libcurl),yes)
+OBJS += fe-auth-oauth-curl.o
+endif
+
 ifeq ($(PORTNAME), cygwin)
 override shlib = cyg$(NAME)$(DLSUFFIX)
 endif
@@ -81,7 +86,7 @@ endif
 # that are built correctly for use in a shlib.
 SHLIB_LINK_INTERNAL = -lpgcommon_shlib -lpgport_shlib
 ifneq ($(PORTNAME), win32)
-SHLIB_LINK += $(filter -lcrypt -ldes -lcom_err -lcrypto -lk5crypto -lkrb5 -lgssapi_krb5 -lgss -lgssapi -lssl -lsocket -lnsl -lresolv -lintl -lm, $(LIBS)) $(LDAP_LIBS_FE) $(PTHREAD_LIBS)
+SHLIB_LINK += $(filter -lcrypt -ldes -lcom_err -lcrypto -lk5crypto -lkrb5 -lgssapi_krb5 -lgss -lgssapi -lssl -lcurl -lsocket -lnsl -lresolv -lintl -lm, $(LIBS)) $(LDAP_LIBS_FE) $(PTHREAD_LIBS)
 else
 SHLIB_LINK += $(filter -lcrypt -ldes -lcom_err -lcrypto -lk5crypto -lkrb5 -lgssapi32 -lssl -lsocket -lnsl -lresolv -lintl -lm $(PTHREAD_LIBS), $(LIBS)) $(LDAP_LIBS_FE)
 endif
@@ -110,6 +115,8 @@ backend_src = $(top_srcdir)/src/backend
 # which seems to insert references to that even in pure C code. Excluding
 # __tsan_func_exit is necessary when using ThreadSanitizer data race detector
 # which use this function for instrumentation of function exit.
+# libcurl registers an exit handler in the memory debugging code when running
+# with LeakSanitizer.
 # Skip the test when profiling, as gcc may insert exit() calls for that.
 # Also skip the test on platforms where libpq infrastructure may be provided
 # by statically-linked libraries, as we can't expect them to honor this
@@ -117,7 +124,7 @@ backend_src = $(top_srcdir)/src/backend
 libpq-refs-stamp: $(shlib)
 ifneq ($(enable_coverage), yes)
 ifeq (,$(filter solaris,$(PORTNAME)))
-	@if nm -A -u $< 2>/dev/null | grep -v -e __cxa_atexit -e __tsan_func_exit | grep exit; then \
+	@if nm -A -u $< 2>/dev/null | grep -v -e __cxa_atexit -e __tsan_func_exit -e _atexit | grep exit; then \
 		echo 'libpq must not be calling any function which invokes exit'; exit 1; \
 	fi
 endif
diff --git a/src/interfaces/libpq/exports.txt b/src/interfaces/libpq/exports.txt
index 2ad2cbf5ca3..9b789cbec0b 100644
--- a/src/interfaces/libpq/exports.txt
+++ b/src/interfaces/libpq/exports.txt
@@ -206,3 +206,6 @@ PQsocketPoll              203
 PQsetChunkedRowsMode      204
 PQgetCurrentTimeUSec      205
 PQservice                 206
+PQsetAuthDataHook         207
+PQgetAuthDataHook         208
+PQdefaultAuthDataHook     209
diff --git a/src/interfaces/libpq/fe-auth-oauth-curl.c b/src/interfaces/libpq/fe-auth-oauth-curl.c
new file mode 100644
index 00000000000..a80e2047bb7
--- /dev/null
+++ b/src/interfaces/libpq/fe-auth-oauth-curl.c
@@ -0,0 +1,2883 @@
+/*-------------------------------------------------------------------------
+ *
+ * fe-auth-oauth-curl.c
+ *	   The libcurl implementation of OAuth/OIDC authentication, using the
+ *	   OAuth Device Authorization Grant (RFC 8628).
+ *
+ * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/interfaces/libpq/fe-auth-oauth-curl.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres_fe.h"
+
+#include <curl/curl.h>
+#include <math.h>
+#ifdef HAVE_SYS_EPOLL_H
+#include <sys/epoll.h>
+#include <sys/timerfd.h>
+#endif
+#ifdef HAVE_SYS_EVENT_H
+#include <sys/event.h>
+#endif
+#include <unistd.h>
+
+#include "common/jsonapi.h"
+#include "fe-auth.h"
+#include "fe-auth-oauth.h"
+#include "libpq-int.h"
+#include "mb/pg_wchar.h"
+
+/*
+ * It's generally prudent to set a maximum response size to buffer in memory,
+ * but it's less clear what size to choose. The biggest of our expected
+ * responses is the server metadata JSON, which will only continue to grow in
+ * size; the number of IANA-registered parameters in that document is up to 78
+ * as of February 2025.
+ *
+ * Even if every single parameter were to take up 2k on average (a previously
+ * common limit on the size of a URL), 256k gives us 128 parameter values before
+ * we give up. (That's almost certainly complete overkill in practice; 2-4k
+ * appears to be common among popular providers at the moment.)
+ */
+#define MAX_OAUTH_RESPONSE_SIZE (256 * 1024)
+
+/*
+ * Parsed JSON Representations
+ *
+ * As a general rule, we parse and cache only the fields we're currently using.
+ * When adding new fields, ensure the corresponding free_*() function is updated
+ * too.
+ */
+
+/*
+ * The OpenID Provider configuration (alternatively named "authorization server
+ * metadata") jointly described by OpenID Connect Discovery 1.0 and RFC 8414:
+ *
+ *     https://openid.net/specs/openid-connect-discovery-1_0.html
+ *     https://www.rfc-editor.org/rfc/rfc8414#section-3.2
+ */
+struct provider
+{
+	char	   *issuer;
+	char	   *token_endpoint;
+	char	   *device_authorization_endpoint;
+	struct curl_slist *grant_types_supported;
+};
+
+static void
+free_provider(struct provider *provider)
+{
+	free(provider->issuer);
+	free(provider->token_endpoint);
+	free(provider->device_authorization_endpoint);
+	curl_slist_free_all(provider->grant_types_supported);
+}
+
+/*
+ * The Device Authorization response, described by RFC 8628:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc8628#section-3.2
+ */
+struct device_authz
+{
+	char	   *device_code;
+	char	   *user_code;
+	char	   *verification_uri;
+	char	   *verification_uri_complete;
+	char	   *expires_in_str;
+	char	   *interval_str;
+
+	/* Fields below are parsed from the corresponding string above. */
+	int			expires_in;
+	int			interval;
+};
+
+static void
+free_device_authz(struct device_authz *authz)
+{
+	free(authz->device_code);
+	free(authz->user_code);
+	free(authz->verification_uri);
+	free(authz->verification_uri_complete);
+	free(authz->expires_in_str);
+	free(authz->interval_str);
+}
+
+/*
+ * The Token Endpoint error response, as described by RFC 6749:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc6749#section-5.2
+ *
+ * Note that this response type can also be returned from the Device
+ * Authorization Endpoint.
+ */
+struct token_error
+{
+	char	   *error;
+	char	   *error_description;
+};
+
+static void
+free_token_error(struct token_error *err)
+{
+	free(err->error);
+	free(err->error_description);
+}
+
+/*
+ * The Access Token response, as described by RFC 6749:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc6749#section-4.1.4
+ *
+ * During the Device Authorization flow, several temporary errors are expected
+ * as part of normal operation. To make it easy to handle these in the happy
+ * path, this contains an embedded token_error that is filled in if needed.
+ */
+struct token
+{
+	/* for successful responses */
+	char	   *access_token;
+	char	   *token_type;
+
+	/* for error responses */
+	struct token_error err;
+};
+
+static void
+free_token(struct token *tok)
+{
+	free(tok->access_token);
+	free(tok->token_type);
+	free_token_error(&tok->err);
+}
+
+/*
+ * Asynchronous State
+ */
+
+/* States for the overall async machine. */
+enum OAuthStep
+{
+	OAUTH_STEP_INIT = 0,
+	OAUTH_STEP_DISCOVERY,
+	OAUTH_STEP_DEVICE_AUTHORIZATION,
+	OAUTH_STEP_TOKEN_REQUEST,
+	OAUTH_STEP_WAIT_INTERVAL,
+};
+
+/*
+ * The async_ctx holds onto state that needs to persist across multiple calls
+ * to pg_fe_run_oauth_flow(). Almost everything interacts with this in some
+ * way.
+ */
+struct async_ctx
+{
+	enum OAuthStep step;		/* where are we in the flow? */
+
+	int			timerfd;		/* descriptor for signaling async timeouts */
+	pgsocket	mux;			/* the multiplexer socket containing all
+								 * descriptors tracked by libcurl, plus the
+								 * timerfd */
+	CURLM	   *curlm;			/* top-level multi handle for libcurl
+								 * operations */
+	CURL	   *curl;			/* the (single) easy handle for serial
+								 * requests */
+
+	struct curl_slist *headers; /* common headers for all requests */
+	PQExpBufferData work_data;	/* scratch buffer for general use (remember to
+								 * clear out prior contents first!) */
+
+	/*------
+	 * Since a single logical operation may stretch across multiple calls to
+	 * our entry point, errors have three parts:
+	 *
+	 * - errctx:	an optional static string, describing the global operation
+	 *				currently in progress. It'll be translated for you.
+	 *
+	 * - errbuf:	contains the actual error message. Generally speaking, use
+	 *				actx_error[_str] to manipulate this. This must be filled
+	 *				with something useful on an error.
+	 *
+	 * - curl_err:	an optional static error buffer used by libcurl to put
+	 *				detailed information about failures. Unfortunately
+	 *				untranslatable.
+	 *
+	 * These pieces will be combined into a single error message looking
+	 * something like the following, with errctx and/or curl_err omitted when
+	 * absent:
+	 *
+	 *     connection to server ... failed: errctx: errbuf (libcurl: curl_err)
+	 */
+	const char *errctx;			/* not freed; must point to static allocation */
+	PQExpBufferData errbuf;
+	char		curl_err[CURL_ERROR_SIZE];
+
+	/*
+	 * These documents need to survive over multiple calls, and are therefore
+	 * cached directly in the async_ctx.
+	 */
+	struct provider provider;
+	struct device_authz authz;
+
+	int			running;		/* is asynchronous work in progress? */
+	bool		user_prompted;	/* have we already sent the authz prompt? */
+	bool		used_basic_auth;	/* did we send a client secret? */
+	bool		debugging;		/* can we give unsafe developer assistance? */
+};
+
+/*
+ * Tears down the Curl handles and frees the async_ctx.
+ */
+static void
+free_async_ctx(PGconn *conn, struct async_ctx *actx)
+{
+	/*
+	 * In general, none of the error cases below should ever happen if we have
+	 * no bugs above. But if we do hit them, surfacing those errors somehow
+	 * might be the only way to have a chance to debug them.
+	 *
+	 * TODO: At some point it'd be nice to have a standard way to warn about
+	 * teardown failures. Appending to the connection's error message only
+	 * helps if the bug caused a connection failure; otherwise it'll be
+	 * buried...
+	 */
+
+	if (actx->curlm && actx->curl)
+	{
+		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));
+	}
+
+	if (actx->curl)
+	{
+		/*
+		 * curl_multi_cleanup() doesn't free any associated easy handles; we
+		 * need to do that separately. We only ever have one easy handle per
+		 * multi handle.
+		 */
+		curl_easy_cleanup(actx->curl);
+	}
+
+	if (actx->curlm)
+	{
+		CURLMcode	err = curl_multi_cleanup(actx->curlm);
+
+		if (err)
+			libpq_append_conn_error(conn,
+									"libcurl multi handle cleanup failed: %s",
+									curl_multi_strerror(err));
+	}
+
+	free_provider(&actx->provider);
+	free_device_authz(&actx->authz);
+
+	curl_slist_free_all(actx->headers);
+	termPQExpBuffer(&actx->work_data);
+	termPQExpBuffer(&actx->errbuf);
+
+	if (actx->mux != PGINVALID_SOCKET)
+		close(actx->mux);
+	if (actx->timerfd >= 0)
+		close(actx->timerfd);
+
+	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.
+ */
+void
+pg_fe_cleanup_oauth_flow(PGconn *conn)
+{
+	fe_oauth_state *state = conn->sasl_state;
+
+	if (state->async_ctx)
+	{
+		free_async_ctx(conn, state->async_ctx);
+		state->async_ctx = NULL;
+	}
+
+	conn->altsock = PGINVALID_SOCKET;
+}
+
+/*
+ * Macros for manipulating actx->errbuf. actx_error() translates and formats a
+ * string for you; actx_error_str() appends a string directly without
+ * translation.
+ */
+
+#define actx_error(ACTX, FMT, ...) \
+	appendPQExpBuffer(&(ACTX)->errbuf, libpq_gettext(FMT), ##__VA_ARGS__)
+
+#define actx_error_str(ACTX, S) \
+	appendPQExpBufferStr(&(ACTX)->errbuf, S)
+
+/*
+ * Macros for getting and setting state for the connection's two libcurl
+ * handles, so you don't have to write out the error handling every time.
+ */
+
+#define CHECK_MSETOPT(ACTX, OPT, VAL, FAILACTION) \
+	do { \
+		struct async_ctx *_actx = (ACTX); \
+		CURLMcode	_setopterr = curl_multi_setopt(_actx->curlm, OPT, VAL); \
+		if (_setopterr) { \
+			actx_error(_actx, "failed to set %s on OAuth connection: %s",\
+					   #OPT, curl_multi_strerror(_setopterr)); \
+			FAILACTION; \
+		} \
+	} while (0)
+
+#define CHECK_SETOPT(ACTX, OPT, VAL, FAILACTION) \
+	do { \
+		struct async_ctx *_actx = (ACTX); \
+		CURLcode	_setopterr = curl_easy_setopt(_actx->curl, OPT, VAL); \
+		if (_setopterr) { \
+			actx_error(_actx, "failed to set %s on OAuth connection: %s",\
+					   #OPT, curl_easy_strerror(_setopterr)); \
+			FAILACTION; \
+		} \
+	} while (0)
+
+#define CHECK_GETINFO(ACTX, INFO, OUT, FAILACTION) \
+	do { \
+		struct async_ctx *_actx = (ACTX); \
+		CURLcode	_getinfoerr = curl_easy_getinfo(_actx->curl, INFO, OUT); \
+		if (_getinfoerr) { \
+			actx_error(_actx, "failed to get %s from OAuth response: %s",\
+					   #INFO, curl_easy_strerror(_getinfoerr)); \
+			FAILACTION; \
+		} \
+	} while (0)
+
+/*
+ * General JSON Parsing for OAuth Responses
+ */
+
+/*
+ * Represents a single name/value pair in a JSON object. This is the primary
+ * interface to parse_oauth_json().
+ *
+ * All fields are stored internally as strings or lists of strings, so clients
+ * have to explicitly parse other scalar types (though they will have gone
+ * through basic lexical validation). Storing nested objects is not currently
+ * supported, nor is parsing arrays of anything other than strings.
+ */
+struct json_field
+{
+	const char *name;			/* name (key) of the member */
+
+	JsonTokenType type;			/* currently supports JSON_TOKEN_STRING,
+								 * JSON_TOKEN_NUMBER, and
+								 * JSON_TOKEN_ARRAY_START */
+	union
+	{
+		char	  **scalar;		/* for all scalar types */
+		struct curl_slist **array;	/* for type == JSON_TOKEN_ARRAY_START */
+	}			target;
+
+	bool		required;		/* REQUIRED field, or just OPTIONAL? */
+};
+
+/* Documentation macros for json_field.required. */
+#define REQUIRED true
+#define OPTIONAL false
+
+/* Parse state for parse_oauth_json(). */
+struct oauth_parse
+{
+	PQExpBuffer errbuf;			/* detail message for JSON_SEM_ACTION_FAILED */
+	int			nested;			/* nesting level (zero is the top) */
+
+	const struct json_field *fields;	/* field definition array */
+	const struct json_field *active;	/* points inside the fields array */
+};
+
+#define oauth_parse_set_error(ctx, fmt, ...) \
+	appendPQExpBuffer((ctx)->errbuf, libpq_gettext(fmt), ##__VA_ARGS__)
+
+static void
+report_type_mismatch(struct oauth_parse *ctx)
+{
+	char	   *msgfmt;
+
+	Assert(ctx->active);
+
+	/*
+	 * At the moment, the only fields we're interested in are strings,
+	 * numbers, and arrays of strings.
+	 */
+	switch (ctx->active->type)
+	{
+		case JSON_TOKEN_STRING:
+			msgfmt = "field \"%s\" must be a string";
+			break;
+
+		case JSON_TOKEN_NUMBER:
+			msgfmt = "field \"%s\" must be a number";
+			break;
+
+		case JSON_TOKEN_ARRAY_START:
+			msgfmt = "field \"%s\" must be an array of strings";
+			break;
+
+		default:
+			Assert(false);
+			msgfmt = "field \"%s\" has unexpected type";
+	}
+
+	oauth_parse_set_error(ctx, msgfmt, ctx->active->name);
+}
+
+static JsonParseErrorType
+oauth_json_object_start(void *state)
+{
+	struct oauth_parse *ctx = state;
+
+	if (ctx->active)
+	{
+		/*
+		 * Currently, none of the fields we're interested in can be or contain
+		 * objects, so we can reject this case outright.
+		 */
+		report_type_mismatch(ctx);
+		return JSON_SEM_ACTION_FAILED;
+	}
+
+	++ctx->nested;
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_object_field_start(void *state, char *name, bool isnull)
+{
+	struct oauth_parse *ctx = state;
+
+	/* We care only about the top-level fields. */
+	if (ctx->nested == 1)
+	{
+		const struct json_field *field = ctx->fields;
+
+		/*
+		 * We should never start parsing a new field while a previous one is
+		 * still active.
+		 */
+		if (ctx->active)
+		{
+			Assert(false);
+			oauth_parse_set_error(ctx,
+								  "internal error: started field '%s' before field '%s' was finished",
+								  name, ctx->active->name);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		while (field->name)
+		{
+			if (strcmp(name, field->name) == 0)
+			{
+				ctx->active = field;
+				break;
+			}
+
+			++field;
+		}
+
+		/*
+		 * We don't allow duplicate field names; error out if the target has
+		 * already been set.
+		 */
+		if (ctx->active)
+		{
+			field = ctx->active;
+
+			if ((field->type == JSON_TOKEN_ARRAY_START && *field->target.array)
+				|| (field->type != JSON_TOKEN_ARRAY_START && *field->target.scalar))
+			{
+				oauth_parse_set_error(ctx, "field \"%s\" is duplicated",
+									  field->name);
+				return JSON_SEM_ACTION_FAILED;
+			}
+		}
+	}
+
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_object_end(void *state)
+{
+	struct oauth_parse *ctx = state;
+
+	--ctx->nested;
+
+	/*
+	 * All fields should be fully processed by the end of the top-level
+	 * object.
+	 */
+	if (!ctx->nested && ctx->active)
+	{
+		Assert(false);
+		oauth_parse_set_error(ctx,
+							  "internal error: field '%s' still active at end of object",
+							  ctx->active->name);
+		return JSON_SEM_ACTION_FAILED;
+	}
+
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_array_start(void *state)
+{
+	struct oauth_parse *ctx = state;
+
+	if (!ctx->nested)
+	{
+		oauth_parse_set_error(ctx, "top-level element must be an object");
+		return JSON_SEM_ACTION_FAILED;
+	}
+
+	if (ctx->active)
+	{
+		if (ctx->active->type != JSON_TOKEN_ARRAY_START
+		/* The arrays we care about must not have arrays as values. */
+			|| ctx->nested > 1)
+		{
+			report_type_mismatch(ctx);
+			return JSON_SEM_ACTION_FAILED;
+		}
+	}
+
+	++ctx->nested;
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_array_end(void *state)
+{
+	struct oauth_parse *ctx = state;
+
+	if (ctx->active)
+	{
+		/*
+		 * Clear the target (which should be an array inside the top-level
+		 * object). For this to be safe, no target arrays can contain other
+		 * arrays; we check for that in the array_start callback.
+		 */
+		if (ctx->nested != 2 || ctx->active->type != JSON_TOKEN_ARRAY_START)
+		{
+			Assert(false);
+			oauth_parse_set_error(ctx,
+								  "internal error: found unexpected array end while parsing field '%s'",
+								  ctx->active->name);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		ctx->active = NULL;
+	}
+
+	--ctx->nested;
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_scalar(void *state, char *token, JsonTokenType type)
+{
+	struct oauth_parse *ctx = state;
+
+	if (!ctx->nested)
+	{
+		oauth_parse_set_error(ctx, "top-level element must be an object");
+		return JSON_SEM_ACTION_FAILED;
+	}
+
+	if (ctx->active)
+	{
+		const struct json_field *field = ctx->active;
+		JsonTokenType expected = field->type;
+
+		/* Make sure this matches what the active field expects. */
+		if (expected == JSON_TOKEN_ARRAY_START)
+		{
+			/* Are we actually inside an array? */
+			if (ctx->nested < 2)
+			{
+				report_type_mismatch(ctx);
+				return JSON_SEM_ACTION_FAILED;
+			}
+
+			/* Currently, arrays can only contain strings. */
+			expected = JSON_TOKEN_STRING;
+		}
+
+		if (type != expected)
+		{
+			report_type_mismatch(ctx);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		if (field->type != JSON_TOKEN_ARRAY_START)
+		{
+			/* Ensure that we're parsing the top-level keys... */
+			if (ctx->nested != 1)
+			{
+				Assert(false);
+				oauth_parse_set_error(ctx,
+									  "internal error: scalar target found at nesting level %d",
+									  ctx->nested);
+				return JSON_SEM_ACTION_FAILED;
+			}
+
+			/* ...and that a result has not already been set. */
+			if (*field->target.scalar)
+			{
+				Assert(false);
+				oauth_parse_set_error(ctx,
+									  "internal error: scalar field '%s' would be assigned twice",
+									  ctx->active->name);
+				return JSON_SEM_ACTION_FAILED;
+			}
+
+			*field->target.scalar = strdup(token);
+			if (!*field->target.scalar)
+				return JSON_OUT_OF_MEMORY;
+
+			ctx->active = NULL;
+
+			return JSON_SUCCESS;
+		}
+		else
+		{
+			struct curl_slist *temp;
+
+			/* The target array should be inside the top-level object. */
+			if (ctx->nested != 2)
+			{
+				Assert(false);
+				oauth_parse_set_error(ctx,
+									  "internal error: array member found at nesting level %d",
+									  ctx->nested);
+				return JSON_SEM_ACTION_FAILED;
+			}
+
+			/* Note that curl_slist_append() makes a copy of the token. */
+			temp = curl_slist_append(*field->target.array, token);
+			if (!temp)
+				return JSON_OUT_OF_MEMORY;
+
+			*field->target.array = temp;
+		}
+	}
+	else
+	{
+		/* otherwise we just ignore it */
+	}
+
+	return JSON_SUCCESS;
+}
+
+/*
+ * Checks the Content-Type header against the expected type. Parameters are
+ * allowed but ignored.
+ */
+static bool
+check_content_type(struct async_ctx *actx, const char *type)
+{
+	const size_t type_len = strlen(type);
+	char	   *content_type;
+
+	CHECK_GETINFO(actx, CURLINFO_CONTENT_TYPE, &content_type, return false);
+
+	if (!content_type)
+	{
+		actx_error(actx, "no content type was provided");
+		return false;
+	}
+
+	/*
+	 * We need to perform a length limited comparison and not compare the
+	 * whole string.
+	 */
+	if (pg_strncasecmp(content_type, type, type_len) != 0)
+		goto fail;
+
+	/* On an exact match, we're done. */
+	Assert(strlen(content_type) >= type_len);
+	if (content_type[type_len] == '\0')
+		return true;
+
+	/*
+	 * Only a semicolon (optionally preceded by HTTP optional whitespace) is
+	 * acceptable after the prefix we checked. This marks the start of media
+	 * type parameters, which we currently have no use for.
+	 */
+	for (size_t i = type_len; content_type[i]; ++i)
+	{
+		switch (content_type[i])
+		{
+			case ';':
+				return true;	/* success! */
+
+			case ' ':
+			case '\t':
+				/* HTTP optional whitespace allows only spaces and htabs. */
+				break;
+
+			default:
+				goto fail;
+		}
+	}
+
+fail:
+	actx_error(actx, "unexpected content type: \"%s\"", content_type);
+	return false;
+}
+
+/*
+ * A helper function for general JSON parsing. fields is the array of field
+ * definitions with their backing pointers. The response will be parsed from
+ * actx->curl and actx->work_data (as set up by start_request()), and any
+ * parsing errors will be placed into actx->errbuf.
+ */
+static bool
+parse_oauth_json(struct async_ctx *actx, const struct json_field *fields)
+{
+	PQExpBuffer resp = &actx->work_data;
+	JsonLexContext lex = {0};
+	JsonSemAction sem = {0};
+	JsonParseErrorType err;
+	struct oauth_parse ctx = {0};
+	bool		success = false;
+
+	if (!check_content_type(actx, "application/json"))
+		return false;
+
+	if (strlen(resp->data) != resp->len)
+	{
+		actx_error(actx, "response contains embedded NULLs");
+		return false;
+	}
+
+	/*
+	 * pg_parse_json doesn't validate the incoming UTF-8, so we have to check
+	 * that up front.
+	 */
+	if (pg_encoding_verifymbstr(PG_UTF8, resp->data, resp->len) != resp->len)
+	{
+		actx_error(actx, "response is not valid UTF-8");
+		return false;
+	}
+
+	makeJsonLexContextCstringLen(&lex, resp->data, resp->len, PG_UTF8, true);
+	setJsonLexContextOwnsTokens(&lex, true);	/* must not leak on error */
+
+	ctx.errbuf = &actx->errbuf;
+	ctx.fields = fields;
+	sem.semstate = &ctx;
+
+	sem.object_start = oauth_json_object_start;
+	sem.object_field_start = oauth_json_object_field_start;
+	sem.object_end = oauth_json_object_end;
+	sem.array_start = oauth_json_array_start;
+	sem.array_end = oauth_json_array_end;
+	sem.scalar = oauth_json_scalar;
+
+	err = pg_parse_json(&lex, &sem);
+
+	if (err != JSON_SUCCESS)
+	{
+		/*
+		 * For JSON_SEM_ACTION_FAILED, we've already written the error
+		 * message. Other errors come directly from pg_parse_json(), already
+		 * translated.
+		 */
+		if (err != JSON_SEM_ACTION_FAILED)
+			actx_error_str(actx, json_errdetail(err, &lex));
+
+		goto cleanup;
+	}
+
+	/* Check all required fields. */
+	while (fields->name)
+	{
+		if (fields->required
+			&& !*fields->target.scalar
+			&& !*fields->target.array)
+		{
+			actx_error(actx, "field \"%s\" is missing", fields->name);
+			goto cleanup;
+		}
+
+		fields++;
+	}
+
+	success = true;
+
+cleanup:
+	freeJsonLexContext(&lex);
+	return success;
+}
+
+/*
+ * JSON Parser Definitions
+ */
+
+/*
+ * Parses authorization server metadata. Fields are defined by OIDC Discovery
+ * 1.0 and RFC 8414.
+ */
+static bool
+parse_provider(struct async_ctx *actx, struct provider *provider)
+{
+	struct json_field fields[] = {
+		{"issuer", JSON_TOKEN_STRING, {&provider->issuer}, REQUIRED},
+		{"token_endpoint", JSON_TOKEN_STRING, {&provider->token_endpoint}, REQUIRED},
+
+		/*----
+		 * The following fields are technically REQUIRED, but we don't use
+		 * them anywhere yet:
+		 *
+		 * - jwks_uri
+		 * - response_types_supported
+		 * - subject_types_supported
+		 * - id_token_signing_alg_values_supported
+		 */
+
+		{"device_authorization_endpoint", JSON_TOKEN_STRING, {&provider->device_authorization_endpoint}, OPTIONAL},
+		{"grant_types_supported", JSON_TOKEN_ARRAY_START, {.array = &provider->grant_types_supported}, OPTIONAL},
+
+		{0},
+	};
+
+	return parse_oauth_json(actx, fields);
+}
+
+/*
+ * Parses a valid JSON number into a double. The input must have come from
+ * pg_parse_json(), so that we know the lexer has validated it; there's no
+ * in-band signal for invalid formats.
+ */
+static double
+parse_json_number(const char *s)
+{
+	double		parsed;
+	int			cnt;
+
+	/*
+	 * The JSON lexer has already validated the number, which is stricter than
+	 * the %f format, so we should be good to use sscanf().
+	 */
+	cnt = sscanf(s, "%lf", &parsed);
+
+	if (cnt != 1)
+	{
+		/*
+		 * Either the lexer screwed up or our assumption above isn't true, and
+		 * either way a developer needs to take a look.
+		 */
+		Assert(false);
+		return 0;
+	}
+
+	return parsed;
+}
+
+/*
+ * Parses the "interval" JSON number, corresponding to the number of seconds to
+ * wait between token endpoint requests.
+ *
+ * RFC 8628 is pretty silent on sanity checks for the interval. As a matter of
+ * practicality, round any fractional intervals up to the next second, and clamp
+ * the result at a minimum of one. (Zero-second intervals would result in an
+ * expensive network polling loop.) Tests may remove the lower bound with
+ * PGOAUTHDEBUG, for improved performance.
+ */
+static int
+parse_interval(struct async_ctx *actx, const char *interval_str)
+{
+	double		parsed;
+
+	parsed = parse_json_number(interval_str);
+	parsed = ceil(parsed);
+
+	if (parsed < 1)
+		return actx->debugging ? 0 : 1;
+
+	else if (parsed >= INT_MAX)
+		return INT_MAX;
+
+	return parsed;
+}
+
+/*
+ * Parses the "expires_in" JSON number, corresponding to the number of seconds
+ * remaining in the lifetime of the device code request.
+ *
+ * Similar to parse_interval, but we have even fewer requirements for reasonable
+ * values since we don't use the expiration time directly (it's passed to the
+ * PQAUTHDATA_PROMPT_OAUTH_DEVICE hook, in case the application wants to do
+ * something with it). We simply round down and clamp to int range.
+ */
+static int
+parse_expires_in(struct async_ctx *actx, const char *expires_in_str)
+{
+	double		parsed;
+
+	parsed = parse_json_number(expires_in_str);
+	parsed = floor(parsed);
+
+	if (parsed >= INT_MAX)
+		return INT_MAX;
+	else if (parsed <= INT_MIN)
+		return INT_MIN;
+
+	return parsed;
+}
+
+/*
+ * Parses the Device Authorization Response (RFC 8628, Sec. 3.2).
+ */
+static bool
+parse_device_authz(struct async_ctx *actx, struct device_authz *authz)
+{
+	struct json_field fields[] = {
+		{"device_code", JSON_TOKEN_STRING, {&authz->device_code}, REQUIRED},
+		{"user_code", JSON_TOKEN_STRING, {&authz->user_code}, REQUIRED},
+		{"verification_uri", JSON_TOKEN_STRING, {&authz->verification_uri}, REQUIRED},
+		{"expires_in", JSON_TOKEN_NUMBER, {&authz->expires_in_str}, REQUIRED},
+
+		/*
+		 * Some services (Google, Azure) spell verification_uri differently.
+		 * We accept either.
+		 */
+		{"verification_url", JSON_TOKEN_STRING, {&authz->verification_uri}, REQUIRED},
+
+		/*
+		 * There is no evidence of verification_uri_complete being spelled
+		 * with "url" instead with any service provider, so only support
+		 * "uri".
+		 */
+		{"verification_uri_complete", JSON_TOKEN_STRING, {&authz->verification_uri_complete}, OPTIONAL},
+		{"interval", JSON_TOKEN_NUMBER, {&authz->interval_str}, OPTIONAL},
+
+		{0},
+	};
+
+	if (!parse_oauth_json(actx, fields))
+		return false;
+
+	/*
+	 * Parse our numeric fields. Lexing has already completed by this time, so
+	 * we at least know they're valid JSON numbers.
+	 */
+	if (authz->interval_str)
+		authz->interval = parse_interval(actx, authz->interval_str);
+	else
+	{
+		/*
+		 * RFC 8628 specifies 5 seconds as the default value if the server
+		 * doesn't provide an interval.
+		 */
+		authz->interval = 5;
+	}
+
+	Assert(authz->expires_in_str);	/* ensured by parse_oauth_json() */
+	authz->expires_in = parse_expires_in(actx, authz->expires_in_str);
+
+	return true;
+}
+
+/*
+ * Parses the device access token error response (RFC 8628, Sec. 3.5, which
+ * uses the error response defined in RFC 6749, Sec. 5.2).
+ */
+static bool
+parse_token_error(struct async_ctx *actx, struct token_error *err)
+{
+	bool		result;
+	struct json_field fields[] = {
+		{"error", JSON_TOKEN_STRING, {&err->error}, REQUIRED},
+
+		{"error_description", JSON_TOKEN_STRING, {&err->error_description}, OPTIONAL},
+
+		{0},
+	};
+
+	result = parse_oauth_json(actx, fields);
+
+	/*
+	 * Since token errors are parsed during other active error paths, only
+	 * override the errctx if parsing explicitly fails.
+	 */
+	if (!result)
+		actx->errctx = "failed to parse token error response";
+
+	return result;
+}
+
+/*
+ * Constructs a message from the token error response and puts it into
+ * actx->errbuf.
+ */
+static void
+record_token_error(struct async_ctx *actx, const struct token_error *err)
+{
+	if (err->error_description)
+		appendPQExpBuffer(&actx->errbuf, "%s ", err->error_description);
+	else
+	{
+		/*
+		 * Try to get some more helpful detail into the error string. A 401
+		 * status in particular implies that the oauth_client_secret is
+		 * missing or wrong.
+		 */
+		long		response_code;
+
+		CHECK_GETINFO(actx, CURLINFO_RESPONSE_CODE, &response_code, response_code = 0);
+
+		if (response_code == 401)
+		{
+			actx_error(actx, actx->used_basic_auth
+					   ? "provider rejected the oauth_client_secret"
+					   : "provider requires client authentication, and no oauth_client_secret is set");
+			actx_error_str(actx, " ");
+		}
+	}
+
+	appendPQExpBuffer(&actx->errbuf, "(%s)", err->error);
+}
+
+/*
+ * Parses the device access token response (RFC 8628, Sec. 3.5, which uses the
+ * success response defined in RFC 6749, Sec. 5.1).
+ */
+static bool
+parse_access_token(struct async_ctx *actx, struct token *tok)
+{
+	struct json_field fields[] = {
+		{"access_token", JSON_TOKEN_STRING, {&tok->access_token}, REQUIRED},
+		{"token_type", JSON_TOKEN_STRING, {&tok->token_type}, REQUIRED},
+
+		/*---
+		 * We currently have no use for the following OPTIONAL fields:
+		 *
+		 * - expires_in: This will be important for maintaining a token cache,
+		 *               but we do not yet implement one.
+		 *
+		 * - refresh_token: Ditto.
+		 *
+		 * - scope: This is only sent when the authorization server sees fit to
+		 *          change our scope request. It's not clear what we should do
+		 *          about this; either it's been done as a matter of policy, or
+		 *          the user has explicitly denied part of the authorization,
+		 *          and either way the server-side validator is in a better
+		 *          place to complain if the change isn't acceptable.
+		 */
+
+		{0},
+	};
+
+	return parse_oauth_json(actx, fields);
+}
+
+/*
+ * libcurl Multi Setup/Callbacks
+ */
+
+/*
+ * Sets up the actx->mux, which is the altsock that PQconnectPoll clients will
+ * select() on instead of the Postgres socket during OAuth negotiation.
+ *
+ * This is just an epoll set or kqueue abstracting multiple other descriptors.
+ * For epoll, the timerfd is always part of the set; it's just disabled when
+ * we're not using it. For kqueue, the "timerfd" is actually a second kqueue
+ * instance which is only added to the set when needed.
+ */
+static bool
+setup_multiplexer(struct async_ctx *actx)
+{
+#ifdef HAVE_SYS_EPOLL_H
+	struct epoll_event ev = {.events = EPOLLIN};
+
+	actx->mux = epoll_create1(EPOLL_CLOEXEC);
+	if (actx->mux < 0)
+	{
+		actx_error(actx, "failed to create epoll set: %m");
+		return false;
+	}
+
+	actx->timerfd = timerfd_create(CLOCK_MONOTONIC, TFD_CLOEXEC);
+	if (actx->timerfd < 0)
+	{
+		actx_error(actx, "failed to create timerfd: %m");
+		return false;
+	}
+
+	if (epoll_ctl(actx->mux, EPOLL_CTL_ADD, actx->timerfd, &ev) < 0)
+	{
+		actx_error(actx, "failed to add timerfd to epoll set: %m");
+		return false;
+	}
+
+	return true;
+#endif
+#ifdef HAVE_SYS_EVENT_H
+	actx->mux = kqueue();
+	if (actx->mux < 0)
+	{
+		/*- translator: the term "kqueue" (kernel queue) should not be translated */
+		actx_error(actx, "failed to create kqueue: %m");
+		return false;
+	}
+
+	/*
+	 * Originally, we set EVFILT_TIMER directly on the top-level multiplexer.
+	 * This makes it difficult to implement timer_expired(), though, so now we
+	 * set EVFILT_TIMER on a separate actx->timerfd, which is chained to
+	 * actx->mux while the timer is active.
+	 */
+	actx->timerfd = kqueue();
+	if (actx->timerfd < 0)
+	{
+		actx_error(actx, "failed to create timer kqueue: %m");
+		return false;
+	}
+
+	return true;
+#endif
+
+	actx_error(actx, "libpq does not support the Device Authorization flow on this platform");
+	return false;
+}
+
+/*
+ * Adds and removes sockets from the multiplexer set, as directed by the
+ * libcurl multi handle.
+ */
+static int
+register_socket(CURL *curl, curl_socket_t socket, int what, void *ctx,
+				void *socketp)
+{
+#ifdef HAVE_SYS_EPOLL_H
+	struct async_ctx *actx = ctx;
+	struct epoll_event ev = {0};
+	int			res;
+	int			op = EPOLL_CTL_ADD;
+
+	switch (what)
+	{
+		case CURL_POLL_IN:
+			ev.events = EPOLLIN;
+			break;
+
+		case CURL_POLL_OUT:
+			ev.events = EPOLLOUT;
+			break;
+
+		case CURL_POLL_INOUT:
+			ev.events = EPOLLIN | EPOLLOUT;
+			break;
+
+		case CURL_POLL_REMOVE:
+			op = EPOLL_CTL_DEL;
+			break;
+
+		default:
+			actx_error(actx, "unknown libcurl socket operation: %d", what);
+			return -1;
+	}
+
+	res = epoll_ctl(actx->mux, op, socket, &ev);
+	if (res < 0 && errno == EEXIST)
+	{
+		/* We already had this socket in the pollset. */
+		op = EPOLL_CTL_MOD;
+		res = epoll_ctl(actx->mux, op, socket, &ev);
+	}
+
+	if (res < 0)
+	{
+		switch (op)
+		{
+			case EPOLL_CTL_ADD:
+				actx_error(actx, "could not add to epoll set: %m");
+				break;
+
+			case EPOLL_CTL_DEL:
+				actx_error(actx, "could not delete from epoll set: %m");
+				break;
+
+			default:
+				actx_error(actx, "could not update epoll set: %m");
+		}
+
+		return -1;
+	}
+
+	return 0;
+#endif
+#ifdef HAVE_SYS_EVENT_H
+	struct async_ctx *actx = ctx;
+	struct kevent ev[2] = {{0}};
+	struct kevent ev_out[2];
+	struct timespec timeout = {0};
+	int			nev = 0;
+	int			res;
+
+	switch (what)
+	{
+		case CURL_POLL_IN:
+			EV_SET(&ev[nev], socket, EVFILT_READ, EV_ADD | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			break;
+
+		case CURL_POLL_OUT:
+			EV_SET(&ev[nev], socket, EVFILT_WRITE, EV_ADD | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			break;
+
+		case CURL_POLL_INOUT:
+			EV_SET(&ev[nev], socket, EVFILT_READ, EV_ADD | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			EV_SET(&ev[nev], socket, EVFILT_WRITE, EV_ADD | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			break;
+
+		case CURL_POLL_REMOVE:
+
+			/*
+			 * We don't know which of these is currently registered, perhaps
+			 * both, so we try to remove both.  This means we need to tolerate
+			 * ENOENT below.
+			 */
+			EV_SET(&ev[nev], socket, EVFILT_READ, EV_DELETE | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			EV_SET(&ev[nev], socket, EVFILT_WRITE, EV_DELETE | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			break;
+
+		default:
+			actx_error(actx, "unknown libcurl socket operation: %d", what);
+			return -1;
+	}
+
+	res = kevent(actx->mux, ev, nev, ev_out, lengthof(ev_out), &timeout);
+	if (res < 0)
+	{
+		actx_error(actx, "could not modify kqueue: %m");
+		return -1;
+	}
+
+	/*
+	 * We can't use the simple errno version of kevent, because we need to
+	 * skip over ENOENT while still allowing a second change to be processed.
+	 * So we need a longer-form error checking loop.
+	 */
+	for (int i = 0; i < res; ++i)
+	{
+		/*
+		 * EV_RECEIPT should guarantee one EV_ERROR result for every change,
+		 * whether successful or not. Failed entries contain a non-zero errno
+		 * in the data field.
+		 */
+		Assert(ev_out[i].flags & EV_ERROR);
+
+		errno = ev_out[i].data;
+		if (errno && errno != ENOENT)
+		{
+			switch (what)
+			{
+				case CURL_POLL_REMOVE:
+					actx_error(actx, "could not delete from kqueue: %m");
+					break;
+				default:
+					actx_error(actx, "could not add to kqueue: %m");
+			}
+			return -1;
+		}
+	}
+
+	return 0;
+#endif
+
+	actx_error(actx, "libpq does not support multiplexer sockets on this platform");
+	return -1;
+}
+
+/*
+ * Enables or disables the timer in the multiplexer set. The timeout value is
+ * in milliseconds (negative values disable the timer).
+ *
+ * For epoll, rather than continually adding and removing the timer, we keep it
+ * in the set at all times and just disarm it when it's not needed. For kqueue,
+ * the timer is removed completely when disabled to prevent stale timeouts from
+ * remaining in the queue.
+ */
+static bool
+set_timer(struct async_ctx *actx, long timeout)
+{
+#if HAVE_SYS_EPOLL_H
+	struct itimerspec spec = {0};
+
+	if (timeout < 0)
+	{
+		/* the zero itimerspec will disarm the timer below */
+	}
+	else if (timeout == 0)
+	{
+		/*
+		 * A zero timeout means libcurl wants us to call back immediately.
+		 * That's not technically an option for timerfd, but we can make the
+		 * timeout ridiculously short.
+		 */
+		spec.it_value.tv_nsec = 1;
+	}
+	else
+	{
+		spec.it_value.tv_sec = timeout / 1000;
+		spec.it_value.tv_nsec = (timeout % 1000) * 1000000;
+	}
+
+	if (timerfd_settime(actx->timerfd, 0 /* no flags */ , &spec, NULL) < 0)
+	{
+		actx_error(actx, "setting timerfd to %ld: %m", timeout);
+		return false;
+	}
+
+	return true;
+#endif
+#ifdef HAVE_SYS_EVENT_H
+	struct kevent ev;
+
+	/* Enable/disable the timer itself. */
+	EV_SET(&ev, 1, EVFILT_TIMER, timeout < 0 ? EV_DELETE : (EV_ADD | EV_ONESHOT),
+		   0, timeout, 0);
+	if (kevent(actx->timerfd, &ev, 1, NULL, 0, NULL) < 0 && errno != ENOENT)
+	{
+		actx_error(actx, "setting kqueue timer to %ld: %m", timeout);
+		return false;
+	}
+
+	/*
+	 * Add/remove the timer to/from the mux. (In contrast with epoll, if we
+	 * allowed the timer to remain registered here after being disabled, the
+	 * mux queue would retain any previous stale timeout notifications and
+	 * remain readable.)
+	 */
+	EV_SET(&ev, actx->timerfd, EVFILT_READ, timeout < 0 ? EV_DELETE : EV_ADD,
+		   0, 0, 0);
+	if (kevent(actx->mux, &ev, 1, NULL, 0, NULL) < 0 && errno != ENOENT)
+	{
+		actx_error(actx, "could not update timer on kqueue: %m");
+		return false;
+	}
+
+	return true;
+#endif
+
+	actx_error(actx, "libpq does not support timers on this platform");
+	return false;
+}
+
+/*
+ * Returns 1 if the timeout in the multiplexer set has expired since the last
+ * call to set_timer(), 0 if the timer is still running, or -1 (with an
+ * actx_error() report) if the timer cannot be queried.
+ */
+static int
+timer_expired(struct async_ctx *actx)
+{
+#if HAVE_SYS_EPOLL_H
+	struct itimerspec spec = {0};
+
+	if (timerfd_gettime(actx->timerfd, &spec) < 0)
+	{
+		actx_error(actx, "getting timerfd value: %m");
+		return -1;
+	}
+
+	/*
+	 * This implementation assumes we're using single-shot timers. If you
+	 * change to using intervals, you'll need to reimplement this function
+	 * too, possibly with the read() or select() interfaces for timerfd.
+	 */
+	Assert(spec.it_interval.tv_sec == 0
+		   && spec.it_interval.tv_nsec == 0);
+
+	/* If the remaining time to expiration is zero, we're done. */
+	return (spec.it_value.tv_sec == 0
+			&& spec.it_value.tv_nsec == 0);
+#endif
+#ifdef HAVE_SYS_EVENT_H
+	int			res;
+
+	/* Is the timer queue ready? */
+	res = PQsocketPoll(actx->timerfd, 1 /* forRead */ , 0, 0);
+	if (res < 0)
+	{
+		actx_error(actx, "checking kqueue for timeout: %m");
+		return -1;
+	}
+
+	return (res > 0);
+#endif
+
+	actx_error(actx, "libpq does not support timers on this platform");
+	return -1;
+}
+
+/*
+ * Adds or removes timeouts from the multiplexer set, as directed by the
+ * libcurl multi handle.
+ */
+static int
+register_timer(CURLM *curlm, long timeout, void *ctx)
+{
+	struct async_ctx *actx = ctx;
+
+	/*
+	 * There might be an optimization opportunity here: if timeout == 0, we
+	 * could signal drive_request to immediately call
+	 * curl_multi_socket_action, rather than returning all the way up the
+	 * stack only to come right back. But it's not clear that the additional
+	 * code complexity is worth it.
+	 */
+	if (!set_timer(actx, timeout))
+		return -1;				/* actx_error already called */
+
+	return 0;
+}
+
+/*
+ * Prints Curl request debugging information to stderr.
+ *
+ * Note that this will expose a number of critical secrets, so users have to opt
+ * into this (see PGOAUTHDEBUG).
+ */
+static int
+debug_callback(CURL *handle, curl_infotype type, char *data, size_t size,
+			   void *clientp)
+{
+	const char *prefix;
+	bool		printed_prefix = false;
+	PQExpBufferData buf;
+
+	/* Prefixes are modeled off of the default libcurl debug output. */
+	switch (type)
+	{
+		case CURLINFO_TEXT:
+			prefix = "*";
+			break;
+
+		case CURLINFO_HEADER_IN:	/* fall through */
+		case CURLINFO_DATA_IN:
+			prefix = "<";
+			break;
+
+		case CURLINFO_HEADER_OUT:	/* fall through */
+		case CURLINFO_DATA_OUT:
+			prefix = ">";
+			break;
+
+		default:
+			return 0;
+	}
+
+	initPQExpBuffer(&buf);
+
+	/*
+	 * Split the output into lines for readability; sometimes multiple headers
+	 * are included in a single call. We also don't allow unprintable ASCII
+	 * through without a basic <XX> escape.
+	 */
+	for (int i = 0; i < size; i++)
+	{
+		char		c = data[i];
+
+		if (!printed_prefix)
+		{
+			appendPQExpBuffer(&buf, "[libcurl] %s ", prefix);
+			printed_prefix = true;
+		}
+
+		if (c >= 0x20 && c <= 0x7E)
+			appendPQExpBufferChar(&buf, c);
+		else if ((type == CURLINFO_HEADER_IN
+				  || type == CURLINFO_HEADER_OUT
+				  || type == CURLINFO_TEXT)
+				 && (c == '\r' || c == '\n'))
+		{
+			/*
+			 * Don't bother emitting <0D><0A> for headers and text; it's not
+			 * helpful noise.
+			 */
+		}
+		else
+			appendPQExpBuffer(&buf, "<%02X>", c);
+
+		if (c == '\n')
+		{
+			appendPQExpBufferChar(&buf, c);
+			printed_prefix = false;
+		}
+	}
+
+	if (printed_prefix)
+		appendPQExpBufferChar(&buf, '\n');	/* finish the line */
+
+	fprintf(stderr, "%s", buf.data);
+	termPQExpBuffer(&buf);
+	return 0;
+}
+
+/*
+ * Initializes the two libcurl handles in the async_ctx. The multi handle,
+ * actx->curlm, is what drives the asynchronous engine and tells us what to do
+ * next. The easy handle, actx->curl, encapsulates the state for a single
+ * request/response. It's added to the multi handle as needed, during
+ * start_request().
+ */
+static bool
+setup_curl_handles(struct async_ctx *actx)
+{
+	/*
+	 * Create our multi handle. This encapsulates the entire conversation with
+	 * libcurl for this connection.
+	 */
+	actx->curlm = curl_multi_init();
+	if (!actx->curlm)
+	{
+		/* We don't get a lot of feedback on the failure reason. */
+		actx_error(actx, "failed to create libcurl multi handle");
+		return false;
+	}
+
+	/*
+	 * The multi handle tells us what to wait on using two callbacks. These
+	 * will manipulate actx->mux as needed.
+	 */
+	CHECK_MSETOPT(actx, CURLMOPT_SOCKETFUNCTION, register_socket, return false);
+	CHECK_MSETOPT(actx, CURLMOPT_SOCKETDATA, actx, return false);
+	CHECK_MSETOPT(actx, CURLMOPT_TIMERFUNCTION, register_timer, return false);
+	CHECK_MSETOPT(actx, CURLMOPT_TIMERDATA, actx, return false);
+
+	/*
+	 * Set up an easy handle. All of our requests are made serially, so we
+	 * only ever need to keep track of one.
+	 */
+	actx->curl = curl_easy_init();
+	if (!actx->curl)
+	{
+		actx_error(actx, "failed to create libcurl handle");
+		return false;
+	}
+
+	/*
+	 * Multi-threaded applications must set CURLOPT_NOSIGNAL. This requires us
+	 * to handle the possibility of SIGPIPE ourselves using pq_block_sigpipe;
+	 * see pg_fe_run_oauth_flow().
+	 *
+	 * NB: If libcurl is not built against a friendly DNS resolver (c-ares or
+	 * threaded), setting this option prevents DNS lookups from timing out
+	 * correctly. We warn about this situation at configure time.
+	 *
+	 * TODO: Perhaps there's a clever way to warn the user about synchronous
+	 * DNS at runtime too? It's not immediately clear how to do that in a
+	 * helpful way: for many standard single-threaded use cases, the user
+	 * might not care at all, so spraying warnings to stderr would probably do
+	 * more harm than good.
+	 */
+	CHECK_SETOPT(actx, CURLOPT_NOSIGNAL, 1L, return false);
+
+	if (actx->debugging)
+	{
+		/*
+		 * Set a callback for retrieving error information from libcurl, the
+		 * function only takes effect when CURLOPT_VERBOSE has been set so
+		 * make sure the order is kept.
+		 */
+		CHECK_SETOPT(actx, CURLOPT_DEBUGFUNCTION, debug_callback, return false);
+		CHECK_SETOPT(actx, CURLOPT_VERBOSE, 1L, return false);
+	}
+
+	CHECK_SETOPT(actx, CURLOPT_ERRORBUFFER, actx->curl_err, return false);
+
+	/*
+	 * Only HTTPS is allowed. (Debug mode additionally allows HTTP; this is
+	 * intended for testing only.)
+	 *
+	 * There's a bit of unfortunate complexity around the choice of
+	 * CURLoption. CURLOPT_PROTOCOLS is deprecated in modern Curls, but its
+	 * replacement didn't show up until relatively recently.
+	 */
+	{
+#if CURL_AT_LEAST_VERSION(7, 85, 0)
+		const CURLoption popt = CURLOPT_PROTOCOLS_STR;
+		const char *protos = "https";
+		const char *const unsafe = "https,http";
+#else
+		const CURLoption popt = CURLOPT_PROTOCOLS;
+		long		protos = CURLPROTO_HTTPS;
+		const long	unsafe = CURLPROTO_HTTPS | CURLPROTO_HTTP;
+#endif
+
+		if (actx->debugging)
+			protos = unsafe;
+
+		CHECK_SETOPT(actx, popt, protos, return false);
+	}
+
+	/*
+	 * If we're in debug mode, allow the developer to change the trusted CA
+	 * list. For now, this is not something we expose outside of the UNSAFE
+	 * mode, because it's not clear that it's useful in production: both libpq
+	 * and the user's browser must trust the same authorization servers for
+	 * the flow to work at all, so any changes to the roots are likely to be
+	 * done system-wide.
+	 */
+	if (actx->debugging)
+	{
+		const char *env;
+
+		if ((env = getenv("PGOAUTHCAFILE")) != NULL)
+			CHECK_SETOPT(actx, CURLOPT_CAINFO, env, return false);
+	}
+
+	/*
+	 * Suppress the Accept header to make our request as minimal as possible.
+	 * (Ideally we would set it to "application/json" instead, but OpenID is
+	 * pretty strict when it comes to provider behavior, so we have to check
+	 * what comes back anyway.)
+	 */
+	actx->headers = curl_slist_append(actx->headers, "Accept:");
+	if (actx->headers == NULL)
+	{
+		actx_error(actx, "out of memory");
+		return false;
+	}
+	CHECK_SETOPT(actx, CURLOPT_HTTPHEADER, actx->headers, return false);
+
+	return true;
+}
+
+/*
+ * Generic HTTP Request Handlers
+ */
+
+/*
+ * Response callback from libcurl which appends the response body into
+ * actx->work_data (see start_request()). The maximum size of the data is
+ * defined by CURL_MAX_WRITE_SIZE which by default is 16kb (and can only be
+ * changed by recompiling libcurl).
+ */
+static size_t
+append_data(char *buf, size_t size, size_t nmemb, void *userdata)
+{
+	struct async_ctx *actx = userdata;
+	PQExpBuffer resp = &actx->work_data;
+	size_t		len = size * nmemb;
+
+	/* In case we receive data over the threshold, abort the transfer */
+	if ((resp->len + len) > MAX_OAUTH_RESPONSE_SIZE)
+	{
+		actx_error(actx, "response is too large");
+		return 0;
+	}
+
+	/* The data passed from libcurl is not null-terminated */
+	appendBinaryPQExpBuffer(resp, buf, len);
+
+	/*
+	 * Signal an error in order to abort the transfer in case we ran out of
+	 * memory in accepting the data.
+	 */
+	if (PQExpBufferBroken(resp))
+	{
+		actx_error(actx, "out of memory");
+		return 0;
+	}
+
+	return len;
+}
+
+/*
+ * Begins an HTTP request on the multi handle. The caller should have set up all
+ * request-specific options on actx->curl first. The server's response body will
+ * be accumulated in actx->work_data (which will be reset, so don't store
+ * anything important there across this call).
+ *
+ * Once a request is queued, it can be driven to completion via drive_request().
+ * If actx->running is zero upon return, the request has already finished and
+ * drive_request() can be called without returning control to the client.
+ */
+static bool
+start_request(struct async_ctx *actx)
+{
+	CURLMcode	err;
+
+	resetPQExpBuffer(&actx->work_data);
+	CHECK_SETOPT(actx, CURLOPT_WRITEFUNCTION, append_data, return false);
+	CHECK_SETOPT(actx, CURLOPT_WRITEDATA, actx, return false);
+
+	err = curl_multi_add_handle(actx->curlm, actx->curl);
+	if (err)
+	{
+		actx_error(actx, "failed to queue HTTP request: %s",
+				   curl_multi_strerror(err));
+		return false;
+	}
+
+	/*
+	 * actx->running tracks the number of running handles, so we can
+	 * immediately call back if no waiting is needed.
+	 *
+	 * Even though this is nominally an asynchronous process, there are some
+	 * operations that can synchronously fail by this point (e.g. connections
+	 * to closed local ports) or even synchronously succeed if the stars align
+	 * (all the libcurl connection caches hit and the server is fast).
+	 */
+	err = curl_multi_socket_action(actx->curlm, CURL_SOCKET_TIMEOUT, 0, &actx->running);
+	if (err)
+	{
+		actx_error(actx, "asynchronous HTTP request failed: %s",
+				   curl_multi_strerror(err));
+		return false;
+	}
+
+	return true;
+}
+
+/*
+ * CURL_IGNORE_DEPRECATION was added in 7.87.0. If it's not defined, we can make
+ * it a no-op.
+ */
+#ifndef CURL_IGNORE_DEPRECATION
+#define CURL_IGNORE_DEPRECATION(x) x
+#endif
+
+/*
+ * Drives the multi handle towards completion. The caller should have already
+ * set up an asynchronous request via start_request().
+ */
+static PostgresPollingStatusType
+drive_request(struct async_ctx *actx)
+{
+	CURLMcode	err;
+	CURLMsg    *msg;
+	int			msgs_left;
+	bool		done;
+
+	if (actx->running)
+	{
+		/*---
+		 * There's an async request in progress. Pump the multi handle.
+		 *
+		 * curl_multi_socket_all() is officially deprecated, because it's
+		 * inefficient and pointless if your event loop has already handed you
+		 * the exact sockets that are ready. But that's not our use case --
+		 * our client has no way to tell us which sockets are ready. (They
+		 * don't even know there are sockets to begin with.)
+		 *
+		 * We can grab the list of triggered events from the multiplexer
+		 * ourselves, but that's effectively what curl_multi_socket_all() is
+		 * going to do. And there are currently no plans for the Curl project
+		 * to remove or break this API, so ignore the deprecation. See
+		 *
+		 *    https://curl.se/mail/lib-2024-11/0028.html
+		 *
+		 */
+		CURL_IGNORE_DEPRECATION(
+			err = curl_multi_socket_all(actx->curlm, &actx->running);
+		)
+
+		if (err)
+		{
+			actx_error(actx, "asynchronous HTTP request failed: %s",
+					   curl_multi_strerror(err));
+			return PGRES_POLLING_FAILED;
+		}
+
+		if (actx->running)
+		{
+			/* We'll come back again. */
+			return PGRES_POLLING_READING;
+		}
+	}
+
+	done = false;
+	while ((msg = curl_multi_info_read(actx->curlm, &msgs_left)) != NULL)
+	{
+		if (msg->msg != CURLMSG_DONE)
+		{
+			/*
+			 * Future libcurl versions may define new message types; we don't
+			 * know how to handle them, so we'll ignore them.
+			 */
+			continue;
+		}
+
+		/* First check the status of the request itself. */
+		if (msg->data.result != CURLE_OK)
+		{
+			/*
+			 * If a more specific error hasn't already been reported, use
+			 * libcurl's description.
+			 */
+			if (actx->errbuf.len == 0)
+				actx_error_str(actx, curl_easy_strerror(msg->data.result));
+
+			return PGRES_POLLING_FAILED;
+		}
+
+		/* Now remove the finished handle; we'll add it back later if needed. */
+		err = curl_multi_remove_handle(actx->curlm, msg->easy_handle);
+		if (err)
+		{
+			actx_error(actx, "libcurl easy handle removal failed: %s",
+					   curl_multi_strerror(err));
+			return PGRES_POLLING_FAILED;
+		}
+
+		done = true;
+	}
+
+	/* Sanity check. */
+	if (!done)
+	{
+		actx_error(actx, "no result was retrieved for the finished handle");
+		return PGRES_POLLING_FAILED;
+	}
+
+	return PGRES_POLLING_OK;
+}
+
+/*
+ * URL-Encoding Helpers
+ */
+
+/*
+ * Encodes a string using the application/x-www-form-urlencoded format, and
+ * appends it to the given buffer.
+ */
+static void
+append_urlencoded(PQExpBuffer buf, const char *s)
+{
+	char	   *escaped;
+	char	   *haystack;
+	char	   *match;
+
+	/* The first parameter to curl_easy_escape is deprecated by Curl */
+	escaped = curl_easy_escape(NULL, s, 0);
+	if (!escaped)
+	{
+		termPQExpBuffer(buf);	/* mark the buffer broken */
+		return;
+	}
+
+	/*
+	 * curl_easy_escape() almost does what we want, but we need the
+	 * query-specific flavor which uses '+' instead of '%20' for spaces. The
+	 * Curl command-line tool does this with a simple search-and-replace, so
+	 * follow its lead.
+	 */
+	haystack = escaped;
+
+	while ((match = strstr(haystack, "%20")) != NULL)
+	{
+		/* Append the unmatched portion, followed by the plus sign. */
+		appendBinaryPQExpBuffer(buf, haystack, match - haystack);
+		appendPQExpBufferChar(buf, '+');
+
+		/* Keep searching after the match. */
+		haystack = match + 3 /* strlen("%20") */ ;
+	}
+
+	/* Push the remainder of the string onto the buffer. */
+	appendPQExpBufferStr(buf, haystack);
+
+	curl_free(escaped);
+}
+
+/*
+ * Convenience wrapper for encoding a single string. Returns NULL on allocation
+ * failure.
+ */
+static char *
+urlencode(const char *s)
+{
+	PQExpBufferData buf;
+
+	initPQExpBuffer(&buf);
+	append_urlencoded(&buf, s);
+
+	return PQExpBufferDataBroken(buf) ? NULL : buf.data;
+}
+
+/*
+ * Appends a key/value pair to the end of an application/x-www-form-urlencoded
+ * list.
+ */
+static void
+build_urlencoded(PQExpBuffer buf, const char *key, const char *value)
+{
+	if (buf->len)
+		appendPQExpBufferChar(buf, '&');
+
+	append_urlencoded(buf, key);
+	appendPQExpBufferChar(buf, '=');
+	append_urlencoded(buf, value);
+}
+
+/*
+ * Specific HTTP Request Handlers
+ *
+ * This is finally the beginning of the actual application logic. Generally
+ * speaking, a single request consists of a start_* and a finish_* step, with
+ * drive_request() pumping the machine in between.
+ */
+
+/*
+ * Queue an OpenID Provider Configuration Request:
+ *
+ *     https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationRequest
+ *     https://www.rfc-editor.org/rfc/rfc8414#section-3.1
+ *
+ * This is done first to get the endpoint URIs we need to contact and to make
+ * sure the provider provides a device authorization flow. finish_discovery()
+ * will fill in actx->provider.
+ */
+static bool
+start_discovery(struct async_ctx *actx, const char *discovery_uri)
+{
+	CHECK_SETOPT(actx, CURLOPT_HTTPGET, 1L, return false);
+	CHECK_SETOPT(actx, CURLOPT_URL, discovery_uri, return false);
+
+	return start_request(actx);
+}
+
+static bool
+finish_discovery(struct async_ctx *actx)
+{
+	long		response_code;
+
+	/*----
+	 * Now check the response. OIDC Discovery 1.0 is pretty strict:
+	 *
+	 *     A successful response MUST use the 200 OK HTTP status code and
+	 *     return a JSON object using the application/json content type that
+	 *     contains a set of Claims as its members that are a subset of the
+	 *     Metadata values defined in Section 3.
+	 *
+	 * Compared to standard HTTP semantics, this makes life easy -- we don't
+	 * need to worry about redirections (which would call the Issuer host
+	 * validation into question), or non-authoritative responses, or any other
+	 * complications.
+	 */
+	CHECK_GETINFO(actx, CURLINFO_RESPONSE_CODE, &response_code, return false);
+
+	if (response_code != 200)
+	{
+		actx_error(actx, "unexpected response code %ld", response_code);
+		return false;
+	}
+
+	/*
+	 * Pull the fields we care about from the document.
+	 */
+	actx->errctx = "failed to parse OpenID discovery document";
+	if (!parse_provider(actx, &actx->provider))
+		return false;			/* error message already set */
+
+	/*
+	 * Fill in any defaults for OPTIONAL/RECOMMENDED fields we care about.
+	 */
+	if (!actx->provider.grant_types_supported)
+	{
+		/*
+		 * Per Section 3, the default is ["authorization_code", "implicit"].
+		 */
+		struct curl_slist *temp = actx->provider.grant_types_supported;
+
+		temp = curl_slist_append(temp, "authorization_code");
+		if (temp)
+		{
+			temp = curl_slist_append(temp, "implicit");
+		}
+
+		if (!temp)
+		{
+			actx_error(actx, "out of memory");
+			return false;
+		}
+
+		actx->provider.grant_types_supported = temp;
+	}
+
+	return true;
+}
+
+/*
+ * Ensure that the discovery document is provided by the expected issuer.
+ * Currently, issuers are statically configured in the connection string.
+ */
+static bool
+check_issuer(struct async_ctx *actx, PGconn *conn)
+{
+	const struct provider *provider = &actx->provider;
+
+	Assert(conn->oauth_issuer_id);	/* ensured by setup_oauth_parameters() */
+	Assert(provider->issuer);	/* ensured by parse_provider() */
+
+	/*---
+	 * We require strict equality for issuer identifiers -- no path or case
+	 * normalization, no substitution of default ports and schemes, etc. This
+	 * is done to match the rules in OIDC Discovery Sec. 4.3 for config
+	 * validation:
+	 *
+	 *    The issuer value returned MUST be identical to the Issuer URL that
+	 *    was used as the prefix to /.well-known/openid-configuration to
+	 *    retrieve the configuration information.
+	 *
+	 * as well as the rules set out in RFC 9207 for avoiding mix-up attacks:
+	 *
+	 *    Clients MUST then [...] compare the result to the issuer identifier
+	 *    of the authorization server where the authorization request was
+	 *    sent to. This comparison MUST use simple string comparison as defined
+	 *    in Section 6.2.1 of [RFC3986].
+	 */
+	if (strcmp(conn->oauth_issuer_id, provider->issuer) != 0)
+	{
+		actx_error(actx,
+				   "the issuer identifier (%s) does not match oauth_issuer (%s)",
+				   provider->issuer, conn->oauth_issuer_id);
+		return false;
+	}
+
+	return true;
+}
+
+#define HTTPS_SCHEME "https://"
+#define OAUTH_GRANT_TYPE_DEVICE_CODE "urn:ietf:params:oauth:grant-type:device_code"
+
+/*
+ * Ensure that the provider supports the Device Authorization flow (i.e. it
+ * provides an authorization endpoint, and both the token and authorization
+ * endpoint URLs seem reasonable).
+ */
+static bool
+check_for_device_flow(struct async_ctx *actx)
+{
+	const struct provider *provider = &actx->provider;
+
+	Assert(provider->issuer);	/* ensured by parse_provider() */
+	Assert(provider->token_endpoint);	/* ensured by parse_provider() */
+
+	if (!provider->device_authorization_endpoint)
+	{
+		actx_error(actx,
+				   "issuer \"%s\" does not provide a device authorization endpoint",
+				   provider->issuer);
+		return false;
+	}
+
+	/*
+	 * The original implementation checked that OAUTH_GRANT_TYPE_DEVICE_CODE
+	 * was present in the discovery document's grant_types_supported list. MS
+	 * Entra does not advertise this grant type, though, and since it doesn't
+	 * make sense to stand up a device_authorization_endpoint without also
+	 * accepting device codes at the token_endpoint, that's the only thing we
+	 * currently require.
+	 */
+
+	/*
+	 * Although libcurl will fail later if the URL contains an unsupported
+	 * scheme, that error message is going to be a bit opaque. This is a
+	 * decent time to bail out if we're not using HTTPS for the endpoints
+	 * we'll use for the flow.
+	 */
+	if (!actx->debugging)
+	{
+		if (pg_strncasecmp(provider->device_authorization_endpoint,
+						   HTTPS_SCHEME, strlen(HTTPS_SCHEME)) != 0)
+		{
+			actx_error(actx,
+					   "device authorization endpoint \"%s\" must use HTTPS",
+					   provider->device_authorization_endpoint);
+			return false;
+		}
+
+		if (pg_strncasecmp(provider->token_endpoint,
+						   HTTPS_SCHEME, strlen(HTTPS_SCHEME)) != 0)
+		{
+			actx_error(actx,
+					   "token endpoint \"%s\" must use HTTPS",
+					   provider->token_endpoint);
+			return false;
+		}
+	}
+
+	return true;
+}
+
+/*
+ * Adds the client ID (and secret, if provided) to the current request, using
+ * either HTTP headers or the request body.
+ */
+static bool
+add_client_identification(struct async_ctx *actx, PQExpBuffer reqbody, PGconn *conn)
+{
+	bool		success = false;
+	char	   *username = NULL;
+	char	   *password = NULL;
+
+	if (conn->oauth_client_secret)	/* Zero-length secrets are permitted! */
+	{
+		/*----
+		 * Use HTTP Basic auth to send the client_id and secret. Per RFC 6749,
+		 * Sec. 2.3.1,
+		 *
+		 *   Including the client credentials in the request-body using the
+		 *   two parameters is NOT RECOMMENDED and SHOULD be limited to
+		 *   clients unable to directly utilize the HTTP Basic authentication
+		 *   scheme (or other password-based HTTP authentication schemes).
+		 *
+		 * Additionally:
+		 *
+		 *   The client identifier is encoded using the
+		 *   "application/x-www-form-urlencoded" encoding algorithm per Appendix
+		 *   B, and the encoded value is used as the username; the client
+		 *   password is encoded using the same algorithm and used as the
+		 *   password.
+		 *
+		 * (Appendix B modifies application/x-www-form-urlencoded by requiring
+		 * an initial UTF-8 encoding step. Since the client ID and secret must
+		 * both be 7-bit ASCII -- RFC 6749 Appendix A -- we don't worry about
+		 * that in this function.)
+		 *
+		 * client_id is not added to the request body in this case. Not only
+		 * would it be redundant, but some providers in the wild (e.g. Okta)
+		 * refuse to accept it.
+		 */
+		username = urlencode(conn->oauth_client_id);
+		password = urlencode(conn->oauth_client_secret);
+
+		if (!username || !password)
+		{
+			actx_error(actx, "out of memory");
+			goto cleanup;
+		}
+
+		CHECK_SETOPT(actx, CURLOPT_HTTPAUTH, CURLAUTH_BASIC, goto cleanup);
+		CHECK_SETOPT(actx, CURLOPT_USERNAME, username, goto cleanup);
+		CHECK_SETOPT(actx, CURLOPT_PASSWORD, password, goto cleanup);
+
+		actx->used_basic_auth = true;
+	}
+	else
+	{
+		/*
+		 * If we're not otherwise authenticating, client_id is REQUIRED in the
+		 * request body.
+		 */
+		build_urlencoded(reqbody, "client_id", conn->oauth_client_id);
+
+		CHECK_SETOPT(actx, CURLOPT_HTTPAUTH, CURLAUTH_NONE, goto cleanup);
+		actx->used_basic_auth = false;
+	}
+
+	success = true;
+
+cleanup:
+	free(username);
+	free(password);
+
+	return success;
+}
+
+/*
+ * Queue a Device Authorization Request:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc8628#section-3.1
+ *
+ * This is the second step. We ask the provider to verify the end user out of
+ * band and authorize us to act on their behalf; it will give us the required
+ * nonces for us to later poll the request status, which we'll grab in
+ * finish_device_authz().
+ */
+static bool
+start_device_authz(struct async_ctx *actx, PGconn *conn)
+{
+	const char *device_authz_uri = actx->provider.device_authorization_endpoint;
+	PQExpBuffer work_buffer = &actx->work_data;
+
+	Assert(conn->oauth_client_id);	/* ensured by setup_oauth_parameters() */
+	Assert(device_authz_uri);	/* ensured by check_for_device_flow() */
+
+	/* Construct our request body. */
+	resetPQExpBuffer(work_buffer);
+	if (conn->oauth_scope && conn->oauth_scope[0])
+		build_urlencoded(work_buffer, "scope", conn->oauth_scope);
+
+	if (!add_client_identification(actx, work_buffer, conn))
+		return false;
+
+	if (PQExpBufferBroken(work_buffer))
+	{
+		actx_error(actx, "out of memory");
+		return false;
+	}
+
+	/* Make our request. */
+	CHECK_SETOPT(actx, CURLOPT_URL, device_authz_uri, return false);
+	CHECK_SETOPT(actx, CURLOPT_COPYPOSTFIELDS, work_buffer->data, return false);
+
+	return start_request(actx);
+}
+
+static bool
+finish_device_authz(struct async_ctx *actx)
+{
+	long		response_code;
+
+	CHECK_GETINFO(actx, CURLINFO_RESPONSE_CODE, &response_code, return false);
+
+	/*
+	 * Per RFC 8628, Section 3, a successful device authorization response
+	 * uses 200 OK.
+	 */
+	if (response_code == 200)
+	{
+		actx->errctx = "failed to parse device authorization";
+		if (!parse_device_authz(actx, &actx->authz))
+			return false;		/* error message already set */
+
+		return true;
+	}
+
+	/*
+	 * The device authorization endpoint uses the same error response as the
+	 * token endpoint, so the error handling roughly follows
+	 * finish_token_request(). The key difference is that an error here is
+	 * immediately fatal.
+	 */
+	if (response_code == 400 || response_code == 401)
+	{
+		struct token_error err = {0};
+
+		if (!parse_token_error(actx, &err))
+		{
+			free_token_error(&err);
+			return false;
+		}
+
+		/* Copy the token error into the context error buffer */
+		record_token_error(actx, &err);
+
+		free_token_error(&err);
+		return false;
+	}
+
+	/* Any other response codes are considered invalid */
+	actx_error(actx, "unexpected response code %ld", response_code);
+	return false;
+}
+
+/*
+ * Queue an Access Token Request:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc6749#section-4.1.3
+ *
+ * This is the final step. We continually poll the token endpoint to see if the
+ * user has authorized us yet. finish_token_request() will pull either the token
+ * or a (ideally temporary) error status from the provider.
+ */
+static bool
+start_token_request(struct async_ctx *actx, PGconn *conn)
+{
+	const char *token_uri = actx->provider.token_endpoint;
+	const char *device_code = actx->authz.device_code;
+	PQExpBuffer work_buffer = &actx->work_data;
+
+	Assert(conn->oauth_client_id);	/* ensured by setup_oauth_parameters() */
+	Assert(token_uri);			/* ensured by parse_provider() */
+	Assert(device_code);		/* ensured by parse_device_authz() */
+
+	/* Construct our request body. */
+	resetPQExpBuffer(work_buffer);
+	build_urlencoded(work_buffer, "device_code", device_code);
+	build_urlencoded(work_buffer, "grant_type", OAUTH_GRANT_TYPE_DEVICE_CODE);
+
+	if (!add_client_identification(actx, work_buffer, conn))
+		return false;
+
+	if (PQExpBufferBroken(work_buffer))
+	{
+		actx_error(actx, "out of memory");
+		return false;
+	}
+
+	/* Make our request. */
+	CHECK_SETOPT(actx, CURLOPT_URL, token_uri, return false);
+	CHECK_SETOPT(actx, CURLOPT_COPYPOSTFIELDS, work_buffer->data, return false);
+
+	return start_request(actx);
+}
+
+static bool
+finish_token_request(struct async_ctx *actx, struct token *tok)
+{
+	long		response_code;
+
+	CHECK_GETINFO(actx, CURLINFO_RESPONSE_CODE, &response_code, return false);
+
+	/*
+	 * Per RFC 6749, Section 5, a successful response uses 200 OK.
+	 */
+	if (response_code == 200)
+	{
+		actx->errctx = "failed to parse access token response";
+		if (!parse_access_token(actx, tok))
+			return false;		/* error message already set */
+
+		return true;
+	}
+
+	/*
+	 * An error response uses either 400 Bad Request or 401 Unauthorized.
+	 * There are references online to implementations using 403 for error
+	 * return which would violate the specification. For now we stick to the
+	 * specification but we might have to revisit this.
+	 */
+	if (response_code == 400 || response_code == 401)
+	{
+		if (!parse_token_error(actx, &tok->err))
+			return false;
+
+		return true;
+	}
+
+	/* Any other response codes are considered invalid */
+	actx_error(actx, "unexpected response code %ld", response_code);
+	return false;
+}
+
+/*
+ * Finishes the token request and examines the response. If the flow has
+ * completed, a valid token will be returned via the parameter list. Otherwise,
+ * the token parameter remains unchanged, and the caller needs to wait for
+ * another interval (which will have been increased in response to a slow_down
+ * message from the server) before starting a new token request.
+ *
+ * False is returned only for permanent error conditions.
+ */
+static bool
+handle_token_response(struct async_ctx *actx, char **token)
+{
+	bool		success = false;
+	struct token tok = {0};
+	const struct token_error *err;
+
+	if (!finish_token_request(actx, &tok))
+		goto token_cleanup;
+
+	/* A successful token request gives either a token or an in-band error. */
+	Assert(tok.access_token || tok.err.error);
+
+	if (tok.access_token)
+	{
+		*token = tok.access_token;
+		tok.access_token = NULL;
+
+		success = true;
+		goto token_cleanup;
+	}
+
+	/*
+	 * authorization_pending and slow_down are the only acceptable errors;
+	 * anything else and we bail. These are defined in RFC 8628, Sec. 3.5.
+	 */
+	err = &tok.err;
+	if (strcmp(err->error, "authorization_pending") != 0 &&
+		strcmp(err->error, "slow_down") != 0)
+	{
+		record_token_error(actx, err);
+		goto token_cleanup;
+	}
+
+	/*
+	 * A slow_down error requires us to permanently increase our retry
+	 * interval by five seconds.
+	 */
+	if (strcmp(err->error, "slow_down") == 0)
+	{
+		int			prev_interval = actx->authz.interval;
+
+		actx->authz.interval += 5;
+		if (actx->authz.interval < prev_interval)
+		{
+			actx_error(actx, "slow_down interval overflow");
+			goto token_cleanup;
+		}
+	}
+
+	success = true;
+
+token_cleanup:
+	free_token(&tok);
+	return success;
+}
+
+/*
+ * Displays a device authorization prompt for action by the end user, either via
+ * the PQauthDataHook, or by a message on standard error if no hook is set.
+ */
+static bool
+prompt_user(struct async_ctx *actx, PGconn *conn)
+{
+	int			res;
+	PGpromptOAuthDevice prompt = {
+		.verification_uri = actx->authz.verification_uri,
+		.user_code = actx->authz.user_code,
+		.verification_uri_complete = actx->authz.verification_uri_complete,
+		.expires_in = actx->authz.expires_in,
+	};
+
+	res = PQauthDataHook(PQAUTHDATA_PROMPT_OAUTH_DEVICE, conn, &prompt);
+
+	if (!res)
+	{
+		/*
+		 * translator: The first %s is a URL for the user to visit in a
+		 * browser, and the second %s is a code to be copy-pasted there.
+		 */
+		fprintf(stderr, libpq_gettext("Visit %s and enter the code: %s\n"),
+				prompt.verification_uri, prompt.user_code);
+	}
+	else if (res < 0)
+	{
+		actx_error(actx, "device prompt failed");
+		return false;
+	}
+
+	return true;
+}
+
+/*
+ * Calls curl_global_init() in a thread-safe way.
+ *
+ * libcurl has stringent requirements for the thread context in which you call
+ * curl_global_init(), because it's going to try initializing a bunch of other
+ * libraries (OpenSSL, Winsock, etc). Recent versions of libcurl have improved
+ * the thread-safety situation, but there's a chicken-and-egg problem at
+ * runtime: you can't check the thread safety until you've initialized libcurl,
+ * which you can't do from within a thread unless you know it's thread-safe...
+ *
+ * Returns true if initialization was successful. Successful or not, this
+ * function will not try to reinitialize Curl on successive calls.
+ */
+static bool
+initialize_curl(PGconn *conn)
+{
+	/*
+	 * Don't let the compiler play tricks with this variable. In the
+	 * HAVE_THREADSAFE_CURL_GLOBAL_INIT case, we don't care if two threads
+	 * enter simultaneously, but we do care if this gets set transiently to
+	 * PG_BOOL_YES/NO in cases where that's not the final answer.
+	 */
+	static volatile PGTernaryBool init_successful = PG_BOOL_UNKNOWN;
+#if HAVE_THREADSAFE_CURL_GLOBAL_INIT
+	curl_version_info_data *info;
+#endif
+
+#if !HAVE_THREADSAFE_CURL_GLOBAL_INIT
+
+	/*
+	 * Lock around the whole function. If a libpq client performs its own work
+	 * with libcurl, it must either ensure that Curl is initialized safely
+	 * before calling us (in which case our call will be a no-op), or else it
+	 * must guard its own calls to curl_global_init() with a registered
+	 * threadlock handler. See PQregisterThreadLock().
+	 */
+	pglock_thread();
+#endif
+
+	/*
+	 * Skip initialization if we've already done it. (Curl tracks the number
+	 * of calls; there's no point in incrementing the counter every time we
+	 * connect.)
+	 */
+	if (init_successful == PG_BOOL_YES)
+		goto done;
+	else if (init_successful == PG_BOOL_NO)
+	{
+		libpq_append_conn_error(conn,
+								"curl_global_init previously failed during OAuth setup");
+		goto done;
+	}
+
+	/*
+	 * We know we've already initialized Winsock by this point (see
+	 * pqMakeEmptyPGconn()), so we should be able to safely skip that bit. But
+	 * we have to tell libcurl to initialize everything else, because other
+	 * pieces of our client executable may already be using libcurl for their
+	 * own purposes. If we initialize libcurl with only a subset of its
+	 * features, we could break those other clients nondeterministically, and
+	 * that would probably be a nightmare to debug.
+	 *
+	 * If some other part of the program has already called this, it's a
+	 * no-op.
+	 */
+	if (curl_global_init(CURL_GLOBAL_ALL & ~CURL_GLOBAL_WIN32) != CURLE_OK)
+	{
+		libpq_append_conn_error(conn,
+								"curl_global_init failed during OAuth setup");
+		init_successful = PG_BOOL_NO;
+		goto done;
+	}
+
+#if HAVE_THREADSAFE_CURL_GLOBAL_INIT
+
+	/*
+	 * If we determined at configure time that the Curl installation is
+	 * thread-safe, our job here is much easier. We simply initialize above
+	 * without any locking (concurrent or duplicated calls are fine in that
+	 * situation), then double-check to make sure the runtime setting agrees,
+	 * to try to catch silent downgrades.
+	 */
+	info = curl_version_info(CURLVERSION_NOW);
+	if (!(info->features & CURL_VERSION_THREADSAFE))
+	{
+		/*
+		 * 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.");
+		init_successful = PG_BOOL_NO;
+		goto done;
+	}
+#endif
+
+	init_successful = PG_BOOL_YES;
+
+done:
+#if !HAVE_THREADSAFE_CURL_GLOBAL_INIT
+	pgunlock_thread();
+#endif
+	return (init_successful == PG_BOOL_YES);
+}
+
+/*
+ * The core nonblocking libcurl implementation. This will be called several
+ * times to pump the async engine.
+ *
+ * The architecture is based on PQconnectPoll(). The first half drives the
+ * connection state forward as necessary, returning if we're not ready to
+ * proceed to the next step yet. The second half performs the actual transition
+ * between states.
+ *
+ * You can trace the overall OAuth flow through the second half. It's linear
+ * until we get to the end, where we flip back and forth between
+ * OAUTH_STEP_TOKEN_REQUEST and OAUTH_STEP_WAIT_INTERVAL to regularly ping the
+ * provider.
+ */
+static PostgresPollingStatusType
+pg_fe_run_oauth_flow_impl(PGconn *conn)
+{
+	fe_oauth_state *state = conn->sasl_state;
+	struct async_ctx *actx;
+
+	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. */
+		conn->altsock = actx->mux;
+
+		switch (actx->step)
+		{
+			case OAUTH_STEP_INIT:
+				break;
+
+			case OAUTH_STEP_DISCOVERY:
+			case OAUTH_STEP_DEVICE_AUTHORIZATION:
+			case OAUTH_STEP_TOKEN_REQUEST:
+				{
+					PostgresPollingStatusType status;
+
+					status = drive_request(actx);
+
+					if (status == PGRES_POLLING_FAILED)
+						goto error_return;
+					else if (status != PGRES_POLLING_OK)
+					{
+						/* not done yet */
+						return status;
+					}
+
+					break;
+				}
+
+			case OAUTH_STEP_WAIT_INTERVAL:
+
+				/*
+				 * The client application is supposed to wait until our timer
+				 * expires before calling PQconnectPoll() again, but that
+				 * might not happen. To avoid sending a token request early,
+				 * check the timer before continuing.
+				 */
+				if (!timer_expired(actx))
+				{
+					conn->altsock = actx->timerfd;
+					return PGRES_POLLING_READING;
+				}
+
+				/* Disable the expired timer. */
+				if (!set_timer(actx, -1))
+					goto error_return;
+
+				break;
+		}
+
+		/*
+		 * Each case here must ensure that actx->running is set while we're
+		 * waiting on some asynchronous work. Most cases rely on
+		 * start_request() to do that for them.
+		 */
+		switch (actx->step)
+		{
+			case OAUTH_STEP_INIT:
+				actx->errctx = "failed to fetch OpenID discovery document";
+				if (!start_discovery(actx, conn->oauth_discovery_uri))
+					goto error_return;
+
+				actx->step = OAUTH_STEP_DISCOVERY;
+				break;
+
+			case OAUTH_STEP_DISCOVERY:
+				if (!finish_discovery(actx))
+					goto error_return;
+
+				if (!check_issuer(actx, conn))
+					goto error_return;
+
+				actx->errctx = "cannot run OAuth device authorization";
+				if (!check_for_device_flow(actx))
+					goto error_return;
+
+				actx->errctx = "failed to obtain device authorization";
+				if (!start_device_authz(actx, conn))
+					goto error_return;
+
+				actx->step = OAUTH_STEP_DEVICE_AUTHORIZATION;
+				break;
+
+			case OAUTH_STEP_DEVICE_AUTHORIZATION:
+				if (!finish_device_authz(actx))
+					goto error_return;
+
+				actx->errctx = "failed to obtain access token";
+				if (!start_token_request(actx, conn))
+					goto error_return;
+
+				actx->step = OAUTH_STEP_TOKEN_REQUEST;
+				break;
+
+			case OAUTH_STEP_TOKEN_REQUEST:
+				if (!handle_token_response(actx, &conn->oauth_token))
+					goto error_return;
+
+				if (!actx->user_prompted)
+				{
+					/*
+					 * Now that we know the token endpoint isn't broken, give
+					 * the user the login instructions.
+					 */
+					if (!prompt_user(actx, conn))
+						goto error_return;
+
+					actx->user_prompted = true;
+				}
+
+				if (conn->oauth_token)
+					break;		/* done! */
+
+				/*
+				 * Wait for the required interval before issuing the next
+				 * request.
+				 */
+				if (!set_timer(actx, actx->authz.interval * 1000))
+					goto error_return;
+
+				/*
+				 * No Curl requests are running, so we can simplify by having
+				 * the client wait directly on the timerfd rather than the
+				 * multiplexer.
+				 */
+				conn->altsock = actx->timerfd;
+
+				actx->step = OAUTH_STEP_WAIT_INTERVAL;
+				actx->running = 1;
+				break;
+
+			case OAUTH_STEP_WAIT_INTERVAL:
+				actx->errctx = "failed to obtain access token";
+				if (!start_token_request(actx, conn))
+					goto error_return;
+
+				actx->step = OAUTH_STEP_TOKEN_REQUEST;
+				break;
+		}
+
+		/*
+		 * The vast majority of the time, if we don't have a token at this
+		 * point, actx->running will be set. But there are some corner cases
+		 * where we can immediately loop back around; see start_request().
+		 */
+	} while (!conn->oauth_token && !actx->running);
+
+	/* If we've stored a token, we're done. Otherwise come back later. */
+	return conn->oauth_token ? PGRES_POLLING_OK : PGRES_POLLING_READING;
+
+error_return:
+
+	/*
+	 * Assemble the three parts of our error: context, body, and detail. See
+	 * also the documentation for struct async_ctx.
+	 */
+	if (actx->errctx)
+	{
+		appendPQExpBufferStr(&conn->errorMessage,
+							 libpq_gettext(actx->errctx));
+		appendPQExpBufferStr(&conn->errorMessage, ": ");
+	}
+
+	if (PQExpBufferDataBroken(actx->errbuf))
+		appendPQExpBufferStr(&conn->errorMessage,
+							 libpq_gettext("out of memory"));
+	else
+		appendPQExpBufferStr(&conn->errorMessage, actx->errbuf.data);
+
+	if (actx->curl_err[0])
+	{
+		size_t		len;
+
+		appendPQExpBuffer(&conn->errorMessage,
+						  " (libcurl: %s)", actx->curl_err);
+
+		/* Sometimes libcurl adds a newline to the error buffer. :( */
+		len = conn->errorMessage.len;
+		if (len >= 2 && conn->errorMessage.data[len - 2] == '\n')
+		{
+			conn->errorMessage.data[len - 2] = ')';
+			conn->errorMessage.data[len - 1] = '\0';
+			conn->errorMessage.len--;
+		}
+	}
+
+	appendPQExpBufferStr(&conn->errorMessage, "\n");
+
+	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.
+ */
+PostgresPollingStatusType
+pg_fe_run_oauth_flow(PGconn *conn)
+{
+	PostgresPollingStatusType result;
+#ifndef WIN32
+	sigset_t	osigset;
+	bool		sigpipe_pending;
+	bool		masked;
+
+	/*---
+	 * Ignore SIGPIPE on this thread during all Curl processing.
+	 *
+	 * Because we support multiple threads, we have to set up libcurl with
+	 * CURLOPT_NOSIGNAL, which disables its default global handling of
+	 * SIGPIPE. From the Curl docs:
+	 *
+	 *     libcurl makes an effort to never cause such SIGPIPE signals to
+	 *     trigger, but some operating systems have no way to avoid them and
+	 *     even on those that have there are some corner cases when they may
+	 *     still happen, contrary to our desire.
+	 *
+	 * Note that libcurl is also at the mercy of its DNS resolution and SSL
+	 * libraries; if any of them forget a MSG_NOSIGNAL then we're in trouble.
+	 * Modern platforms and libraries seem to get it right, so this is a
+	 * difficult corner case to exercise in practice, and unfortunately it's
+	 * not really clear whether it's necessary in all cases.
+	 */
+	masked = (pq_block_sigpipe(&osigset, &sigpipe_pending) == 0);
+#endif
+
+	result = pg_fe_run_oauth_flow_impl(conn);
+
+#ifndef WIN32
+	if (masked)
+	{
+		/*
+		 * Undo the SIGPIPE mask. Assume we may have gotten EPIPE (we have no
+		 * way of knowing at this level).
+		 */
+		pq_reset_sigpipe(&osigset, sigpipe_pending, true /* EPIPE, maybe */ );
+	}
+#endif
+
+	return result;
+}
diff --git a/src/interfaces/libpq/fe-auth-oauth.c b/src/interfaces/libpq/fe-auth-oauth.c
new file mode 100644
index 00000000000..24448c3e209
--- /dev/null
+++ b/src/interfaces/libpq/fe-auth-oauth.c
@@ -0,0 +1,1153 @@
+/*-------------------------------------------------------------------------
+ *
+ * fe-auth-oauth.c
+ *	   The front-end (client) implementation of OAuth/OIDC authentication
+ *	   using the SASL OAUTHBEARER mechanism.
+ *
+ * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/interfaces/libpq/fe-auth-oauth.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres_fe.h"
+
+#include "common/base64.h"
+#include "common/hmac.h"
+#include "common/jsonapi.h"
+#include "common/oauth-common.h"
+#include "fe-auth.h"
+#include "fe-auth-oauth.h"
+#include "mb/pg_wchar.h"
+
+/* The exported OAuth callback mechanism. */
+static void *oauth_init(PGconn *conn, const char *password,
+						const char *sasl_mechanism);
+static SASLStatus oauth_exchange(void *opaq, bool final,
+								 char *input, int inputlen,
+								 char **output, int *outputlen);
+static bool oauth_channel_bound(void *opaq);
+static void oauth_free(void *opaq);
+
+const pg_fe_sasl_mech pg_oauth_mech = {
+	oauth_init,
+	oauth_exchange,
+	oauth_channel_bound,
+	oauth_free,
+};
+
+/*
+ * Initializes mechanism state for OAUTHBEARER.
+ *
+ * For a full description of the API, see libpq/fe-auth-sasl.h.
+ */
+static void *
+oauth_init(PGconn *conn, const char *password,
+		   const char *sasl_mechanism)
+{
+	fe_oauth_state *state;
+
+	/*
+	 * We only support one SASL mechanism here; anything else is programmer
+	 * error.
+	 */
+	Assert(sasl_mechanism != NULL);
+	Assert(strcmp(sasl_mechanism, OAUTHBEARER_NAME) == 0);
+
+	state = calloc(1, sizeof(*state));
+	if (!state)
+		return NULL;
+
+	state->step = FE_OAUTH_INIT;
+	state->conn = conn;
+
+	return state;
+}
+
+/*
+ * Frees the state allocated by oauth_init().
+ *
+ * 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().
+ */
+static void
+oauth_free(void *opaq)
+{
+	fe_oauth_state *state = opaq;
+
+	/* Any async authentication state should have been cleaned up already. */
+	Assert(!state->async_ctx);
+
+	free(state);
+}
+
+#define kvsep "\x01"
+
+/*
+ * Constructs an OAUTHBEARER client initial response (RFC 7628, Sec. 3.1).
+ *
+ * If discover is true, the initial response will contain a request for the
+ * server's required OAuth parameters (Sec. 4.3). Otherwise, conn->token must
+ * be set; it will be sent as the connection's bearer token.
+ *
+ * Returns the response as a null-terminated string, or NULL on error.
+ */
+static char *
+client_initial_response(PGconn *conn, bool discover)
+{
+	static const char *const resp_format = "n,," kvsep "auth=%s%s" kvsep kvsep;
+
+	PQExpBufferData buf;
+	const char *authn_scheme;
+	char	   *response = NULL;
+	const char *token = conn->oauth_token;
+
+	if (discover)
+	{
+		/* Parameter discovery uses a completely empty auth value. */
+		authn_scheme = token = "";
+	}
+	else
+	{
+		/*
+		 * Use a Bearer authentication scheme (RFC 6750, Sec. 2.1). A trailing
+		 * space is used as a separator.
+		 */
+		authn_scheme = "Bearer ";
+
+		/* conn->token must have been set in this case. */
+		if (!token)
+		{
+			Assert(false);
+			libpq_append_conn_error(conn,
+									"internal error: no OAuth token was set for the connection");
+			return NULL;
+		}
+	}
+
+	initPQExpBuffer(&buf);
+	appendPQExpBuffer(&buf, resp_format, authn_scheme, token);
+
+	if (!PQExpBufferDataBroken(buf))
+		response = strdup(buf.data);
+	termPQExpBuffer(&buf);
+
+	if (!response)
+		libpq_append_conn_error(conn, "out of memory");
+
+	return response;
+}
+
+/*
+ * JSON Parser (for the OAUTHBEARER error result)
+ */
+
+/* Relevant JSON fields in the error result object. */
+#define ERROR_STATUS_FIELD "status"
+#define ERROR_SCOPE_FIELD "scope"
+#define ERROR_OPENID_CONFIGURATION_FIELD "openid-configuration"
+
+struct json_ctx
+{
+	char	   *errmsg;			/* any non-NULL value stops all processing */
+	PQExpBufferData errbuf;		/* backing memory for errmsg */
+	int			nested;			/* nesting level (zero is the top) */
+
+	const char *target_field_name;	/* points to a static allocation */
+	char	  **target_field;	/* see below */
+
+	/* target_field, if set, points to one of the following: */
+	char	   *status;
+	char	   *scope;
+	char	   *discovery_uri;
+};
+
+#define oauth_json_has_error(ctx) \
+	(PQExpBufferDataBroken((ctx)->errbuf) || (ctx)->errmsg)
+
+#define oauth_json_set_error(ctx, ...) \
+	do { \
+		appendPQExpBuffer(&(ctx)->errbuf, __VA_ARGS__); \
+		(ctx)->errmsg = (ctx)->errbuf.data; \
+	} while (0)
+
+static JsonParseErrorType
+oauth_json_object_start(void *state)
+{
+	struct json_ctx *ctx = state;
+
+	if (ctx->target_field)
+	{
+		Assert(ctx->nested == 1);
+
+		oauth_json_set_error(ctx,
+							 libpq_gettext("field \"%s\" must be a string"),
+							 ctx->target_field_name);
+	}
+
+	++ctx->nested;
+	return oauth_json_has_error(ctx) ? JSON_SEM_ACTION_FAILED : JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_object_end(void *state)
+{
+	struct json_ctx *ctx = state;
+
+	--ctx->nested;
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_object_field_start(void *state, char *name, bool isnull)
+{
+	struct json_ctx *ctx = state;
+
+	/* Only top-level keys are considered. */
+	if (ctx->nested == 1)
+	{
+		if (strcmp(name, ERROR_STATUS_FIELD) == 0)
+		{
+			ctx->target_field_name = ERROR_STATUS_FIELD;
+			ctx->target_field = &ctx->status;
+		}
+		else if (strcmp(name, ERROR_SCOPE_FIELD) == 0)
+		{
+			ctx->target_field_name = ERROR_SCOPE_FIELD;
+			ctx->target_field = &ctx->scope;
+		}
+		else if (strcmp(name, ERROR_OPENID_CONFIGURATION_FIELD) == 0)
+		{
+			ctx->target_field_name = ERROR_OPENID_CONFIGURATION_FIELD;
+			ctx->target_field = &ctx->discovery_uri;
+		}
+	}
+
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_array_start(void *state)
+{
+	struct json_ctx *ctx = state;
+
+	if (!ctx->nested)
+	{
+		ctx->errmsg = libpq_gettext("top-level element must be an object");
+	}
+	else if (ctx->target_field)
+	{
+		Assert(ctx->nested == 1);
+
+		oauth_json_set_error(ctx,
+							 libpq_gettext("field \"%s\" must be a string"),
+							 ctx->target_field_name);
+	}
+
+	return oauth_json_has_error(ctx) ? JSON_SEM_ACTION_FAILED : JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_scalar(void *state, char *token, JsonTokenType type)
+{
+	struct json_ctx *ctx = state;
+
+	if (!ctx->nested)
+	{
+		ctx->errmsg = libpq_gettext("top-level element must be an object");
+		return JSON_SEM_ACTION_FAILED;
+	}
+
+	if (ctx->target_field)
+	{
+		if (ctx->nested != 1)
+		{
+			/*
+			 * ctx->target_field should not have been set for nested keys.
+			 * Assert and don't continue any further for production builds.
+			 */
+			Assert(false);
+			oauth_json_set_error(ctx,
+								 "internal error: target scalar found at nesting level %d during OAUTHBEARER parsing",
+								 ctx->nested);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		/*
+		 * We don't allow duplicate field names; error out if the target has
+		 * already been set.
+		 */
+		if (*ctx->target_field)
+		{
+			oauth_json_set_error(ctx,
+								 libpq_gettext("field \"%s\" is duplicated"),
+								 ctx->target_field_name);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		/* The only fields we support are strings. */
+		if (type != JSON_TOKEN_STRING)
+		{
+			oauth_json_set_error(ctx,
+								 libpq_gettext("field \"%s\" must be a string"),
+								 ctx->target_field_name);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		*ctx->target_field = strdup(token);
+		if (!*ctx->target_field)
+			return JSON_OUT_OF_MEMORY;
+
+		ctx->target_field = NULL;
+		ctx->target_field_name = NULL;
+	}
+	else
+	{
+		/* otherwise we just ignore it */
+	}
+
+	return JSON_SUCCESS;
+}
+
+#define HTTPS_SCHEME "https://"
+#define HTTP_SCHEME "http://"
+
+/* We support both well-known suffixes defined by RFC 8414. */
+#define WK_PREFIX "/.well-known/"
+#define OPENID_WK_SUFFIX "openid-configuration"
+#define OAUTH_WK_SUFFIX "oauth-authorization-server"
+
+/*
+ * Derives an issuer identifier from one of our recognized .well-known URIs,
+ * using the rules in RFC 8414.
+ */
+static char *
+issuer_from_well_known_uri(PGconn *conn, const char *wkuri)
+{
+	const char *authority_start = NULL;
+	const char *wk_start;
+	const char *wk_end;
+	char	   *issuer;
+	ptrdiff_t	start_offset,
+				end_offset;
+	size_t		end_len;
+
+	/*
+	 * https:// is required for issuer identifiers (RFC 8414, Sec. 2; OIDC
+	 * Discovery 1.0, Sec. 3). This is a case-insensitive comparison at this
+	 * level (but issuer identifier comparison at the level above this is
+	 * case-sensitive, so in practice it's probably moot).
+	 */
+	if (pg_strncasecmp(wkuri, HTTPS_SCHEME, strlen(HTTPS_SCHEME)) == 0)
+		authority_start = wkuri + strlen(HTTPS_SCHEME);
+
+	if (!authority_start
+		&& oauth_unsafe_debugging_enabled()
+		&& pg_strncasecmp(wkuri, HTTP_SCHEME, strlen(HTTP_SCHEME)) == 0)
+	{
+		/* Allow http:// for testing only. */
+		authority_start = wkuri + strlen(HTTP_SCHEME);
+	}
+
+	if (!authority_start)
+	{
+		libpq_append_conn_error(conn,
+								"OAuth discovery URI \"%s\" must use HTTPS",
+								wkuri);
+		return NULL;
+	}
+
+	/*
+	 * Well-known URIs in general may support queries and fragments, but the
+	 * two types we support here do not. (They must be constructed from the
+	 * components of issuer identifiers, which themselves may not contain any
+	 * queries or fragments.)
+	 *
+	 * It's important to check this first, to avoid getting tricked later by a
+	 * prefix buried inside a query or fragment.
+	 */
+	if (strpbrk(authority_start, "?#") != NULL)
+	{
+		libpq_append_conn_error(conn,
+								"OAuth discovery URI \"%s\" must not contain query or fragment components",
+								wkuri);
+		return NULL;
+	}
+
+	/*
+	 * Find the start of the .well-known prefix. IETF rules (RFC 8615) state
+	 * this must be at the beginning of the path component, but OIDC defined
+	 * it at the end instead (OIDC Discovery 1.0, Sec. 4), so we have to
+	 * search for it anywhere.
+	 */
+	wk_start = strstr(authority_start, WK_PREFIX);
+	if (!wk_start)
+	{
+		libpq_append_conn_error(conn,
+								"OAuth discovery URI \"%s\" is not a .well-known URI",
+								wkuri);
+		return NULL;
+	}
+
+	/*
+	 * Now find the suffix type. We only support the two defined in OIDC
+	 * Discovery 1.0 and RFC 8414.
+	 */
+	wk_end = wk_start + strlen(WK_PREFIX);
+
+	if (strncmp(wk_end, OPENID_WK_SUFFIX, strlen(OPENID_WK_SUFFIX)) == 0)
+		wk_end += strlen(OPENID_WK_SUFFIX);
+	else if (strncmp(wk_end, OAUTH_WK_SUFFIX, strlen(OAUTH_WK_SUFFIX)) == 0)
+		wk_end += strlen(OAUTH_WK_SUFFIX);
+	else
+		wk_end = NULL;
+
+	/*
+	 * Even if there's a match, we still need to check to make sure the suffix
+	 * takes up the entire path segment, to weed out constructions like
+	 * "/.well-known/openid-configuration-bad".
+	 */
+	if (!wk_end || (*wk_end != '/' && *wk_end != '\0'))
+	{
+		libpq_append_conn_error(conn,
+								"OAuth discovery URI \"%s\" uses an unsupported .well-known suffix",
+								wkuri);
+		return NULL;
+	}
+
+	/*
+	 * Finally, make sure the .well-known components are provided either as a
+	 * prefix (IETF style) or as a postfix (OIDC style). In other words,
+	 * "https://localhost/a/.well-known/openid-configuration/b" is not allowed
+	 * to claim association with "https://localhost/a/b".
+	 */
+	if (*wk_end != '\0')
+	{
+		/*
+		 * It's not at the end, so it's required to be at the beginning at the
+		 * path. Find the starting slash.
+		 */
+		const char *path_start;
+
+		path_start = strchr(authority_start, '/');
+		Assert(path_start);		/* otherwise we wouldn't have found WK_PREFIX */
+
+		if (wk_start != path_start)
+		{
+			libpq_append_conn_error(conn,
+									"OAuth discovery URI \"%s\" uses an invalid format",
+									wkuri);
+			return NULL;
+		}
+	}
+
+	/* Checks passed! Now build the issuer. */
+	issuer = strdup(wkuri);
+	if (!issuer)
+	{
+		libpq_append_conn_error(conn, "out of memory");
+		return NULL;
+	}
+
+	/*
+	 * The .well-known components are from [wk_start, wk_end). Remove those to
+	 * form the issuer ID, by shifting the path suffix (which may be empty)
+	 * leftwards.
+	 */
+	start_offset = wk_start - wkuri;
+	end_offset = wk_end - wkuri;
+	end_len = strlen(wk_end) + 1;	/* move the NULL terminator too */
+
+	memmove(issuer + start_offset, issuer + end_offset, end_len);
+
+	return issuer;
+}
+
+/*
+ * Parses the server error result (RFC 7628, Sec. 3.2.2) contained in msg and
+ * stores any discovered openid_configuration and scope settings for the
+ * connection.
+ */
+static bool
+handle_oauth_sasl_error(PGconn *conn, const char *msg, int msglen)
+{
+	JsonLexContext lex = {0};
+	JsonSemAction sem = {0};
+	JsonParseErrorType err;
+	struct json_ctx ctx = {0};
+	char	   *errmsg = NULL;
+	bool		success = false;
+
+	Assert(conn->oauth_issuer_id);	/* ensured by setup_oauth_parameters() */
+
+	/* Sanity check. */
+	if (strlen(msg) != msglen)
+	{
+		libpq_append_conn_error(conn,
+								"server's error message contained an embedded NULL, and was discarded");
+		return false;
+	}
+
+	/*
+	 * pg_parse_json doesn't validate the incoming UTF-8, so we have to check
+	 * that up front.
+	 */
+	if (pg_encoding_verifymbstr(PG_UTF8, msg, msglen) != msglen)
+	{
+		libpq_append_conn_error(conn,
+								"server's error response is not valid UTF-8");
+		return false;
+	}
+
+	makeJsonLexContextCstringLen(&lex, msg, msglen, PG_UTF8, true);
+	setJsonLexContextOwnsTokens(&lex, true);	/* must not leak on error */
+
+	initPQExpBuffer(&ctx.errbuf);
+	sem.semstate = &ctx;
+
+	sem.object_start = oauth_json_object_start;
+	sem.object_end = oauth_json_object_end;
+	sem.object_field_start = oauth_json_object_field_start;
+	sem.array_start = oauth_json_array_start;
+	sem.scalar = oauth_json_scalar;
+
+	err = pg_parse_json(&lex, &sem);
+
+	if (err == JSON_SEM_ACTION_FAILED)
+	{
+		if (PQExpBufferDataBroken(ctx.errbuf))
+			errmsg = libpq_gettext("out of memory");
+		else if (ctx.errmsg)
+			errmsg = ctx.errmsg;
+		else
+		{
+			/*
+			 * Developer error: one of the action callbacks didn't call
+			 * oauth_json_set_error() before erroring out.
+			 */
+			Assert(oauth_json_has_error(&ctx));
+			errmsg = "<unexpected empty error>";
+		}
+	}
+	else if (err != JSON_SUCCESS)
+		errmsg = json_errdetail(err, &lex);
+
+	if (errmsg)
+		libpq_append_conn_error(conn,
+								"failed to parse server's error response: %s",
+								errmsg);
+
+	/* Don't need the error buffer or the JSON lexer anymore. */
+	termPQExpBuffer(&ctx.errbuf);
+	freeJsonLexContext(&lex);
+
+	if (errmsg)
+		goto cleanup;
+
+	if (ctx.discovery_uri)
+	{
+		char	   *discovery_issuer;
+
+		/*
+		 * The URI MUST correspond to our existing issuer, to avoid mix-ups.
+		 *
+		 * Issuer comparison is done byte-wise, rather than performing any URL
+		 * normalization; this follows the suggestions for issuer comparison
+		 * in RFC 9207 Sec. 2.4 (which requires simple string comparison) and
+		 * vastly simplifies things. Since this is the key protection against
+		 * a rogue server sending the client to an untrustworthy location,
+		 * simpler is better.
+		 */
+		discovery_issuer = issuer_from_well_known_uri(conn, ctx.discovery_uri);
+		if (!discovery_issuer)
+			goto cleanup;		/* error message already set */
+
+		if (strcmp(conn->oauth_issuer_id, discovery_issuer) != 0)
+		{
+			libpq_append_conn_error(conn,
+									"server's discovery document at %s (issuer \"%s\") is incompatible with oauth_issuer (%s)",
+									ctx.discovery_uri, discovery_issuer,
+									conn->oauth_issuer_id);
+
+			free(discovery_issuer);
+			goto cleanup;
+		}
+
+		free(discovery_issuer);
+
+		if (!conn->oauth_discovery_uri)
+		{
+			conn->oauth_discovery_uri = ctx.discovery_uri;
+			ctx.discovery_uri = NULL;
+		}
+		else
+		{
+			/* This must match the URI we'd previously determined. */
+			if (strcmp(conn->oauth_discovery_uri, ctx.discovery_uri) != 0)
+			{
+				libpq_append_conn_error(conn,
+										"server's discovery document has moved to %s (previous location was %s)",
+										ctx.discovery_uri,
+										conn->oauth_discovery_uri);
+				goto cleanup;
+			}
+		}
+	}
+
+	if (ctx.scope)
+	{
+		/* Servers may not override a previously set oauth_scope. */
+		if (!conn->oauth_scope)
+		{
+			conn->oauth_scope = ctx.scope;
+			ctx.scope = NULL;
+		}
+	}
+
+	if (!ctx.status)
+	{
+		libpq_append_conn_error(conn,
+								"server sent error response without a status");
+		goto cleanup;
+	}
+
+	if (strcmp(ctx.status, "invalid_token") != 0)
+	{
+		/*
+		 * invalid_token is the only error code we'll automatically retry for;
+		 * otherwise, just bail out now.
+		 */
+		libpq_append_conn_error(conn,
+								"server rejected OAuth bearer token: %s",
+								ctx.status);
+		goto cleanup;
+	}
+
+	success = true;
+
+cleanup:
+	free(ctx.status);
+	free(ctx.scope);
+	free(ctx.discovery_uri);
+
+	return success;
+}
+
+/*
+ * Callback implementation of conn->async_auth() for a user-defined OAuth flow.
+ * Delegates the retrieval of the token to the application's 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_*
+ * statuses for use by PQconnectPoll().
+ */
+static PostgresPollingStatusType
+run_user_oauth_flow(PGconn *conn)
+{
+	fe_oauth_state *state = conn->sasl_state;
+	PGoauthBearerRequest *request = state->async_ctx;
+	PostgresPollingStatusType status;
+
+	if (!request->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);
+	if (status == PGRES_POLLING_FAILED)
+	{
+		libpq_append_conn_error(conn, "user-defined OAuth flow failed");
+		return status;
+	}
+	else if (status == PGRES_POLLING_OK)
+	{
+		/*
+		 * 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.)
+		 */
+		if (!request->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);
+		if (!conn->oauth_token)
+		{
+			libpq_append_conn_error(conn, "out of memory");
+			return PGRES_POLLING_FAILED;
+		}
+
+		return PGRES_POLLING_OK;
+	}
+
+	/* The hook wants the client to poll the altsock. Make sure it set one. */
+	if (conn->altsock == PGINVALID_SOCKET)
+	{
+		libpq_append_conn_error(conn,
+								"user-defined OAuth flow did not provide a socket for polling");
+		return PGRES_POLLING_FAILED;
+	}
+
+	return status;
+}
+
+/*
+ * Cleanup callback for the async user flow. Delegates most of its job to the
+ * user-provided cleanup implementation, then disconnects the altsock.
+ */
+static void
+cleanup_user_oauth_flow(PGconn *conn)
+{
+	fe_oauth_state *state = conn->sasl_state;
+	PGoauthBearerRequest *request = state->async_ctx;
+
+	Assert(request);
+
+	if (request->cleanup)
+		request->cleanup(conn, request);
+	conn->altsock = PGINVALID_SOCKET;
+
+	free(request);
+	state->async_ctx = NULL;
+}
+
+/*
+ * Chooses an OAuth client flow for the connection, which will retrieve a Bearer
+ * 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
+ * asynchronous callbacks which will be managed by run_user_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.)
+ *
+ * If neither a custom handler nor the builtin flow is available, the connection
+ * fails here.
+ */
+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,
+	};
+
+	Assert(request.openid_configuration);
+
+	/* The client may have overridden the OAuth flow. */
+	res = PQauthDataHook(PQAUTHDATA_OAUTH_BEARER_TOKEN, conn, &request);
+	if (res > 0)
+	{
+		PGoauthBearerRequest *request_copy;
+
+		if (request.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);
+			if (!conn->oauth_token)
+			{
+				libpq_append_conn_error(conn, "out of memory");
+				goto fail;
+			}
+
+			/* short-circuit */
+			if (request.cleanup)
+				request.cleanup(conn, &request);
+			return true;
+		}
+
+		request_copy = malloc(sizeof(*request_copy));
+		if (!request_copy)
+		{
+			libpq_append_conn_error(conn, "out of memory");
+			goto fail;
+		}
+
+		memcpy(request_copy, &request, sizeof(request));
+
+		conn->async_auth = run_user_oauth_flow;
+		conn->cleanup_async_auth = cleanup_user_oauth_flow;
+		state->async_ctx = request_copy;
+	}
+	else if (res < 0)
+	{
+		libpq_append_conn_error(conn, "user-defined OAuth flow failed");
+		goto fail;
+	}
+	else
+	{
+#if USE_LIBCURL
+		/* Hand off to our built-in OAuth flow. */
+		conn->async_auth = pg_fe_run_oauth_flow;
+		conn->cleanup_async_auth = pg_fe_cleanup_oauth_flow;
+
+#else
+		libpq_append_conn_error(conn, "no custom OAuth flows are available, and libpq was not built with libcurl support");
+		goto fail;
+
+#endif
+	}
+
+	return true;
+
+fail:
+	if (request.cleanup)
+		request.cleanup(conn, &request);
+	return false;
+}
+
+/*
+ * Fill in our issuer identifier and discovery URI, if possible, using the
+ * connection parameters. If conn->oauth_discovery_uri can't be populated in
+ * this function, it will be requested from the server.
+ */
+static bool
+setup_oauth_parameters(PGconn *conn)
+{
+	/*---
+	 * To talk to a server, we require the user to provide issuer and client
+	 * identifiers.
+	 *
+	 * While it's possible for an OAuth client to support multiple issuers, it
+	 * requires additional effort to make sure the flows in use are safe -- to
+	 * quote RFC 9207,
+	 *
+	 *     OAuth clients that interact with only one authorization server are
+	 *     not vulnerable to mix-up attacks. However, when such clients decide
+	 *     to add support for a second authorization server in the future, they
+	 *     become vulnerable and need to apply countermeasures to mix-up
+	 *     attacks.
+	 *
+	 * For now, we allow only one.
+	 */
+	if (!conn->oauth_issuer || !conn->oauth_client_id)
+	{
+		libpq_append_conn_error(conn,
+								"server requires OAuth authentication, but oauth_issuer and oauth_client_id are not both set");
+		return false;
+	}
+
+	/*
+	 * oauth_issuer is interpreted differently if it's a well-known discovery
+	 * URI rather than just an issuer identifier.
+	 */
+	if (strstr(conn->oauth_issuer, WK_PREFIX) != NULL)
+	{
+		/*
+		 * Convert the URI back to an issuer identifier. (This also performs
+		 * validation of the URI format.)
+		 */
+		conn->oauth_issuer_id = issuer_from_well_known_uri(conn,
+														   conn->oauth_issuer);
+		if (!conn->oauth_issuer_id)
+			return false;		/* error message already set */
+
+		conn->oauth_discovery_uri = strdup(conn->oauth_issuer);
+		if (!conn->oauth_discovery_uri)
+		{
+			libpq_append_conn_error(conn, "out of memory");
+			return false;
+		}
+	}
+	else
+	{
+		/*
+		 * Treat oauth_issuer as an issuer identifier. We'll ask the server
+		 * for the discovery URI.
+		 */
+		conn->oauth_issuer_id = strdup(conn->oauth_issuer);
+		if (!conn->oauth_issuer_id)
+		{
+			libpq_append_conn_error(conn, "out of memory");
+			return false;
+		}
+	}
+
+	return true;
+}
+
+/*
+ * Implements the OAUTHBEARER SASL exchange (RFC 7628, Sec. 3.2).
+ *
+ * If the necessary OAuth parameters are set up on the connection, this will run
+ * the client flow asynchronously and present the resulting token to the server.
+ * Otherwise, an empty discovery response will be sent and any parameters sent
+ * back by the server will be stored for a second attempt.
+ *
+ * For a full description of the API, see libpq/sasl.h.
+ */
+static SASLStatus
+oauth_exchange(void *opaq, bool final,
+			   char *input, int inputlen,
+			   char **output, int *outputlen)
+{
+	fe_oauth_state *state = opaq;
+	PGconn	   *conn = state->conn;
+	bool		discover = false;
+
+	*output = NULL;
+	*outputlen = 0;
+
+	switch (state->step)
+	{
+		case FE_OAUTH_INIT:
+			/* We begin in the initial response phase. */
+			Assert(inputlen == -1);
+
+			if (!setup_oauth_parameters(conn))
+				return SASL_FAILED;
+
+			if (conn->oauth_token)
+			{
+				/*
+				 * A previous connection already fetched the token; we'll use
+				 * it below.
+				 */
+			}
+			else if (conn->oauth_discovery_uri)
+			{
+				/*
+				 * We don't have a token, but we have a discovery URI already
+				 * stored. Decide whether we're using a user-provided OAuth
+				 * flow or the one we have built in.
+				 */
+				if (!setup_token_request(conn, state))
+					return SASL_FAILED;
+
+				if (conn->oauth_token)
+				{
+					/*
+					 * A really smart user implementation may have already
+					 * given us the token (e.g. if there was an unexpired copy
+					 * already cached), and we can use it immediately.
+					 */
+				}
+				else
+				{
+					/*
+					 * Otherwise, we'll have to hand the connection over to
+					 * our OAuth implementation.
+					 *
+					 * This could take a while, since it generally involves a
+					 * user in the loop. To avoid consuming the server's
+					 * authentication timeout, we'll continue this handshake
+					 * to the end, so that the server can close its side of
+					 * the connection. We'll open a second connection later
+					 * once we've retrieved a token.
+					 */
+					discover = true;
+				}
+			}
+			else
+			{
+				/*
+				 * If we don't have a token, and we don't have a discovery URI
+				 * to be able to request a token, we ask the server for one
+				 * explicitly.
+				 */
+				discover = true;
+			}
+
+			/*
+			 * Generate an initial response. This either contains a token, if
+			 * we have one, or an empty discovery response which is doomed to
+			 * fail.
+			 */
+			*output = client_initial_response(conn, discover);
+			if (!*output)
+				return SASL_FAILED;
+
+			*outputlen = strlen(*output);
+			state->step = FE_OAUTH_BEARER_SENT;
+
+			if (conn->oauth_token)
+			{
+				/*
+				 * For the purposes of require_auth, our side of
+				 * authentication is done at this point; the server will
+				 * either accept the connection or send an error. Unlike
+				 * SCRAM, there is no additional server data to check upon
+				 * success.
+				 */
+				conn->client_finished_auth = true;
+			}
+
+			return SASL_CONTINUE;
+
+		case FE_OAUTH_BEARER_SENT:
+			if (final)
+			{
+				/*
+				 * OAUTHBEARER does not make use of additional data with a
+				 * successful SASL exchange, so we shouldn't get an
+				 * AuthenticationSASLFinal message.
+				 */
+				libpq_append_conn_error(conn,
+										"server sent unexpected additional OAuth data");
+				return SASL_FAILED;
+			}
+
+			/*
+			 * An error message was sent by the server. Respond with the
+			 * required dummy message (RFC 7628, sec. 3.2.3).
+			 */
+			*output = strdup(kvsep);
+			if (unlikely(!*output))
+			{
+				libpq_append_conn_error(conn, "out of memory");
+				return SASL_FAILED;
+			}
+			*outputlen = strlen(*output);	/* == 1 */
+
+			/* Grab the settings from discovery. */
+			if (!handle_oauth_sasl_error(conn, input, inputlen))
+				return SASL_FAILED;
+
+			if (conn->oauth_token)
+			{
+				/*
+				 * The server rejected our token. Continue onwards towards the
+				 * expected FATAL message, but mark our state to catch any
+				 * unexpected "success" from the server.
+				 */
+				state->step = FE_OAUTH_SERVER_ERROR;
+				return SASL_CONTINUE;
+			}
+
+			if (!conn->async_auth)
+			{
+				/*
+				 * No OAuth flow is set up yet. Did we get enough information
+				 * from the server to create one?
+				 */
+				if (!conn->oauth_discovery_uri)
+				{
+					libpq_append_conn_error(conn,
+											"server requires OAuth authentication, but no discovery metadata was provided");
+					return SASL_FAILED;
+				}
+
+				/* Yes. Set up the flow now. */
+				if (!setup_token_request(conn, state))
+					return SASL_FAILED;
+
+				if (conn->oauth_token)
+				{
+					/*
+					 * A token was available in a custom flow's cache. Skip
+					 * the asynchronous processing.
+					 */
+					goto reconnect;
+				}
+			}
+
+			/*
+			 * Time to retrieve a token. This involves a number of HTTP
+			 * connections and timed waits, so we escape the synchronous auth
+			 * processing and tell PQconnectPoll to transfer control to our
+			 * async implementation.
+			 */
+			Assert(conn->async_auth);	/* should have been set already */
+			state->step = FE_OAUTH_REQUESTING_TOKEN;
+			return SASL_ASYNC;
+
+		case FE_OAUTH_REQUESTING_TOKEN:
+
+			/*
+			 * We've returned successfully from token retrieval. Double-check
+			 * that we have what we need for the next connection.
+			 */
+			if (!conn->oauth_token)
+			{
+				Assert(false);	/* should have failed before this point! */
+				libpq_append_conn_error(conn,
+										"internal error: OAuth flow did not set a token");
+				return SASL_FAILED;
+			}
+
+			goto reconnect;
+
+		case FE_OAUTH_SERVER_ERROR:
+
+			/*
+			 * After an error, the server should send an error response to
+			 * fail the SASL handshake, which is handled in higher layers.
+			 *
+			 * If we get here, the server either sent *another* challenge
+			 * which isn't defined in the RFC, or completed the handshake
+			 * successfully after telling us it was going to fail. Neither is
+			 * acceptable.
+			 */
+			libpq_append_conn_error(conn,
+									"server sent additional OAuth data after error");
+			return SASL_FAILED;
+
+		default:
+			libpq_append_conn_error(conn, "invalid OAuth exchange state");
+			break;
+	}
+
+	Assert(false);				/* should never get here */
+	return SASL_FAILED;
+
+reconnect:
+
+	/*
+	 * Despite being a failure from the point of view of SASL, we have enough
+	 * information to restart with a new connection.
+	 */
+	libpq_append_conn_error(conn, "retrying connection with new bearer token");
+	conn->oauth_want_retry = true;
+	return SASL_FAILED;
+}
+
+static bool
+oauth_channel_bound(void *opaq)
+{
+	/* This mechanism does not support channel binding. */
+	return false;
+}
+
+/*
+ * Fully clears out any stored OAuth token. This is done proactively upon
+ * successful connection as well as during pqClosePGconn().
+ */
+void
+pqClearOAuthToken(PGconn *conn)
+{
+	if (!conn->oauth_token)
+		return;
+
+	explicit_bzero(conn->oauth_token, strlen(conn->oauth_token));
+	free(conn->oauth_token);
+	conn->oauth_token = NULL;
+}
+
+/*
+ * Returns true if the PGOAUTHDEBUG=UNSAFE flag is set in the environment.
+ */
+bool
+oauth_unsafe_debugging_enabled(void)
+{
+	const char *env = getenv("PGOAUTHDEBUG");
+
+	return (env && strcmp(env, "UNSAFE") == 0);
+}
diff --git a/src/interfaces/libpq/fe-auth-oauth.h b/src/interfaces/libpq/fe-auth-oauth.h
new file mode 100644
index 00000000000..32598721686
--- /dev/null
+++ b/src/interfaces/libpq/fe-auth-oauth.h
@@ -0,0 +1,45 @@
+/*-------------------------------------------------------------------------
+ *
+ * fe-auth-oauth.h
+ *
+ *	  Definitions for OAuth authentication implementations
+ *
+ * Portions Copyright (c) 2024, PostgreSQL Global Development Group
+ *
+ * src/interfaces/libpq/fe-auth-oauth.h
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#ifndef FE_AUTH_OAUTH_H
+#define FE_AUTH_OAUTH_H
+
+#include "libpq-fe.h"
+#include "libpq-int.h"
+
+
+enum fe_oauth_step
+{
+	FE_OAUTH_INIT,
+	FE_OAUTH_BEARER_SENT,
+	FE_OAUTH_REQUESTING_TOKEN,
+	FE_OAUTH_SERVER_ERROR,
+};
+
+typedef struct
+{
+	enum fe_oauth_step step;
+
+	PGconn	   *conn;
+	void	   *async_ctx;
+} fe_oauth_state;
+
+extern PostgresPollingStatusType pg_fe_run_oauth_flow(PGconn *conn);
+extern void pg_fe_cleanup_oauth_flow(PGconn *conn);
+extern void pqClearOAuthToken(PGconn *conn);
+extern bool oauth_unsafe_debugging_enabled(void);
+
+/* Mechanisms in fe-auth-oauth.c */
+extern const pg_fe_sasl_mech pg_oauth_mech;
+
+#endif							/* FE_AUTH_OAUTH_H */
diff --git a/src/interfaces/libpq/fe-auth.c b/src/interfaces/libpq/fe-auth.c
index 761ee8f88f7..ec7a9236044 100644
--- a/src/interfaces/libpq/fe-auth.c
+++ b/src/interfaces/libpq/fe-auth.c
@@ -40,9 +40,11 @@
 #endif
 
 #include "common/md5.h"
+#include "common/oauth-common.h"
 #include "common/scram-common.h"
 #include "fe-auth.h"
 #include "fe-auth-sasl.h"
+#include "fe-auth-oauth.h"
 #include "libpq-fe.h"
 
 #ifdef ENABLE_GSS
@@ -535,6 +537,13 @@ pg_SASL_init(PGconn *conn, int payloadlen, bool *async)
 			conn->sasl = &pg_scram_mech;
 			conn->password_needed = true;
 		}
+		else if (strcmp(mechanism_buf.data, OAUTHBEARER_NAME) == 0 &&
+				 !selected_mechanism)
+		{
+			selected_mechanism = OAUTHBEARER_NAME;
+			conn->sasl = &pg_oauth_mech;
+			conn->password_needed = false;
+		}
 	}
 
 	if (!selected_mechanism)
@@ -559,13 +568,6 @@ pg_SASL_init(PGconn *conn, int payloadlen, bool *async)
 
 		if (!allowed)
 		{
-			/*
-			 * TODO: this is dead code until a second SASL mechanism is added;
-			 * the connection can't have proceeded past check_expected_areq()
-			 * if no SASL methods are allowed.
-			 */
-			Assert(false);
-
 			libpq_append_conn_error(conn, "authentication method requirement \"%s\" failed: server requested %s authentication",
 									conn->require_auth, selected_mechanism);
 			goto error;
@@ -1580,3 +1582,23 @@ PQchangePassword(PGconn *conn, const char *user, const char *passwd)
 		}
 	}
 }
+
+PQauthDataHook_type PQauthDataHook = PQdefaultAuthDataHook;
+
+PQauthDataHook_type
+PQgetAuthDataHook(void)
+{
+	return PQauthDataHook;
+}
+
+void
+PQsetAuthDataHook(PQauthDataHook_type hook)
+{
+	PQauthDataHook = hook ? hook : PQdefaultAuthDataHook;
+}
+
+int
+PQdefaultAuthDataHook(PGauthData type, PGconn *conn, void *data)
+{
+	return 0;					/* handle nothing */
+}
diff --git a/src/interfaces/libpq/fe-auth.h b/src/interfaces/libpq/fe-auth.h
index 1d4991f8996..de98e0d20c4 100644
--- a/src/interfaces/libpq/fe-auth.h
+++ b/src/interfaces/libpq/fe-auth.h
@@ -18,6 +18,9 @@
 #include "libpq-int.h"
 
 
+extern PQauthDataHook_type PQauthDataHook;
+
+
 /* Prototypes for functions in fe-auth.c */
 extern int	pg_fe_sendauth(AuthRequest areq, int payloadlen, PGconn *conn,
 						   bool *async);
diff --git a/src/interfaces/libpq/fe-connect.c b/src/interfaces/libpq/fe-connect.c
index 85d1ca2864f..d5051f5e820 100644
--- a/src/interfaces/libpq/fe-connect.c
+++ b/src/interfaces/libpq/fe-connect.c
@@ -28,6 +28,7 @@
 #include "common/scram-common.h"
 #include "common/string.h"
 #include "fe-auth.h"
+#include "fe-auth-oauth.h"
 #include "libpq-fe.h"
 #include "libpq-int.h"
 #include "mb/pg_wchar.h"
@@ -373,6 +374,23 @@ static const internalPQconninfoOption PQconninfoOptions[] = {
 	{"scram_server_key", NULL, NULL, NULL, "SCRAM-Server-Key", "D", SCRAM_MAX_KEY_LEN * 2,
 	offsetof(struct pg_conn, scram_server_key)},
 
+	/* OAuth v2 */
+	{"oauth_issuer", NULL, NULL, NULL,
+		"OAuth-Issuer", "", 40,
+	offsetof(struct pg_conn, oauth_issuer)},
+
+	{"oauth_client_id", NULL, NULL, NULL,
+		"OAuth-Client-ID", "", 40,
+	offsetof(struct pg_conn, oauth_client_id)},
+
+	{"oauth_client_secret", NULL, NULL, NULL,
+		"OAuth-Client-Secret", "", 40,
+	offsetof(struct pg_conn, oauth_client_secret)},
+
+	{"oauth_scope", NULL, NULL, NULL,
+		"OAuth-Scope", "", 15,
+	offsetof(struct pg_conn, oauth_scope)},
+
 	/* Terminating entry --- MUST BE LAST */
 	{NULL, NULL, NULL, NULL,
 	NULL, NULL, 0}
@@ -399,6 +417,7 @@ static const PQEnvironmentOption EnvironmentOptions[] =
 static const pg_fe_sasl_mech *supported_sasl_mechs[] =
 {
 	&pg_scram_mech,
+	&pg_oauth_mech,
 };
 #define SASL_MECHANISM_COUNT lengthof(supported_sasl_mechs)
 
@@ -655,6 +674,7 @@ pqDropServerData(PGconn *conn)
 	conn->write_failed = false;
 	free(conn->write_err_msg);
 	conn->write_err_msg = NULL;
+	conn->oauth_want_retry = false;
 
 	/*
 	 * Cancel connections need to retain their be_pid and be_key across
@@ -1144,7 +1164,7 @@ static inline void
 fill_allowed_sasl_mechs(PGconn *conn)
 {
 	/*---
-	 * We only support one mechanism at the moment, so rather than deal with a
+	 * We only support two mechanisms at the moment, so rather than deal with a
 	 * linked list, conn->allowed_sasl_mechs is an array of static length. We
 	 * rely on the compile-time assertion here to keep us honest.
 	 *
@@ -1519,6 +1539,10 @@ pqConnectOptions2(PGconn *conn)
 			{
 				mech = &pg_scram_mech;
 			}
+			else if (strcmp(method, "oauth") == 0)
+			{
+				mech = &pg_oauth_mech;
+			}
 
 			/*
 			 * Final group: meta-options.
@@ -4111,7 +4135,19 @@ keep_going:						/* We will come back to here until there is
 				conn->inStart = conn->inCursor;
 
 				if (res != STATUS_OK)
+				{
+					/*
+					 * OAuth connections may perform two-step discovery, where
+					 * the first connection is a dummy.
+					 */
+					if (conn->sasl == &pg_oauth_mech && conn->oauth_want_retry)
+					{
+						need_new_connection = true;
+						goto keep_going;
+					}
+
 					goto error_return;
+				}
 
 				/*
 				 * Just make sure that any data sent by pg_fe_sendauth is
@@ -4390,6 +4426,9 @@ keep_going:						/* We will come back to here until there is
 					}
 				}
 
+				/* Don't hold onto any OAuth tokens longer than necessary. */
+				pqClearOAuthToken(conn);
+
 				/*
 				 * For non cancel requests we can release the address list
 				 * now. For cancel requests we never actually resolve
@@ -5002,6 +5041,12 @@ freePGconn(PGconn *conn)
 	free(conn->load_balance_hosts);
 	free(conn->scram_client_key);
 	free(conn->scram_server_key);
+	free(conn->oauth_issuer);
+	free(conn->oauth_issuer_id);
+	free(conn->oauth_discovery_uri);
+	free(conn->oauth_client_id);
+	free(conn->oauth_client_secret);
+	free(conn->oauth_scope);
 	termPQExpBuffer(&conn->errorMessage);
 	termPQExpBuffer(&conn->workBuffer);
 
@@ -5155,6 +5200,7 @@ pqClosePGconn(PGconn *conn)
 	conn->asyncStatus = PGASYNC_IDLE;
 	conn->xactStatus = PQTRANS_IDLE;
 	conn->pipelineStatus = PQ_PIPELINE_OFF;
+	pqClearOAuthToken(conn);
 	pqClearAsyncResult(conn);	/* deallocate result */
 	pqClearConnErrorState(conn);
 
diff --git a/src/interfaces/libpq/libpq-fe.h b/src/interfaces/libpq/libpq-fe.h
index a3491faf0c3..b7399dee58e 100644
--- a/src/interfaces/libpq/libpq-fe.h
+++ b/src/interfaces/libpq/libpq-fe.h
@@ -59,6 +59,8 @@ extern "C"
 /* Features added in PostgreSQL v18: */
 /* Indicates presence of PQfullProtocolVersion */
 #define LIBPQ_HAS_FULL_PROTOCOL_VERSION 1
+/* Indicates presence of the PQAUTHDATA_PROMPT_OAUTH_DEVICE authdata hook */
+#define LIBPQ_HAS_PROMPT_OAUTH_DEVICE 1
 
 /*
  * Option flags for PQcopyResult
@@ -186,6 +188,13 @@ typedef enum
 	PQ_PIPELINE_ABORTED
 } PGpipelineStatus;
 
+typedef enum
+{
+	PQAUTHDATA_PROMPT_OAUTH_DEVICE, /* user must visit a device-authorization
+									 * URL */
+	PQAUTHDATA_OAUTH_BEARER_TOKEN,	/* server requests an OAuth Bearer token */
+} PGauthData;
+
 /* PGconn encapsulates a connection to the backend.
  * The contents of this struct are not supposed to be known to applications.
  */
@@ -720,10 +729,86 @@ extern int	PQenv2encoding(void);
 
 /* === in fe-auth.c === */
 
+typedef struct _PGpromptOAuthDevice
+{
+	const char *verification_uri;	/* verification URI to visit */
+	const char *user_code;		/* user code to enter */
+	const char *verification_uri_complete;	/* optional combination of URI and
+											 * code, or NULL */
+	int			expires_in;		/* seconds until user code expires */
+} PGpromptOAuthDevice;
+
+/* for PGoauthBearerRequest.async() */
+#ifdef _WIN32
+#define SOCKTYPE uintptr_t		/* avoids depending on winsock2.h for SOCKET */
+#else
+#define SOCKTYPE int
+#endif
+
+typedef struct _PGoauthBearerRequest
+{
+	/* Hook inputs (constant across all calls) */
+	const char *const openid_configuration; /* OIDC discovery URI */
+	const char *const scope;	/* required scope(s), or NULL */
+
+	/* Hook outputs */
+
+	/*---------
+	 * Callback implementing a custom asynchronous OAuth flow.
+	 *
+	 * The callback may return
+	 * - PGRES_POLLING_READING/WRITING, to indicate that a socket descriptor
+	 *   has been stored in *altsock and libpq should wait until it is
+	 *   readable or writable before calling back;
+	 * - PGRES_POLLING_OK, to indicate that the flow is complete and
+	 *   request->token has been set; or
+	 * - PGRES_POLLING_FAILED, to indicate that token retrieval has failed.
+	 *
+	 * This callback is optional. If the token can be obtained without
+	 * 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.
+	 */
+	PostgresPollingStatusType (*async) (PGconn *conn,
+										struct _PGoauthBearerRequest *request,
+										SOCKTYPE * altsock);
+
+	/*
+	 * Callback to clean up custom allocations. A hook implementation may use
+	 * this to free request->token and any resources in request->user.
+	 *
+	 * This is technically optional, but highly recommended, because there is
+	 * no other indication as to when it is safe to free the token.
+	 */
+	void		(*cleanup) (PGconn *conn, struct _PGoauthBearerRequest *request);
+
+	/*
+	 * The hook should set this to the Bearer token contents for the
+	 * connection, once the flow is completed.  The token contents must remain
+	 * available to libpq until the hook's cleanup callback is called.
+	 */
+	char	   *token;
+
+	/*
+	 * Hook-defined data. libpq will not modify this pointer across calls to
+	 * the async callback, so it can be used to keep track of
+	 * application-specific state. Resources allocated here should be freed by
+	 * the cleanup callback.
+	 */
+	void	   *user;
+} PGoauthBearerRequest;
+
+#undef SOCKTYPE
+
 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);
 
+typedef int (*PQauthDataHook_type) (PGauthData type, PGconn *conn, void *data);
+extern void PQsetAuthDataHook(PQauthDataHook_type hook);
+extern PQauthDataHook_type PQgetAuthDataHook(void);
+extern int	PQdefaultAuthDataHook(PGauthData type, PGconn *conn, void *data);
+
 /* === in encnames.c === */
 
 extern int	pg_char_to_encoding(const char *name);
diff --git a/src/interfaces/libpq/libpq-int.h b/src/interfaces/libpq/libpq-int.h
index 2546f9f8a50..f36f7f19d58 100644
--- a/src/interfaces/libpq/libpq-int.h
+++ b/src/interfaces/libpq/libpq-int.h
@@ -437,6 +437,17 @@ struct pg_conn
 								 * cancel request, instead of being a normal
 								 * connection that's used for queries */
 
+	/* OAuth v2 */
+	char	   *oauth_issuer;	/* token issuer/URL */
+	char	   *oauth_issuer_id;	/* token issuer identifier */
+	char	   *oauth_discovery_uri;	/* URI of the issuer's discovery
+										 * document */
+	char	   *oauth_client_id;	/* client identifier */
+	char	   *oauth_client_secret;	/* client secret */
+	char	   *oauth_scope;	/* access token scope */
+	char	   *oauth_token;	/* access token */
+	bool		oauth_want_retry;	/* should we retry on failure? */
+
 	/* Optional file to write trace info to */
 	FILE	   *Pfdebug;
 	int			traceFlags;
@@ -505,7 +516,7 @@ struct pg_conn
 								 * the server? */
 	uint32		allowed_auth_methods;	/* bitmask of acceptable AuthRequest
 										 * codes */
-	const pg_fe_sasl_mech *allowed_sasl_mechs[1];	/* and acceptable SASL
+	const pg_fe_sasl_mech *allowed_sasl_mechs[2];	/* and acceptable SASL
 													 * mechanisms */
 	bool		client_finished_auth;	/* have we finished our half of the
 										 * authentication exchange? */
diff --git a/src/interfaces/libpq/meson.build b/src/interfaces/libpq/meson.build
index dd64d291b3e..19f4a52a97a 100644
--- a/src/interfaces/libpq/meson.build
+++ b/src/interfaces/libpq/meson.build
@@ -1,6 +1,7 @@
 # Copyright (c) 2022-2025, PostgreSQL Global Development Group
 
 libpq_sources = files(
+  'fe-auth-oauth.c',
   'fe-auth-scram.c',
   'fe-auth.c',
   'fe-cancel.c',
@@ -37,6 +38,10 @@ if gssapi.found()
   )
 endif
 
+if libcurl.found()
+  libpq_sources += files('fe-auth-oauth-curl.c')
+endif
+
 export_file = custom_target('libpq.exports',
   kwargs: gen_export_kwargs,
 )
diff --git a/src/makefiles/meson.build b/src/makefiles/meson.build
index d49b2079a44..60e13d50235 100644
--- a/src/makefiles/meson.build
+++ b/src/makefiles/meson.build
@@ -229,6 +229,7 @@ pgxs_deps = {
   'gssapi': gssapi,
   'icu': icu,
   'ldap': ldap,
+  'libcurl': libcurl,
   'libxml': libxml,
   'libxslt': libxslt,
   'llvm': llvm,
diff --git a/src/test/authentication/t/001_password.pl b/src/test/authentication/t/001_password.pl
index 1357f806b6f..4ce22ccbdf2 100644
--- a/src/test/authentication/t/001_password.pl
+++ b/src/test/authentication/t/001_password.pl
@@ -404,11 +404,11 @@ $node->connect_fails(
 $node->connect_fails(
 	"user=scram_role require_auth=!scram-sha-256",
 	"SCRAM authentication forbidden, fails with SCRAM auth",
-	expected_stderr => qr/server requested SASL authentication/);
+	expected_stderr => qr/server requested SCRAM-SHA-256 authentication/);
 $node->connect_fails(
 	"user=scram_role require_auth=!password,!md5,!scram-sha-256",
 	"multiple authentication types forbidden, fails with SCRAM auth",
-	expected_stderr => qr/server requested SASL authentication/);
+	expected_stderr => qr/server requested SCRAM-SHA-256 authentication/);
 
 # Test that bad passwords are rejected.
 $ENV{"PGPASSWORD"} = 'badpass';
@@ -465,13 +465,13 @@ $node->connect_fails(
 	"user=scram_role require_auth=!scram-sha-256",
 	"password authentication forbidden, fails with SCRAM auth",
 	expected_stderr =>
-	  qr/authentication method requirement "!scram-sha-256" failed: server requested SASL authentication/
+	  qr/authentication method requirement "!scram-sha-256" failed: server requested SCRAM-SHA-256 authentication/
 );
 $node->connect_fails(
 	"user=scram_role require_auth=!password,!md5,!scram-sha-256",
 	"multiple authentication types forbidden, fails with SCRAM auth",
 	expected_stderr =>
-	  qr/authentication method requirement "!password,!md5,!scram-sha-256" failed: server requested SASL authentication/
+	  qr/authentication method requirement "!password,!md5,!scram-sha-256" failed: server requested SCRAM-SHA-256 authentication/
 );
 
 # Test SYSTEM_USER <> NULL with parallel workers.
diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile
index 89e78b7d114..4e4be3fa511 100644
--- a/src/test/modules/Makefile
+++ b/src/test/modules/Makefile
@@ -11,6 +11,7 @@ SUBDIRS = \
 		  dummy_index_am \
 		  dummy_seclabel \
 		  libpq_pipeline \
+		  oauth_validator \
 		  plsample \
 		  spgist_name_ops \
 		  test_bloomfilter \
diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build
index a57077b682e..2b057451473 100644
--- a/src/test/modules/meson.build
+++ b/src/test/modules/meson.build
@@ -9,6 +9,7 @@ subdir('gin')
 subdir('injection_points')
 subdir('ldap_password_func')
 subdir('libpq_pipeline')
+subdir('oauth_validator')
 subdir('plsample')
 subdir('spgist_name_ops')
 subdir('ssl_passphrase_callback')
diff --git a/src/test/modules/oauth_validator/.gitignore b/src/test/modules/oauth_validator/.gitignore
new file mode 100644
index 00000000000..5dcb3ff9723
--- /dev/null
+++ b/src/test/modules/oauth_validator/.gitignore
@@ -0,0 +1,4 @@
+# Generated subdirectories
+/log/
+/results/
+/tmp_check/
diff --git a/src/test/modules/oauth_validator/Makefile b/src/test/modules/oauth_validator/Makefile
new file mode 100644
index 00000000000..05b9f06ed73
--- /dev/null
+++ b/src/test/modules/oauth_validator/Makefile
@@ -0,0 +1,40 @@
+#-------------------------------------------------------------------------
+#
+# Makefile for src/test/modules/oauth_validator
+#
+# Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/test/modules/oauth_validator/Makefile
+#
+#-------------------------------------------------------------------------
+
+MODULES = validator fail_validator magic_validator
+PGFILEDESC = "validator - test OAuth validator module"
+
+PROGRAM = oauth_hook_client
+PGAPPICON = win32
+OBJS = $(WIN32RES) oauth_hook_client.o
+
+PG_CPPFLAGS = -I$(libpq_srcdir)
+PG_LIBS_INTERNAL += $(libpq_pgport)
+
+NO_INSTALLCHECK = 1
+
+TAP_TESTS = 1
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = src/test/modules/oauth_validator
+top_builddir = ../../../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+
+export PYTHON
+export with_libcurl
+export with_python
+
+endif
diff --git a/src/test/modules/oauth_validator/README b/src/test/modules/oauth_validator/README
new file mode 100644
index 00000000000..54eac5b117e
--- /dev/null
+++ b/src/test/modules/oauth_validator/README
@@ -0,0 +1,13 @@
+Test programs and libraries for OAuth
+-------------------------------------
+
+This folder contains tests for the client- and server-side OAuth
+implementations. Most tests are run end-to-end to test both simultaneously. The
+tests in t/001_server use a mock OAuth authorization server, implemented jointly
+by t/OAuth/Server.pm and t/oauth_server.py, to run the libpq Device
+Authorization flow. The tests in t/002_client exercise custom OAuth flows and
+don't need an authorization server.
+
+Tests in this folder require 'oauth' to be present in PG_TEST_EXTRA, since
+HTTPS servers listening on localhost with TCP/IP sockets will be started. A
+Python installation is required to run the mock authorization server.
diff --git a/src/test/modules/oauth_validator/fail_validator.c b/src/test/modules/oauth_validator/fail_validator.c
new file mode 100644
index 00000000000..a4c7a4451d3
--- /dev/null
+++ b/src/test/modules/oauth_validator/fail_validator.c
@@ -0,0 +1,47 @@
+/*-------------------------------------------------------------------------
+ *
+ * fail_validator.c
+ *	  Test module for serverside OAuth token validation callbacks, which is
+ *	  guaranteed to always fail in the validation callback
+ *
+ * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/test/modules/oauth_validator/fail_validator.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "fmgr.h"
+#include "libpq/oauth.h"
+
+PG_MODULE_MAGIC;
+
+static bool fail_token(const ValidatorModuleState *state,
+					   const char *token,
+					   const char *role,
+					   ValidatorModuleResult *result);
+
+/* Callback implementations (we only need the main one) */
+static const OAuthValidatorCallbacks validator_callbacks = {
+	PG_OAUTH_VALIDATOR_MAGIC,
+
+	.validate_cb = fail_token,
+};
+
+const OAuthValidatorCallbacks *
+_PG_oauth_validator_module_init(void)
+{
+	return &validator_callbacks;
+}
+
+static bool
+fail_token(const ValidatorModuleState *state,
+		   const char *token, const char *role,
+		   ValidatorModuleResult *res)
+{
+	elog(FATAL, "fail_validator: sentinel error");
+	pg_unreachable();
+}
diff --git a/src/test/modules/oauth_validator/magic_validator.c b/src/test/modules/oauth_validator/magic_validator.c
new file mode 100644
index 00000000000..9dc55b602e3
--- /dev/null
+++ b/src/test/modules/oauth_validator/magic_validator.c
@@ -0,0 +1,48 @@
+/*-------------------------------------------------------------------------
+ *
+ * magic_validator.c
+ *	  Test module for serverside OAuth token validation callbacks, which
+ *	  should fail due to using the wrong PG_OAUTH_VALIDATOR_MAGIC marker
+ *	  and thus the wrong ABI version
+ *
+ * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/test/modules/oauth_validator/magic_validator.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "fmgr.h"
+#include "libpq/oauth.h"
+
+PG_MODULE_MAGIC;
+
+static bool validate_token(const ValidatorModuleState *state,
+						   const char *token,
+						   const char *role,
+						   ValidatorModuleResult *result);
+
+/* Callback implementations (we only need the main one) */
+static const OAuthValidatorCallbacks validator_callbacks = {
+	0xdeadbeef,
+
+	.validate_cb = validate_token,
+};
+
+const OAuthValidatorCallbacks *
+_PG_oauth_validator_module_init(void)
+{
+	return &validator_callbacks;
+}
+
+static bool
+validate_token(const ValidatorModuleState *state,
+			   const char *token, const char *role,
+			   ValidatorModuleResult *res)
+{
+	elog(FATAL, "magic_validator: this should be unreachable");
+	pg_unreachable();
+}
diff --git a/src/test/modules/oauth_validator/meson.build b/src/test/modules/oauth_validator/meson.build
new file mode 100644
index 00000000000..36d1b26369f
--- /dev/null
+++ b/src/test/modules/oauth_validator/meson.build
@@ -0,0 +1,85 @@
+# Copyright (c) 2025, PostgreSQL Global Development Group
+
+validator_sources = files(
+  'validator.c',
+)
+
+if host_system == 'windows'
+  validator_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'validator',
+    '--FILEDESC', 'validator - test OAuth validator module',])
+endif
+
+validator = shared_module('validator',
+  validator_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += validator
+
+fail_validator_sources = files(
+  'fail_validator.c',
+)
+
+if host_system == 'windows'
+  fail_validator_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'fail_validator',
+    '--FILEDESC', 'fail_validator - failing OAuth validator module',])
+endif
+
+fail_validator = shared_module('fail_validator',
+  fail_validator_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += fail_validator
+
+magic_validator_sources = files(
+  'magic_validator.c',
+)
+
+if host_system == 'windows'
+  magic_validator_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'magic_validator',
+    '--FILEDESC', 'magic_validator - ABI incompatible OAuth validator module',])
+endif
+
+magic_validator = shared_module('magic_validator',
+  magic_validator_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += magic_validator
+
+oauth_hook_client_sources = files(
+  'oauth_hook_client.c',
+)
+
+if host_system == 'windows'
+  oauth_hook_client_sources += rc_bin_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'oauth_hook_client',
+    '--FILEDESC', 'oauth_hook_client - test program for libpq OAuth hooks',])
+endif
+
+oauth_hook_client = executable('oauth_hook_client',
+  oauth_hook_client_sources,
+  dependencies: [frontend_code, libpq],
+  kwargs: default_bin_args + {
+    'install': false,
+  },
+)
+testprep_targets += oauth_hook_client
+
+tests += {
+  'name': 'oauth_validator',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'tap': {
+    'tests': [
+      't/001_server.pl',
+      't/002_client.pl',
+    ],
+    'env': {
+      'PYTHON': python.path(),
+      'with_libcurl': libcurl.found() ? 'yes' : 'no',
+      'with_python': 'yes',
+    },
+  },
+}
diff --git a/src/test/modules/oauth_validator/oauth_hook_client.c b/src/test/modules/oauth_validator/oauth_hook_client.c
new file mode 100644
index 00000000000..9f553792c05
--- /dev/null
+++ b/src/test/modules/oauth_validator/oauth_hook_client.c
@@ -0,0 +1,293 @@
+/*-------------------------------------------------------------------------
+ *
+ * oauth_hook_client.c
+ *		Test driver for t/002_client.pl, which verifies OAuth hook
+ *		functionality in libpq.
+ *
+ * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ *
+ * IDENTIFICATION
+ *		src/test/modules/oauth_validator/oauth_hook_client.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres_fe.h"
+
+#include <sys/socket.h>
+
+#include "getopt_long.h"
+#include "libpq-fe.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(" --expected-scope SCOPE	fail if received scopes do not match SCOPE\n");
+	printf(" --expected-uri URI		fail if received configuration link does not match URI\n");
+	printf(" --misbehave=MODE		have the hook fail required postconditions\n"
+		   "						(MODEs: no-hook, fail-async, no-token, no-socket)\n");
+	printf(" --no-hook				don't install OAuth hooks\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(" --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_scope = NULL;
+static const char *misbehave_mode = NULL;
+static char *token = NULL;
+
+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},
+		{0}
+	};
+
+	const char *conninfo;
+	PGconn	   *conn;
+	int			c;
+
+	while ((c = getopt_long(argc, argv, "h", long_options, NULL)) != -1)
+	{
+		switch (c)
+		{
+			case 'h':
+				usage(argv);
+				return 0;
+
+			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;
+
+			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);
+
+	/* Connect. (All the actual work is in the hook.) */
+	if (stress_async)
+	{
+		/*
+		 * Perform an asynchronous connection, busy-looping on PQconnectPoll()
+		 * without actually waiting on socket events. This stresses code paths
+		 * that rely on asynchronous work to be done before continuing with
+		 * the next step in the flow.
+		 */
+		PostgresPollingStatusType res;
+
+		conn = PQconnectStart(conninfo);
+
+		do
+		{
+			res = PQconnectPoll(conn);
+		} while (res != PGRES_POLLING_FAILED && res != PGRES_POLLING_OK);
+	}
+	else
+	{
+		/* Perform a standard synchronous connection. */
+		conn = PQconnectdb(conninfo);
+	}
+
+	if (PQstatus(conn) != CONNECTION_OK)
+	{
+		fprintf(stderr, "connection to database failed: %s\n",
+				PQerrorMessage(conn));
+		PQfinish(conn);
+		return 1;
+	}
+
+	printf("connection succeeded\n");
+	PQfinish(conn);
+	return 0;
+}
+
+/*
+ * PQauthDataHook implementation. Replaces the default client flow by handling
+ * PQAUTHDATA_OAUTH_BEARER_TOKEN.
+ */
+static int
+handle_auth_data(PGauthData type, PGconn *conn, void *data)
+{
+	PGoauthBearerRequest *req = data;
+
+	if (no_hook || (type != PQAUTHDATA_OAUTH_BEARER_TOKEN))
+		return 0;
+
+	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;
+		}
+	}
+
+	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". */
+		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/001_server.pl b/src/test/modules/oauth_validator/t/001_server.pl
new file mode 100644
index 00000000000..6fa59fbeb25
--- /dev/null
+++ b/src/test/modules/oauth_validator/t/001_server.pl
@@ -0,0 +1,594 @@
+
+#
+# Tests the libpq builtin OAuth flow, as well as server-side HBA and validator
+# setup.
+#
+# Copyright (c) 2021-2025, PostgreSQL Global Development Group
+#
+
+use strict;
+use warnings FATAL => 'all';
+
+use JSON::PP     qw(encode_json);
+use MIME::Base64 qw(encode_base64);
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+use FindBin;
+use lib $FindBin::RealBin;
+
+use OAuth::Server;
+
+if (!$ENV{PG_TEST_EXTRA} || $ENV{PG_TEST_EXTRA} !~ /\boauth\b/)
+{
+	plan skip_all =>
+	  'Potentially unsafe test oauth not enabled in PG_TEST_EXTRA';
+}
+
+if ($windows_os)
+{
+	plan skip_all => 'OAuth server-side tests are not supported on Windows';
+}
+
+if ($ENV{with_libcurl} ne 'yes')
+{
+	plan skip_all => 'client-side OAuth not supported by this build';
+}
+
+if ($ENV{with_python} ne 'yes')
+{
+	plan skip_all => 'OAuth tests require --with-python to run';
+}
+
+my $node = PostgreSQL::Test::Cluster->new('primary');
+$node->init;
+$node->append_conf('postgresql.conf', "log_connections = on\n");
+$node->append_conf('postgresql.conf',
+	"oauth_validator_libraries = 'validator'\n");
+$node->start;
+
+$node->safe_psql('postgres', 'CREATE USER test;');
+$node->safe_psql('postgres', 'CREATE USER testalt;');
+$node->safe_psql('postgres', 'CREATE USER testparam;');
+
+# Save a background connection for later configuration changes.
+my $bgconn = $node->background_psql('postgres');
+
+my $webserver = OAuth::Server->new();
+$webserver->run();
+
+END
+{
+	my $exit_code = $?;
+
+	$webserver->stop() if defined $webserver;    # might have been SKIP'd
+
+	$? = $exit_code;
+}
+
+my $port = $webserver->port();
+my $issuer = "http://localhost:$port";
+
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test      oauth issuer="$issuer"       scope="openid postgres"
+local all testalt   oauth issuer="$issuer/.well-known/oauth-authorization-server/alternate" scope="openid postgres alt"
+local all testparam oauth issuer="$issuer/param" scope="openid postgres"
+});
+$node->reload;
+
+my $log_start = $node->wait_for_log(qr/reloading configuration files/);
+
+# Check pg_hba_file_rules() support.
+my $contents = $bgconn->query_safe(
+	qq(SELECT rule_number, auth_method, options
+		 FROM pg_hba_file_rules
+		 ORDER BY rule_number;));
+is( $contents,
+	qq{1|oauth|\{issuer=$issuer,"scope=openid postgres",validator=validator\}
+2|oauth|\{issuer=$issuer/.well-known/oauth-authorization-server/alternate,"scope=openid postgres alt",validator=validator\}
+3|oauth|\{issuer=$issuer/param,"scope=openid postgres",validator=validator\}},
+	"pg_hba_file_rules recreates OAuth HBA settings");
+
+# To test against HTTP rather than HTTPS, we need to enable PGOAUTHDEBUG. But
+# first, check to make sure the client refuses such connections by default.
+$node->connect_fails(
+	"user=test dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635",
+	"HTTPS is required without debug mode",
+	expected_stderr =>
+	  qr@OAuth discovery URI "\Q$issuer\E/.well-known/openid-configuration" must use HTTPS@
+);
+
+$ENV{PGOAUTHDEBUG} = "UNSAFE";
+
+my $user = "test";
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635",
+	"connect as test",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@,
+	log_like => [
+		qr/oauth_validator: token="9243959234", role="$user"/,
+		qr/oauth_validator: issuer="\Q$issuer\E", scope="openid postgres"/,
+		qr/connection authenticated: identity="test" method=oauth/,
+		qr/connection authorized/,
+	]);
+
+# The /alternate issuer uses slightly different parameters, along with an
+# OAuth-style discovery document.
+$user = "testalt";
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer/alternate oauth_client_id=f02c6361-0636",
+	"connect as testalt",
+	expected_stderr =>
+	  qr@Visit https://example\.org/ and enter the code: postgresuser@,
+	log_like => [
+		qr/oauth_validator: token="9243959234-alt", role="$user"/,
+		qr|oauth_validator: issuer="\Q$issuer/.well-known/oauth-authorization-server/alternate\E", scope="openid postgres alt"|,
+		qr/connection authenticated: identity="testalt" method=oauth/,
+		qr/connection authorized/,
+	]);
+
+# The issuer linked by the server must match the client's oauth_issuer setting.
+$node->connect_fails(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0636",
+	"oauth_issuer must match discovery",
+	expected_stderr =>
+	  qr@server's discovery document at \Q$issuer/.well-known/oauth-authorization-server/alternate\E \(issuer "\Q$issuer/alternate\E"\) is incompatible with oauth_issuer \(\Q$issuer\E\)@
+);
+
+# Test require_auth settings against OAUTHBEARER.
+my @cases = (
+	{ require_auth => "oauth" },
+	{ require_auth => "oauth,scram-sha-256" },
+	{ require_auth => "password,oauth" },
+	{ require_auth => "none,oauth" },
+	{ require_auth => "!scram-sha-256" },
+	{ require_auth => "!none" },
+
+	{
+		require_auth => "!oauth",
+		failure => qr/server requested OAUTHBEARER authentication/
+	},
+	{
+		require_auth => "scram-sha-256",
+		failure => qr/server requested OAUTHBEARER authentication/
+	},
+	{
+		require_auth => "!password,!oauth",
+		failure => qr/server requested OAUTHBEARER authentication/
+	},
+	{
+		require_auth => "none",
+		failure => qr/server requested SASL authentication/
+	},
+	{
+		require_auth => "!oauth,!scram-sha-256",
+		failure => qr/server requested SASL authentication/
+	});
+
+$user = "test";
+foreach my $c (@cases)
+{
+	my $connstr =
+	  "user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635 require_auth=$c->{'require_auth'}";
+
+	if (defined $c->{'failure'})
+	{
+		$node->connect_fails(
+			$connstr,
+			"require_auth=$c->{'require_auth'} fails",
+			expected_stderr => $c->{'failure'});
+	}
+	else
+	{
+		$node->connect_ok(
+			$connstr,
+			"require_auth=$c->{'require_auth'} succeeds",
+			expected_stderr =>
+			  qr@Visit https://example\.com/ and enter the code: postgresuser@
+		);
+	}
+}
+
+# Make sure the client_id and secret are correctly encoded. $vschars contains
+# every allowed character for a client_id/_secret (the "VSCHAR" class).
+# $vschars_esc is additionally backslash-escaped for inclusion in a
+# single-quoted connection string.
+my $vschars =
+  " !\"#\$%&'()*+,-./0123456789:;<=>?\@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~";
+my $vschars_esc =
+  " !\"#\$%&\\'()*+,-./0123456789:;<=>?\@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\\\]^_`abcdefghijklmnopqrstuvwxyz{|}~";
+
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id='$vschars_esc'",
+	"escapable characters: client_id",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id='$vschars_esc' oauth_client_secret='$vschars_esc'",
+	"escapable characters: client_id and secret",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+#
+# Further tests rely on support for specific behaviors in oauth_server.py. To
+# trigger these behaviors, we ask for the special issuer .../param (which is set
+# up in HBA for the testparam user) and encode magic instructions into the
+# oauth_client_id.
+#
+
+my $common_connstr =
+  "user=testparam dbname=postgres oauth_issuer=$issuer/param ";
+my $base_connstr = $common_connstr;
+
+sub connstr
+{
+	my (%params) = @_;
+
+	my $json = encode_json(\%params);
+	my $encoded = encode_base64($json, "");
+
+	return "$base_connstr oauth_client_id=$encoded";
+}
+
+# Make sure the param system works end-to-end first.
+$node->connect_ok(
+	connstr(),
+	"connect to /param",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$node->connect_ok(
+	connstr(stage => 'token', retries => 1),
+	"token retry",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(stage => 'token', retries => 2),
+	"token retry (twice)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(stage => 'all', retries => 1, interval => 2),
+	"token retry (two second interval)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(stage => 'all', retries => 1, interval => JSON::PP::null),
+	"token retry (default interval)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$node->connect_ok(
+	connstr(stage => 'all', content_type => 'application/json;charset=utf-8'),
+	"content type with charset",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(
+		stage => 'all',
+		content_type => "application/json \t;\t charset=utf-8"),
+	"content type with charset (whitespace)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(stage => 'device', uri_spelling => "verification_url"),
+	"alternative spelling of verification_uri",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$node->connect_fails(
+	connstr(stage => 'device', huge_response => JSON::PP::true),
+	"bad device authz response: overlarge JSON",
+	expected_stderr =>
+	  qr/failed to obtain device authorization: response is too large/);
+$node->connect_fails(
+	connstr(stage => 'token', huge_response => JSON::PP::true),
+	"bad token response: overlarge JSON",
+	expected_stderr =>
+	  qr/failed to obtain access token: response is too large/);
+
+$node->connect_fails(
+	connstr(stage => 'device', content_type => 'text/plain'),
+	"bad device authz response: wrong content type",
+	expected_stderr =>
+	  qr/failed to parse device authorization: unexpected content type/);
+$node->connect_fails(
+	connstr(stage => 'token', content_type => 'text/plain'),
+	"bad token response: wrong content type",
+	expected_stderr =>
+	  qr/failed to parse access token response: unexpected content type/);
+$node->connect_fails(
+	connstr(stage => 'token', content_type => 'application/jsonx'),
+	"bad token response: wrong content type (correct prefix)",
+	expected_stderr =>
+	  qr/failed to parse access token response: unexpected content type/);
+
+$node->connect_fails(
+	connstr(
+		stage => 'all',
+		interval => ~0,
+		retries => 1,
+		retry_code => "slow_down"),
+	"bad token response: server overflows the device authz interval",
+	expected_stderr =>
+	  qr/failed to obtain access token: slow_down interval overflow/);
+
+$node->connect_fails(
+	connstr(stage => 'token', error_code => "invalid_grant"),
+	"bad token response: invalid_grant, no description",
+	expected_stderr => qr/failed to obtain access token: \(invalid_grant\)/);
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_grant",
+		error_desc => "grant expired"),
+	"bad token response: expired grant",
+	expected_stderr =>
+	  qr/failed to obtain access token: grant expired \(invalid_grant\)/);
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_client",
+		error_status => 401),
+	"bad token response: client authentication failure, default description",
+	expected_stderr =>
+	  qr/failed to obtain access token: provider requires client authentication, and no oauth_client_secret is set \(invalid_client\)/
+);
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_client",
+		error_status => 401,
+		error_desc => "authn failure"),
+	"bad token response: client authentication failure, provided description",
+	expected_stderr =>
+	  qr/failed to obtain access token: authn failure \(invalid_client\)/);
+
+$node->connect_fails(
+	connstr(stage => 'token', token => ""),
+	"server rejects access: empty token",
+	expected_stderr => qr/bearer authentication failed/);
+$node->connect_fails(
+	connstr(stage => 'token', token => "****"),
+	"server rejects access: invalid token contents",
+	expected_stderr => qr/bearer authentication failed/);
+
+# Test behavior of the oauth_client_secret.
+$base_connstr = "$common_connstr oauth_client_secret=''";
+
+$node->connect_ok(
+	connstr(stage => 'all', expected_secret => ''),
+	"empty oauth_client_secret",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$base_connstr = "$common_connstr oauth_client_secret='$vschars_esc'";
+
+$node->connect_ok(
+	connstr(stage => 'all', expected_secret => $vschars),
+	"nonempty oauth_client_secret",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_client",
+		error_status => 401),
+	"bad token response: client authentication failure, default description with oauth_client_secret",
+	expected_stderr =>
+	  qr/failed to obtain access token: provider rejected the oauth_client_secret \(invalid_client\)/
+);
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_client",
+		error_status => 401,
+		error_desc => "mutual TLS required for client"),
+	"bad token response: client authentication failure, provided description with oauth_client_secret",
+	expected_stderr =>
+	  qr/failed to obtain access token: mutual TLS required for client \(invalid_client\)/
+);
+
+# Stress test: make sure our builtin flow operates correctly even if the client
+# application isn't respecting PGRES_POLLING_READING/WRITING signals returned
+# from PQconnectPoll().
+$base_connstr =
+  "$common_connstr port=" . $node->port . " host=" . $node->host;
+my @cmd = (
+	"oauth_hook_client", "--no-hook", "--stress-async",
+	connstr(stage => 'all', retries => 1, interval => 1));
+
+note "running '" . join("' '", @cmd) . "'";
+my ($stdout, $stderr) = run_command(\@cmd);
+
+like($stdout, qr/connection succeeded/, "stress-async: stdout matches");
+unlike(
+	$stderr,
+	qr/connection to database failed/,
+	"stress-async: stderr matches");
+
+#
+# This section of tests reconfigures the validator module itself, rather than
+# the OAuth server.
+#
+
+# Searching the logs is easier if OAuth parameter discovery isn't cluttering
+# things up; hardcode the discovery URI. (Scope is hardcoded to empty to cover
+# that case as well.)
+$common_connstr =
+  "dbname=postgres oauth_issuer=$issuer/.well-known/openid-configuration oauth_scope='' oauth_client_id=f02c6361-0635";
+
+# Misbehaving validators must fail shut.
+$bgconn->query_safe("ALTER SYSTEM SET oauth_validator.authn_id TO ''");
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+$node->connect_fails(
+	"$common_connstr user=test",
+	"validator must set authn_id",
+	expected_stderr => qr/OAuth bearer authentication failed/,
+	log_like => [
+		qr/connection authenticated: identity=""/,
+		qr/DETAIL:\s+Validator provided no identity/,
+		qr/FATAL:\s+OAuth bearer authentication failed/,
+	]);
+
+# Even if a validator authenticates the user, if the token isn't considered
+# valid, the connection fails.
+$bgconn->query_safe(
+	"ALTER SYSTEM SET oauth_validator.authn_id TO 'test\@example.org'");
+$bgconn->query_safe(
+	"ALTER SYSTEM SET oauth_validator.authorize_tokens TO false");
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+$node->connect_fails(
+	"$common_connstr user=test",
+	"validator must authorize token explicitly",
+	expected_stderr => qr/OAuth bearer authentication failed/,
+	log_like => [
+		qr/connection authenticated: identity="test\@example\.org"/,
+		qr/DETAIL:\s+Validator failed to authorize the provided token/,
+		qr/FATAL:\s+OAuth bearer authentication failed/,
+	]);
+
+#
+# Test user mapping.
+#
+
+# Allow "[email protected]" to log in under the test role.
+unlink($node->data_dir . '/pg_ident.conf');
+$node->append_conf(
+	'pg_ident.conf', qq{
+oauthmap	user\@example.com	test
+});
+
+# test and testalt use the map; testparam uses ident delegation.
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test      oauth issuer="$issuer" scope="" map=oauthmap
+local all testalt   oauth issuer="$issuer" scope="" map=oauthmap
+local all testparam oauth issuer="$issuer" scope="" delegate_ident_mapping=1
+});
+
+# To start, have the validator use the role names as authn IDs.
+$bgconn->query_safe("ALTER SYSTEM RESET oauth_validator.authn_id");
+$bgconn->query_safe("ALTER SYSTEM RESET oauth_validator.authorize_tokens");
+
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+# The test and testalt roles should no longer map correctly.
+$node->connect_fails(
+	"$common_connstr user=test",
+	"mismatched username map (test)",
+	expected_stderr => qr/OAuth bearer authentication failed/);
+$node->connect_fails(
+	"$common_connstr user=testalt",
+	"mismatched username map (testalt)",
+	expected_stderr => qr/OAuth bearer authentication failed/);
+
+# Have the validator identify the end user as [email protected].
+$bgconn->query_safe(
+	"ALTER SYSTEM SET oauth_validator.authn_id TO 'user\@example.com'");
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+# Now the test role can be logged into. (testalt still can't be mapped.)
+$node->connect_ok(
+	"$common_connstr user=test",
+	"matched username map (test)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_fails(
+	"$common_connstr user=testalt",
+	"mismatched username map (testalt)",
+	expected_stderr => qr/OAuth bearer authentication failed/);
+
+# testparam ignores the map entirely.
+$node->connect_ok(
+	"$common_connstr user=testparam",
+	"delegated ident (testparam)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$bgconn->query_safe("ALTER SYSTEM RESET oauth_validator.authn_id");
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+#
+# Test multiple validators.
+#
+
+$node->append_conf('postgresql.conf',
+	"oauth_validator_libraries = 'validator, fail_validator'\n");
+
+# With multiple validators, every HBA line must explicitly declare one.
+my $result = $node->restart(fail_ok => 1);
+is($result, 0,
+	'restart fails without explicit validators in oauth HBA entries');
+
+$log_start = $node->wait_for_log(
+	qr/authentication method "oauth" requires argument "validator" to be set/,
+	$log_start);
+
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test    oauth validator=validator      issuer="$issuer"           scope="openid postgres"
+local all testalt oauth validator=fail_validator issuer="$issuer/.well-known/oauth-authorization-server/alternate" scope="openid postgres alt"
+});
+$node->restart;
+
+$log_start = $node->wait_for_log(qr/ready to accept connections/, $log_start);
+
+# The test user should work as before.
+$user = "test";
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635",
+	"validator is used for $user",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@,
+	log_like => [qr/connection authorized/]);
+
+# testalt should be routed through the fail_validator.
+$user = "testalt";
+$node->connect_fails(
+	"user=$user dbname=postgres oauth_issuer=$issuer/.well-known/oauth-authorization-server/alternate oauth_client_id=f02c6361-0636",
+	"fail_validator is used for $user",
+	expected_stderr => qr/FATAL:\s+fail_validator: sentinel error/);
+
+#
+# Test ABI compatibility magic marker
+#
+$node->append_conf('postgresql.conf',
+	"oauth_validator_libraries = 'magic_validator'\n");
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test    oauth validator=magic_validator      issuer="$issuer"           scope="openid postgres"
+});
+$node->restart;
+
+$log_start = $node->wait_for_log(qr/ready to accept connections/, $log_start);
+
+$node->connect_fails(
+	"user=test dbname=postgres oauth_issuer=$issuer/.well-known/oauth-authorization-server/alternate oauth_client_id=f02c6361-0636",
+	"magic_validator is used for $user",
+	expected_stderr =>
+	  qr/FATAL:\s+OAuth validator module "magic_validator": magic number mismatch/
+);
+$node->stop;
+
+done_testing();
diff --git a/src/test/modules/oauth_validator/t/002_client.pl b/src/test/modules/oauth_validator/t/002_client.pl
new file mode 100644
index 00000000000..ab83258d736
--- /dev/null
+++ b/src/test/modules/oauth_validator/t/002_client.pl
@@ -0,0 +1,154 @@
+#
+# Exercises the API for custom OAuth client flows, using the oauth_hook_client
+# test driver.
+#
+# Copyright (c) 2021-2025, PostgreSQL Global Development Group
+#
+
+use strict;
+use warnings FATAL => 'all';
+
+use JSON::PP     qw(encode_json);
+use MIME::Base64 qw(encode_base64);
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+if (!$ENV{PG_TEST_EXTRA} || $ENV{PG_TEST_EXTRA} !~ /\boauth\b/)
+{
+	plan skip_all =>
+	  'Potentially unsafe test oauth not enabled in PG_TEST_EXTRA';
+}
+
+#
+# Cluster Setup
+#
+
+my $node = PostgreSQL::Test::Cluster->new('primary');
+$node->init;
+$node->append_conf('postgresql.conf', "log_connections = on\n");
+$node->append_conf('postgresql.conf',
+	"oauth_validator_libraries = 'validator'\n");
+$node->start;
+
+$node->safe_psql('postgres', 'CREATE USER test;');
+
+# These tests don't use the builtin flow, and we don't have an authorization
+# server running, so the address used here shouldn't matter. Use an invalid IP
+# address, so if there's some cascade of errors that causes the client to
+# attempt a connection, we'll fail noisily.
+my $issuer = "https://256.256.256.256";
+my $scope = "openid postgres";
+
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test oauth issuer="$issuer" scope="$scope"
+});
+$node->reload;
+
+my ($log_start, $log_end);
+$log_start = $node->wait_for_log(qr/reloading configuration files/);
+
+$ENV{PGOAUTHDEBUG} = "UNSAFE";
+
+#
+# Tests
+#
+
+my $user = "test";
+my $base_connstr = $node->connstr() . " user=$user";
+my $common_connstr =
+  "$base_connstr oauth_issuer=$issuer oauth_client_id=myID";
+
+sub test
+{
+	my ($test_name, %params) = @_;
+
+	my $flags = [];
+	if (defined($params{flags}))
+	{
+		$flags = $params{flags};
+	}
+
+	my @cmd = ("oauth_hook_client", @{$flags}, $common_connstr);
+	note "running '" . join("' '", @cmd) . "'";
+
+	my ($stdout, $stderr) = run_command(\@cmd);
+
+	if (defined($params{expected_stdout}))
+	{
+		like($stdout, $params{expected_stdout}, "$test_name: stdout matches");
+	}
+
+	if (defined($params{expected_stderr}))
+	{
+		like($stderr, $params{expected_stderr}, "$test_name: stderr matches");
+	}
+	else
+	{
+		is($stderr, "", "$test_name: no stderr");
+	}
+}
+
+test(
+	"basic synchronous hook can provide a token",
+	flags => [
+		"--token", "my-token",
+		"--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"/, ]);
+
+if ($ENV{with_libcurl} ne 'yes')
+{
+	# libpq should help users out if no OAuth support is built in.
+	test(
+		"fails without custom hook installed",
+		flags => ["--no-hook"],
+		expected_stderr =>
+		  qr/no custom OAuth flows are available, and libpq was not built with libcurl support/
+	);
+}
+
+# connect_timeout should work if the flow doesn't respond.
+$common_connstr = "$common_connstr connect_timeout=1";
+test(
+	"connect_timeout interrupts hung client flow",
+	flags => ["--hang-forever"],
+	expected_stderr => qr/failed: timeout expired/);
+
+# Test various misbehaviors of the client hook.
+my @cases = (
+	{
+		flag => "--misbehave=no-hook",
+		expected_error =>
+		  qr/user-defined OAuth flow provided neither a token nor an async callback/,
+	},
+	{
+		flag => "--misbehave=fail-async",
+		expected_error => qr/user-defined OAuth flow failed/,
+	},
+	{
+		flag => "--misbehave=no-token",
+		expected_error => qr/user-defined OAuth flow did not provide a token/,
+	},
+	{
+		flag => "--misbehave=no-socket",
+		expected_error =>
+		  qr/user-defined OAuth flow did not provide a socket for polling/,
+	});
+
+foreach my $c (@cases)
+{
+	test(
+		"hook misbehavior: $c->{'flag'}",
+		flags => [ $c->{'flag'} ],
+		expected_stderr => $c->{'expected_error'});
+}
+
+done_testing();
diff --git a/src/test/modules/oauth_validator/t/OAuth/Server.pm b/src/test/modules/oauth_validator/t/OAuth/Server.pm
new file mode 100644
index 00000000000..655b2870b0b
--- /dev/null
+++ b/src/test/modules/oauth_validator/t/OAuth/Server.pm
@@ -0,0 +1,140 @@
+
+# Copyright (c) 2025, PostgreSQL Global Development Group
+
+=pod
+
+=head1 NAME
+
+OAuth::Server - runs a mock OAuth authorization server for testing
+
+=head1 SYNOPSIS
+
+  use OAuth::Server;
+
+  my $server = OAuth::Server->new();
+  $server->run;
+
+  my $port = $server->port;
+  my $issuer = "http://localhost:$port";
+
+  # test against $issuer...
+
+  $server->stop;
+
+=head1 DESCRIPTION
+
+This is glue API between the Perl tests and the Python authorization server
+daemon implemented in t/oauth_server.py. (Python has a fairly usable HTTP server
+in its standard library, so the implementation was ported from Perl.)
+
+This authorization server does not use TLS (it implements a nonstandard, unsafe
+issuer at "http://localhost:<port>"), so libpq in particular will need to set
+PGOAUTHDEBUG=UNSAFE to be able to talk to it.
+
+=cut
+
+package OAuth::Server;
+
+use warnings;
+use strict;
+use Scalar::Util;
+use Test::More;
+
+=pod
+
+=head1 METHODS
+
+=over
+
+=item OAuth::Server->new()
+
+Create a new OAuth Server object.
+
+=cut
+
+sub new
+{
+	my $class = shift;
+
+	my $self = {};
+	bless($self, $class);
+
+	return $self;
+}
+
+=pod
+
+=item $server->port()
+
+Returns the port in use by the server.
+
+=cut
+
+sub port
+{
+	my $self = shift;
+
+	return $self->{'port'};
+}
+
+=pod
+
+=item $server->run()
+
+Runs the authorization server daemon in t/oauth_server.py.
+
+=cut
+
+sub run
+{
+	my $self = shift;
+	my $port;
+
+	my $pid = open(my $read_fh, "-|", $ENV{PYTHON}, "t/oauth_server.py")
+	  or die "failed to start OAuth server: $!";
+
+	# Get the port number from the daemon. It closes stdout afterwards; that way
+	# we can slurp in the entire contents here rather than worrying about the
+	# number of bytes to read.
+	$port = do { local $/ = undef; <$read_fh> }
+	  // die "failed to read port number: $!";
+	chomp $port;
+	die "server did not advertise a valid port"
+	  unless Scalar::Util::looks_like_number($port);
+
+	$self->{'pid'} = $pid;
+	$self->{'port'} = $port;
+	$self->{'child'} = $read_fh;
+
+	note("OAuth provider (PID $pid) is listening on port $port\n");
+}
+
+=pod
+
+=item $server->stop()
+
+Sends SIGTERM to the authorization server and waits for it to exit.
+
+=cut
+
+sub stop
+{
+	my $self = shift;
+
+	note("Sending SIGTERM to OAuth provider PID: $self->{'pid'}\n");
+
+	kill(15, $self->{'pid'});
+	$self->{'pid'} = undef;
+
+	# Closing the popen() handle waits for the process to exit.
+	close($self->{'child'});
+	$self->{'child'} = undef;
+}
+
+=pod
+
+=back
+
+=cut
+
+1;
diff --git a/src/test/modules/oauth_validator/t/oauth_server.py b/src/test/modules/oauth_validator/t/oauth_server.py
new file mode 100755
index 00000000000..4faf3323d38
--- /dev/null
+++ b/src/test/modules/oauth_validator/t/oauth_server.py
@@ -0,0 +1,391 @@
+#! /usr/bin/env python3
+#
+# A mock OAuth authorization server, designed to be invoked from
+# OAuth/Server.pm. This listens on an ephemeral port number (printed to stdout
+# so that the Perl tests can contact it) and runs as a daemon until it is
+# signaled.
+#
+
+import base64
+import http.server
+import json
+import os
+import sys
+import time
+import urllib.parse
+from collections import defaultdict
+
+
+class OAuthHandler(http.server.BaseHTTPRequestHandler):
+    """
+    Core implementation of the authorization server. The API is
+    inheritance-based, with entry points at do_GET() and do_POST(). See the
+    documentation for BaseHTTPRequestHandler.
+    """
+
+    JsonObject = dict[str, object]  # TypeAlias is not available until 3.10
+
+    def _check_issuer(self):
+        """
+        Switches the behavior of the provider depending on the issuer URI.
+        """
+        self._alt_issuer = (
+            self.path.startswith("/alternate/")
+            or self.path == "/.well-known/oauth-authorization-server/alternate"
+        )
+        self._parameterized = self.path.startswith("/param/")
+
+        if self._alt_issuer:
+            # The /alternate issuer uses IETF-style .well-known URIs.
+            if self.path.startswith("/.well-known/"):
+                self.path = self.path.removesuffix("/alternate")
+            else:
+                self.path = self.path.removeprefix("/alternate")
+        elif self._parameterized:
+            self.path = self.path.removeprefix("/param")
+
+    def _check_authn(self):
+        """
+        Checks the expected value of the Authorization header, if any.
+        """
+        secret = self._get_param("expected_secret", None)
+        if secret is None:
+            return
+
+        assert "Authorization" in self.headers
+        method, creds = self.headers["Authorization"].split()
+
+        if method != "Basic":
+            raise RuntimeError(f"client used {method} auth; expected Basic")
+
+        username = urllib.parse.quote_plus(self.client_id)
+        password = urllib.parse.quote_plus(secret)
+        expected_creds = f"{username}:{password}"
+
+        if creds.encode() != base64.b64encode(expected_creds.encode()):
+            raise RuntimeError(
+                f"client sent '{creds}'; expected b64encode('{expected_creds}')"
+            )
+
+    def do_GET(self):
+        self._response_code = 200
+        self._check_issuer()
+
+        config_path = "/.well-known/openid-configuration"
+        if self._alt_issuer:
+            config_path = "/.well-known/oauth-authorization-server"
+
+        if self.path == config_path:
+            resp = self.config()
+        else:
+            self.send_error(404, "Not Found")
+            return
+
+        self._send_json(resp)
+
+    def _parse_params(self) -> dict[str, str]:
+        """
+        Parses apart the form-urlencoded request body and returns the resulting
+        dict. For use by do_POST().
+        """
+        size = int(self.headers["Content-Length"])
+        form = self.rfile.read(size)
+
+        assert self.headers["Content-Type"] == "application/x-www-form-urlencoded"
+        return urllib.parse.parse_qs(
+            form.decode("utf-8"),
+            strict_parsing=True,
+            keep_blank_values=True,
+            encoding="utf-8",
+            errors="strict",
+        )
+
+    @property
+    def client_id(self) -> str:
+        """
+        Returns the client_id sent in the POST body or the Authorization header.
+        self._parse_params() must have been called first.
+        """
+        if "client_id" in self._params:
+            return self._params["client_id"][0]
+
+        if "Authorization" not in self.headers:
+            raise RuntimeError("client did not send any client_id")
+
+        _, creds = self.headers["Authorization"].split()
+
+        decoded = base64.b64decode(creds).decode("utf-8")
+        username, _ = decoded.split(":", 1)
+
+        return urllib.parse.unquote_plus(username)
+
+    def do_POST(self):
+        self._response_code = 200
+        self._check_issuer()
+
+        self._params = self._parse_params()
+        if self._parameterized:
+            # Pull encoded test parameters out of the peer's client_id field.
+            # This is expected to be Base64-encoded JSON.
+            js = base64.b64decode(self.client_id)
+            self._test_params = json.loads(js)
+
+        self._check_authn()
+
+        if self.path == "/authorize":
+            resp = self.authorization()
+        elif self.path == "/token":
+            resp = self.token()
+        else:
+            self.send_error(404)
+            return
+
+        self._send_json(resp)
+
+    def _should_modify(self) -> bool:
+        """
+        Returns True if the client has requested a modification to this stage of
+        the exchange.
+        """
+        if not hasattr(self, "_test_params"):
+            return False
+
+        stage = self._test_params.get("stage")
+
+        return (
+            stage == "all"
+            or (
+                stage == "discovery"
+                and self.path == "/.well-known/openid-configuration"
+            )
+            or (stage == "device" and self.path == "/authorize")
+            or (stage == "token" and self.path == "/token")
+        )
+
+    def _get_param(self, name, default):
+        """
+        If the client has requested a modification to this stage (see
+        _should_modify()), this method searches the provided test parameters for
+        a key of the given name, and returns it if found. Otherwise the provided
+        default is returned.
+        """
+        if self._should_modify() and name in self._test_params:
+            return self._test_params[name]
+
+        return default
+
+    @property
+    def _content_type(self) -> str:
+        """
+        Returns "application/json" unless the test has requested something
+        different.
+        """
+        return self._get_param("content_type", "application/json")
+
+    @property
+    def _interval(self) -> int:
+        """
+        Returns 0 unless the test has requested something different.
+        """
+        return self._get_param("interval", 0)
+
+    @property
+    def _retry_code(self) -> str:
+        """
+        Returns "authorization_pending" unless the test has requested something
+        different.
+        """
+        return self._get_param("retry_code", "authorization_pending")
+
+    @property
+    def _uri_spelling(self) -> str:
+        """
+        Returns "verification_uri" unless the test has requested something
+        different.
+        """
+        return self._get_param("uri_spelling", "verification_uri")
+
+    @property
+    def _response_padding(self):
+        """
+        If the huge_response test parameter is set to True, returns a dict
+        containing a gigantic string value, which can then be folded into a JSON
+        response.
+        """
+        if not self._get_param("huge_response", False):
+            return dict()
+
+        return {"_pad_": "x" * 1024 * 1024}
+
+    @property
+    def _access_token(self):
+        """
+        The actual Bearer token sent back to the client on success. Tests may
+        override this with the "token" test parameter.
+        """
+        token = self._get_param("token", None)
+        if token is not None:
+            return token
+
+        token = "9243959234"
+        if self._alt_issuer:
+            token += "-alt"
+
+        return token
+
+    def _send_json(self, js: JsonObject) -> None:
+        """
+        Sends the provided JSON dict as an application/json response.
+        self._response_code can be modified to send JSON error responses.
+        """
+        resp = json.dumps(js).encode("ascii")
+        self.log_message("sending JSON response: %s", resp)
+
+        self.send_response(self._response_code)
+        self.send_header("Content-Type", self._content_type)
+        self.send_header("Content-Length", str(len(resp)))
+        self.end_headers()
+
+        self.wfile.write(resp)
+
+    def config(self) -> JsonObject:
+        port = self.server.socket.getsockname()[1]
+
+        issuer = f"http://localhost:{port}"
+        if self._alt_issuer:
+            issuer += "/alternate"
+        elif self._parameterized:
+            issuer += "/param"
+
+        return {
+            "issuer": issuer,
+            "token_endpoint": issuer + "/token",
+            "device_authorization_endpoint": issuer + "/authorize",
+            "response_types_supported": ["token"],
+            "subject_types_supported": ["public"],
+            "id_token_signing_alg_values_supported": ["RS256"],
+            "grant_types_supported": [
+                "authorization_code",
+                "urn:ietf:params:oauth:grant-type:device_code",
+            ],
+        }
+
+    @property
+    def _token_state(self):
+        """
+        A cached _TokenState object for the connected client (as determined by
+        the request's client_id), or a new one if it doesn't already exist.
+
+        This relies on the existence of a defaultdict attached to the server;
+        see main() below.
+        """
+        return self.server.token_state[self.client_id]
+
+    def _remove_token_state(self):
+        """
+        Removes any cached _TokenState for the current client_id. Call this
+        after the token exchange ends to get rid of unnecessary state.
+        """
+        if self.client_id in self.server.token_state:
+            del self.server.token_state[self.client_id]
+
+    def authorization(self) -> JsonObject:
+        uri = "https://example.com/"
+        if self._alt_issuer:
+            uri = "https://example.org/"
+
+        resp = {
+            "device_code": "postgres",
+            "user_code": "postgresuser",
+            self._uri_spelling: uri,
+            "expires_in": 5,
+            **self._response_padding,
+        }
+
+        interval = self._interval
+        if interval is not None:
+            resp["interval"] = interval
+            self._token_state.min_delay = interval
+        else:
+            self._token_state.min_delay = 5  # default
+
+        # Check the scope.
+        if "scope" in self._params:
+            assert self._params["scope"][0], "empty scopes should be omitted"
+
+        return resp
+
+    def token(self) -> JsonObject:
+        if err := self._get_param("error_code", None):
+            self._response_code = self._get_param("error_status", 400)
+
+            resp = {"error": err}
+            if desc := self._get_param("error_desc", ""):
+                resp["error_description"] = desc
+
+            return resp
+
+        if self._should_modify() and "retries" in self._test_params:
+            retries = self._test_params["retries"]
+
+            # Check to make sure the token interval is being respected.
+            now = time.monotonic()
+            if self._token_state.last_try is not None:
+                delay = now - self._token_state.last_try
+                assert (
+                    delay > self._token_state.min_delay
+                ), f"client waited only {delay} seconds between token requests (expected {self._token_state.min_delay})"
+
+            self._token_state.last_try = now
+
+            # If we haven't reached the required number of retries yet, return a
+            # "pending" response.
+            if self._token_state.retries < retries:
+                self._token_state.retries += 1
+
+                self._response_code = 400
+                return {"error": self._retry_code}
+
+        # Clean up any retry tracking state now that the exchange is ending.
+        self._remove_token_state()
+
+        return {
+            "access_token": self._access_token,
+            "token_type": "bearer",
+            **self._response_padding,
+        }
+
+
+def main():
+    """
+    Starts the authorization server on localhost. The ephemeral port in use will
+    be printed to stdout.
+    """
+
+    s = http.server.HTTPServer(("127.0.0.1", 0), OAuthHandler)
+
+    # Attach a "cache" dictionary to the server to allow the OAuthHandlers to
+    # track state across token requests. The use of defaultdict ensures that new
+    # entries will be created automatically.
+    class _TokenState:
+        retries = 0
+        min_delay = None
+        last_try = None
+
+    s.token_state = defaultdict(_TokenState)
+
+    # Give the parent the port number to contact (this is also the signal that
+    # we're ready to receive requests).
+    port = s.socket.getsockname()[1]
+    print(port)
+
+    # stdout is closed to allow the parent to just "read to the end".
+    stdout = sys.stdout.fileno()
+    sys.stdout.close()
+    os.close(stdout)
+
+    s.serve_forever()  # we expect our parent to send a termination signal
+
+
+if __name__ == "__main__":
+    main()
diff --git a/src/test/modules/oauth_validator/validator.c b/src/test/modules/oauth_validator/validator.c
new file mode 100644
index 00000000000..b2e5d182e1b
--- /dev/null
+++ b/src/test/modules/oauth_validator/validator.c
@@ -0,0 +1,143 @@
+/*-------------------------------------------------------------------------
+ *
+ * validator.c
+ *	  Test module for serverside OAuth token validation callbacks
+ *
+ * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/test/modules/oauth_validator/validator.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "fmgr.h"
+#include "libpq/oauth.h"
+#include "miscadmin.h"
+#include "utils/guc.h"
+#include "utils/memutils.h"
+
+PG_MODULE_MAGIC;
+
+static void validator_startup(ValidatorModuleState *state);
+static void validator_shutdown(ValidatorModuleState *state);
+static bool validate_token(const ValidatorModuleState *state,
+						   const char *token,
+						   const char *role,
+						   ValidatorModuleResult *result);
+
+/* Callback implementations (exercise all three) */
+static const OAuthValidatorCallbacks validator_callbacks = {
+	PG_OAUTH_VALIDATOR_MAGIC,
+
+	.startup_cb = validator_startup,
+	.shutdown_cb = validator_shutdown,
+	.validate_cb = validate_token
+};
+
+/* GUCs */
+static char *authn_id = NULL;
+static bool authorize_tokens = true;
+
+/*---
+ * Extension entry point. Sets up GUCs for use by tests:
+ *
+ * - oauth_validator.authn_id	Sets the user identifier to return during token
+ *								validation. Defaults to the username in the
+ *								startup packet.
+ *
+ * - oauth_validator.authorize_tokens
+ *								Sets whether to successfully validate incoming
+ *								tokens. Defaults to true.
+ */
+void
+_PG_init(void)
+{
+	DefineCustomStringVariable("oauth_validator.authn_id",
+							   "Authenticated identity to use for future connections",
+							   NULL,
+							   &authn_id,
+							   NULL,
+							   PGC_SIGHUP,
+							   0,
+							   NULL, NULL, NULL);
+	DefineCustomBoolVariable("oauth_validator.authorize_tokens",
+							 "Should tokens be marked valid?",
+							 NULL,
+							 &authorize_tokens,
+							 true,
+							 PGC_SIGHUP,
+							 0,
+							 NULL, NULL, NULL);
+
+	MarkGUCPrefixReserved("oauth_validator");
+}
+
+/*
+ * Validator module entry point.
+ */
+const OAuthValidatorCallbacks *
+_PG_oauth_validator_module_init(void)
+{
+	return &validator_callbacks;
+}
+
+#define PRIVATE_COOKIE ((void *) 13579)
+
+/*
+ * Startup callback, to set up private data for the validator.
+ */
+static void
+validator_startup(ValidatorModuleState *state)
+{
+	/*
+	 * Make sure the server is correctly setting sversion. (Real modules
+	 * should not do this; it would defeat upgrade compatibility.)
+	 */
+	if (state->sversion != PG_VERSION_NUM)
+		elog(ERROR, "oauth_validator: sversion set to %d", state->sversion);
+
+	state->private_data = PRIVATE_COOKIE;
+}
+
+/*
+ * Shutdown callback, to tear down the validator.
+ */
+static void
+validator_shutdown(ValidatorModuleState *state)
+{
+	/* Check to make sure our private state still exists. */
+	if (state->private_data != PRIVATE_COOKIE)
+		elog(PANIC, "oauth_validator: private state cookie changed to %p in shutdown",
+			 state->private_data);
+}
+
+/*
+ * Validator implementation. Logs the incoming data and authorizes the token by
+ * default; the behavior can be modified via the module's GUC settings.
+ */
+static bool
+validate_token(const ValidatorModuleState *state,
+			   const char *token, const char *role,
+			   ValidatorModuleResult *res)
+{
+	/* Check to make sure our private state still exists. */
+	if (state->private_data != PRIVATE_COOKIE)
+		elog(ERROR, "oauth_validator: private state cookie changed to %p in validate",
+			 state->private_data);
+
+	elog(LOG, "oauth_validator: token=\"%s\", role=\"%s\"", token, role);
+	elog(LOG, "oauth_validator: issuer=\"%s\", scope=\"%s\"",
+		 MyProcPort->hba->oauth_issuer,
+		 MyProcPort->hba->oauth_scope);
+
+	res->authorized = authorize_tokens;
+	if (authn_id)
+		res->authn_id = pstrdup(authn_id);
+	else
+		res->authn_id = pstrdup(role);
+
+	return true;
+}
diff --git a/src/test/perl/PostgreSQL/Test/Cluster.pm b/src/test/perl/PostgreSQL/Test/Cluster.pm
index f521ad0b12f..ab7d7452ede 100644
--- a/src/test/perl/PostgreSQL/Test/Cluster.pm
+++ b/src/test/perl/PostgreSQL/Test/Cluster.pm
@@ -2515,6 +2515,11 @@ instead of the default.
 
 If this regular expression is set, matches it with the output generated.
 
+=item expected_stderr => B<value>
+
+If this regular expression is set, matches it against the standard error
+stream; otherwise the stderr must be empty.
+
 =item log_like => [ qr/required message/ ]
 
 =item log_unlike => [ qr/prohibited message/ ]
@@ -2558,7 +2563,20 @@ sub connect_ok
 		like($stdout, $params{expected_stdout}, "$test_name: stdout matches");
 	}
 
-	is($stderr, "", "$test_name: no stderr");
+	if (defined($params{expected_stderr}))
+	{
+		if (like($stderr, $params{expected_stderr}, "$test_name: stderr matches")
+			&& ($ret != 0))
+		{
+			# In this case (failing test but matching stderr) we'll have
+			# swallowed the output needed to debug. Put it back into the logs.
+			diag("$test_name: full stderr:\n" . $stderr);
+		}
+	}
+	else
+	{
+		is($stderr, "", "$test_name: no stderr");
+	}
 
 	$self->log_check($test_name, $log_location, %params);
 }
diff --git a/src/tools/pgindent/pgindent b/src/tools/pgindent/pgindent
index d8acce7e929..7dccf4614aa 100755
--- a/src/tools/pgindent/pgindent
+++ b/src/tools/pgindent/pgindent
@@ -242,6 +242,14 @@ sub pre_indent
 	# Protect wrapping in CATALOG()
 	$source =~ s!^(CATALOG\(.*)$!/*$1*/!gm;
 
+	# Treat a CURL_IGNORE_DEPRECATION() as braces for the purposes of
+	# indentation. (The recursive regex comes from the perlre documentation; it
+	# matches balanced parentheses as group $1 and the contents as group $2.)
+	my $curlopen = '{ /* CURL_IGNORE_DEPRECATION */';
+	my $curlclose = '} /* CURL_IGNORE_DEPRECATION */';
+	$source =~
+	  s!^[ \t]+CURL_IGNORE_DEPRECATION(\(((?:(?>[^()]+)|(?1))*)\))!$curlopen$2$curlclose!gms;
+
 	return $source;
 }
 
@@ -256,6 +264,12 @@ sub post_indent
 	$source =~ s!^/\* Open extern "C" \*/$!{!gm;
 	$source =~ s!^/\* Close extern "C" \*/$!}!gm;
 
+	# Restore the CURL_IGNORE_DEPRECATION() macro, keeping in mind that our
+	# markers may have been re-indented.
+	$source =~
+	  s!{[ \t]+/\* CURL_IGNORE_DEPRECATION \*/!CURL_IGNORE_DEPRECATION(!gm;
+	$source =~ s!}[ \t]+/\* CURL_IGNORE_DEPRECATION \*/!)!gm;
+
 	## Comments
 
 	# Undo change of dash-protected block comments
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index bce4214503d..48f8184b061 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -371,6 +371,9 @@ CState
 CTECycleClause
 CTEMaterialize
 CTESearchClause
+CURL
+CURLM
+CURLoption
 CV
 CachedExpression
 CachedPlan
@@ -1724,6 +1727,7 @@ NumericDigit
 NumericSortSupport
 NumericSumAccum
 NumericVar
+OAuthValidatorCallbacks
 OM_uint32
 OP
 OSAPerGroupState
@@ -1832,6 +1836,7 @@ PGVerbosity
 PG_Locale_Strategy
 PG_Lock_Status
 PG_init_t
+PGauthData
 PGcancel
 PGcancelConn
 PGcmdQueueEntry
@@ -1839,7 +1844,9 @@ PGconn
 PGdataValue
 PGlobjfuncs
 PGnotify
+PGoauthBearerRequest
 PGpipelineStatus
+PGpromptOAuthDevice
 PGresAttDesc
 PGresAttValue
 PGresParamDesc
@@ -1952,6 +1959,7 @@ PQArgBlock
 PQEnvironmentOption
 PQExpBuffer
 PQExpBufferData
+PQauthDataHook_type
 PQcommMethods
 PQconninfoOption
 PQnoticeProcessor
@@ -3092,6 +3100,8 @@ VacuumRelation
 VacuumStmt
 ValidIOData
 ValidateIndexState
+ValidatorModuleState
+ValidatorModuleResult
 ValuesScan
 ValuesScanState
 Var
@@ -3489,6 +3499,7 @@ explain_get_index_name_hook_type
 f_smgr
 fasthash_state
 fd_set
+fe_oauth_state
 fe_scram_state
 fe_scram_state_enum
 fetch_range_request
-- 
2.34.1



  [application/octet-stream] v52-0002-fixup-Add-support-for-OAUTHBEARER-SASL-mechanism.patch (1.4K, ../../CAOYmi+nP8AM9xm+xUW5kDz7XDF7MKBjuDnQ4LjMEm07tpwDgrg@mail.gmail.com/3-v52-0002-fixup-Add-support-for-OAUTHBEARER-SASL-mechanism.patch)
  download | inline diff:
From fd7224fa4617f548320c4f923ba2d6d6e81c9243 Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Mon, 17 Feb 2025 14:42:42 -0800
Subject: [PATCH v52 2/3] fixup! Add support for OAUTHBEARER SASL mechanism

---
 src/interfaces/libpq/fe-auth-oauth.c | 12 +++++++++++-
 1 file changed, 11 insertions(+), 1 deletion(-)

diff --git a/src/interfaces/libpq/fe-auth-oauth.c b/src/interfaces/libpq/fe-auth-oauth.c
index 24448c3e209..fb1e9a1a8aa 100644
--- a/src/interfaces/libpq/fe-auth-oauth.c
+++ b/src/interfaces/libpq/fe-auth-oauth.c
@@ -815,13 +815,23 @@ fail:
 }
 
 /*
- * Fill in our issuer identifier and discovery URI, if possible, using the
+ * Fill in our issuer identifier (and discovery URI, if possible) using the
  * connection parameters. If conn->oauth_discovery_uri can't be populated in
  * this function, it will be requested from the server.
  */
 static bool
 setup_oauth_parameters(PGconn *conn)
 {
+	/*
+	 * This is the only function that sets conn->oauth_issuer_id. If a
+	 * previous connection attempt has already computed it, don't overwrite it
+	 * or the discovery URI. (There's no reason for them to change once
+	 * they're set, and handle_oauth_sasl_error() will fail the connection if
+	 * the server attempts to switch them on us later.)
+	 */
+	if (conn->oauth_issuer_id)
+		return true;
+
 	/*---
 	 * To talk to a server, we require the user to provide issuer and client
 	 * identifiers.
-- 
2.34.1



  [application/octet-stream] v52-0003-cirrus-Temporarily-fix-libcurl-link-error.patch (1.3K, ../../CAOYmi+nP8AM9xm+xUW5kDz7XDF7MKBjuDnQ4LjMEm07tpwDgrg@mail.gmail.com/4-v52-0003-cirrus-Temporarily-fix-libcurl-link-error.patch)
  download | inline diff:
From 937f565848a955985382a3f2bb20641a135f5b79 Mon Sep 17 00:00:00 2001
From: Daniel Gustafsson <[email protected]>
Date: Mon, 17 Feb 2025 12:57:38 +0100
Subject: [PATCH v52 3/3] cirrus: Temporarily fix libcurl link error

On FreeBSD the ftp/curl port appears to be missing a minimum
version dependency on libssh2, so the following starts showing
up after upgrading to curl 8.11.1_1:

  libcurl.so.4: Undefined symbol "libssh2_session_callback_set2"

Awaiting an upgrade of the FreeBSD CI images to version 14, work
around the issue.

Author: Jacob Champion <[email protected]>
Reviewed-by: Daniel Gustafsson <[email protected]>
Discussion: https://postgr.es/m/CAOYmi+kZAka0sdxCOBxsQc2ozEZGZKHWU_9nrPXg3sG1NJ-zJw@mail.gmail.com
---
 .cirrus.tasks.yml | 1 +
 1 file changed, 1 insertion(+)

diff --git a/.cirrus.tasks.yml b/.cirrus.tasks.yml
index 2f5f5ef21a8..91b51142d2e 100644
--- a/.cirrus.tasks.yml
+++ b/.cirrus.tasks.yml
@@ -168,6 +168,7 @@ task:
     sysctl kern.corefile='/tmp/cores/%N.%P.core'
   setup_additional_packages_script: |
     pkg install -y curl
+    pkg upgrade -y libssh2 # XXX shouldn't be necessary. revisit w/ FreeBSD 14
 
   # NB: Intentionally build without -Dllvm. The freebsd image size is already
   # large enough to make VM startup slow, and even without llvm freebsd
-- 
2.34.1



^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-02-19 14:13  Daniel Gustafsson <[email protected]>
  parent: Jacob Champion <[email protected]>
  0 siblings, 2 replies; 101+ messages in thread

From: Daniel Gustafsson @ 2025-02-19 14:13 UTC (permalink / raw)
  To: Jacob Champion <[email protected]>; +Cc: Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

> On 18 Feb 2025, at 00:51, Jacob Champion <[email protected]> wrote:
> 
> On Mon, Feb 17, 2025 at 10:15 AM Jacob Champion
> <[email protected]> wrote:
>> It's been a little bit since I've re-run my
>> fuzzers, and a new Valgrind run would be a good idea, so I will just
>> keep throwing tests at it
> 
> Fuzzers are happy so far.
> 
> Valgrind did find something! A mistake I made during parameter
> discovery: setup_oauth_parameters() ensures that conn->oauth_issuer_id
> is always set using the "issuer" connection option, but during the
> second connection, I reassigned the pointer for it (and
> conn->oauth_discovery_uri) and leaked the previous allocations.

Nice.

> Reviews for the commit message:

All proposed changes applied.

The attached rebased has your 0002 fix as well as some minor tweaks like a few
small whitespace changes from a pgperltidy run and a copyright date fix which
still said 2024.

Unless something shows up I plan to commit this sometime tomorrow to allow it
ample time in the tree before the freeze.

--
Daniel Gustafsson



Attachments:

  [application/octet-stream] v53-0002-cirrus-Temporarily-fix-libcurl-link-error.patch (1.3K, ../../[email protected]/2-v53-0002-cirrus-Temporarily-fix-libcurl-link-error.patch)
  download | inline diff:
From f5575816eb2a62d2465615b4d58b8eea0d22c330 Mon Sep 17 00:00:00 2001
From: Daniel Gustafsson <[email protected]>
Date: Wed, 19 Feb 2025 15:09:02 +0100
Subject: [PATCH v53 2/2] cirrus: Temporarily fix libcurl link error

On FreeBSD the ftp/curl port appears to be missing a minimum
version dependency on libssh2, so the following starts showing
up after upgrading to curl 8.11.1_1:

  libcurl.so.4: Undefined symbol "libssh2_session_callback_set2"

Awaiting an upgrade of the FreeBSD CI images to version 14, work
around the issue.

Author: Jacob Champion <[email protected]>
Reviewed-by: Daniel Gustafsson <[email protected]>
Discussion: https://postgr.es/m/CAOYmi+kZAka0sdxCOBxsQc2ozEZGZKHWU_9nrPXg3sG1NJ-zJw@mail.gmail.com
---
 .cirrus.tasks.yml | 1 +
 1 file changed, 1 insertion(+)

diff --git a/.cirrus.tasks.yml b/.cirrus.tasks.yml
index 2f5f5ef21a8..91b51142d2e 100644
--- a/.cirrus.tasks.yml
+++ b/.cirrus.tasks.yml
@@ -168,6 +168,7 @@ task:
     sysctl kern.corefile='/tmp/cores/%N.%P.core'
   setup_additional_packages_script: |
     pkg install -y curl
+    pkg upgrade -y libssh2 # XXX shouldn't be necessary. revisit w/ FreeBSD 14
 
   # NB: Intentionally build without -Dllvm. The freebsd image size is already
   # large enough to make VM startup slow, and even without llvm freebsd
-- 
2.39.3 (Apple Git-146)



  [application/octet-stream] v53-0001-Add-support-for-OAUTHBEARER-SASL-mechanism.patch (324.6K, ../../[email protected]/3-v53-0001-Add-support-for-OAUTHBEARER-SASL-mechanism.patch)
  download | inline diff:
From 86f5b77604a991299a9ddd966fd714c86eaa2e6a Mon Sep 17 00:00:00 2001
From: Daniel Gustafsson <[email protected]>
Date: Wed, 19 Feb 2025 15:08:59 +0100
Subject: [PATCH v53 1/2] Add support for OAUTHBEARER SASL mechanism

This commit implements OAUTHBEARER, RFC 7628, and OAuth 2.0 Device
Authorization Grants, RFC 8628.  In order to use this there is a
new pg_hba auth method called oauth.  When speaking to a OAuth-
enabled server, it looks a bit like this:

  $ psql 'host=example.org oauth_issuer=... oauth_client_id=...'
  Visit https://oauth.example.org/login and enter the code: FPQ2-M4BG

Device authorization is currently the only supported flow so the
OAuth issuer must support that in order for users to authenticate.
Third-party clients may however extend this and provide their own
flows.  The built-in device authorization flow is currently not
supported on Windows.

In order for validation to happen server side a new framework for
plugging in OAuth validation modules is added.  As validation is
implementation specific, with no default specified in the standard,
PostgreSQL does not ship with one built-in.  Each pg_hba entry can
specify a specific validator or be left blank for the validator
installed as default.

This adds a requirement on libcurl for the client side support,
which is optional to build, but the server side has no additional
build requirements.  In order to run the tests, Python is required
as this adds a https server written in Python.  Tests are gated
behind PG_TEST_EXTRA as they open ports.

This patch has been a multi-year project with many contributors
involved with reviews and in-depth discussions:  Michael Paquier,
Heikki Linnakangas, Zhihong Yu, Mahendrakar Srinivasarao, Andrey
Chudnovsky and Stephen Frost to name a few.  While Jacob Champion
is the main author there have been some levels of hacking by others.
Daniel Gustafsson contributed the validation module and various bits
and pieces; Thomas Munro wrote the client side support for kqueue.

Author: Jacob Champion <[email protected]>
Co-authored-by: Daniel Gustafsson <[email protected]>
Co-authored-by: Thomas Munro <[email protected]>
Reviewed-by: Daniel Gustafsson <[email protected]>
Reviewed-by: Peter Eisentraut <[email protected]>
Reviewed-by: Antonin Houska <[email protected]>
Reviewed-by: Kashif Zeeshan <[email protected]>
Discussion: https://postgr.es/m/[email protected]
---
 .cirrus.tasks.yml                             |   15 +-
 config/programs.m4                            |   65 +
 configure                                     |  332 ++
 configure.ac                                  |   41 +
 doc/src/sgml/client-auth.sgml                 |  252 ++
 doc/src/sgml/config.sgml                      |   26 +
 doc/src/sgml/filelist.sgml                    |    1 +
 doc/src/sgml/installation.sgml                |   27 +
 doc/src/sgml/libpq.sgml                       |  445 +++
 doc/src/sgml/oauth-validators.sgml            |  414 +++
 doc/src/sgml/postgres.sgml                    |    1 +
 doc/src/sgml/protocol.sgml                    |  133 +-
 doc/src/sgml/regress.sgml                     |   10 +
 meson.build                                   |  100 +
 meson_options.txt                             |    3 +
 src/Makefile.global.in                        |    1 +
 src/backend/libpq/Makefile                    |    1 +
 src/backend/libpq/auth-oauth.c                |  894 +++++
 src/backend/libpq/auth.c                      |   10 +-
 src/backend/libpq/hba.c                       |   64 +-
 src/backend/libpq/meson.build                 |    1 +
 src/backend/libpq/pg_hba.conf.sample          |    4 +-
 src/backend/utils/adt/hbafuncs.c              |   19 +
 src/backend/utils/misc/guc_tables.c           |   12 +
 src/backend/utils/misc/postgresql.conf.sample |    3 +
 src/include/common/oauth-common.h             |   19 +
 src/include/libpq/auth.h                      |    1 +
 src/include/libpq/hba.h                       |    7 +-
 src/include/libpq/oauth.h                     |  101 +
 src/include/pg_config.h.in                    |    9 +
 src/interfaces/libpq/Makefile                 |   11 +-
 src/interfaces/libpq/exports.txt              |    3 +
 src/interfaces/libpq/fe-auth-oauth-curl.c     | 2883 +++++++++++++++++
 src/interfaces/libpq/fe-auth-oauth.c          | 1163 +++++++
 src/interfaces/libpq/fe-auth-oauth.h          |   46 +
 src/interfaces/libpq/fe-auth.c                |   36 +-
 src/interfaces/libpq/fe-auth.h                |    3 +
 src/interfaces/libpq/fe-connect.c             |   48 +-
 src/interfaces/libpq/libpq-fe.h               |   85 +
 src/interfaces/libpq/libpq-int.h              |   13 +-
 src/interfaces/libpq/meson.build              |    5 +
 src/makefiles/meson.build                     |    1 +
 src/test/authentication/t/001_password.pl     |    8 +-
 src/test/modules/Makefile                     |    1 +
 src/test/modules/meson.build                  |    1 +
 src/test/modules/oauth_validator/.gitignore   |    4 +
 src/test/modules/oauth_validator/Makefile     |   40 +
 src/test/modules/oauth_validator/README       |   13 +
 .../modules/oauth_validator/fail_validator.c  |   47 +
 .../modules/oauth_validator/magic_validator.c |   48 +
 src/test/modules/oauth_validator/meson.build  |   85 +
 .../oauth_validator/oauth_hook_client.c       |  293 ++
 .../modules/oauth_validator/t/001_server.pl   |  594 ++++
 .../modules/oauth_validator/t/002_client.pl   |  154 +
 .../modules/oauth_validator/t/OAuth/Server.pm |  140 +
 .../modules/oauth_validator/t/oauth_server.py |  391 +++
 src/test/modules/oauth_validator/validator.c  |  143 +
 src/test/perl/PostgreSQL/Test/Cluster.pm      |   22 +-
 src/tools/pgindent/pgindent                   |   14 +
 src/tools/pgindent/typedefs.list              |   11 +
 60 files changed, 9278 insertions(+), 39 deletions(-)
 create mode 100644 doc/src/sgml/oauth-validators.sgml
 create mode 100644 src/backend/libpq/auth-oauth.c
 create mode 100644 src/include/common/oauth-common.h
 create mode 100644 src/include/libpq/oauth.h
 create mode 100644 src/interfaces/libpq/fe-auth-oauth-curl.c
 create mode 100644 src/interfaces/libpq/fe-auth-oauth.c
 create mode 100644 src/interfaces/libpq/fe-auth-oauth.h
 create mode 100644 src/test/modules/oauth_validator/.gitignore
 create mode 100644 src/test/modules/oauth_validator/Makefile
 create mode 100644 src/test/modules/oauth_validator/README
 create mode 100644 src/test/modules/oauth_validator/fail_validator.c
 create mode 100644 src/test/modules/oauth_validator/magic_validator.c
 create mode 100644 src/test/modules/oauth_validator/meson.build
 create mode 100644 src/test/modules/oauth_validator/oauth_hook_client.c
 create mode 100644 src/test/modules/oauth_validator/t/001_server.pl
 create mode 100644 src/test/modules/oauth_validator/t/002_client.pl
 create mode 100644 src/test/modules/oauth_validator/t/OAuth/Server.pm
 create mode 100755 src/test/modules/oauth_validator/t/oauth_server.py
 create mode 100644 src/test/modules/oauth_validator/validator.c

diff --git a/.cirrus.tasks.yml b/.cirrus.tasks.yml
index fffa438cec1..2f5f5ef21a8 100644
--- a/.cirrus.tasks.yml
+++ b/.cirrus.tasks.yml
@@ -23,7 +23,7 @@ env:
   MTEST_ARGS: --print-errorlogs --no-rebuild -C build
   PGCTLTIMEOUT: 120 # avoids spurious failures during parallel tests
   TEMP_CONFIG: ${CIRRUS_WORKING_DIR}/src/tools/ci/pg_ci_base.conf
-  PG_TEST_EXTRA: kerberos ldap ssl libpq_encryption load_balance
+  PG_TEST_EXTRA: kerberos ldap ssl libpq_encryption load_balance oauth
 
 
 # What files to preserve in case tests fail
@@ -167,7 +167,7 @@ task:
     chown root:postgres /tmp/cores
     sysctl kern.corefile='/tmp/cores/%N.%P.core'
   setup_additional_packages_script: |
-    #pkg install -y ...
+    pkg install -y curl
 
   # NB: Intentionally build without -Dllvm. The freebsd image size is already
   # large enough to make VM startup slow, and even without llvm freebsd
@@ -329,6 +329,7 @@ LINUX_CONFIGURE_FEATURES: &LINUX_CONFIGURE_FEATURES >-
   --with-gssapi
   --with-icu
   --with-ldap
+  --with-libcurl
   --with-libxml
   --with-libxslt
   --with-llvm
@@ -422,8 +423,10 @@ task:
     EOF
 
   setup_additional_packages_script: |
-    #apt-get update
-    #DEBIAN_FRONTEND=noninteractive apt-get -y install ...
+    apt-get update
+    DEBIAN_FRONTEND=noninteractive apt-get -y install \
+      libcurl4-openssl-dev \
+      libcurl4-openssl-dev:i386 \
 
   matrix:
     - name: Linux - Debian Bookworm - Autoconf
@@ -799,8 +802,8 @@ task:
     folder: $CCACHE_DIR
 
   setup_additional_packages_script: |
-    #apt-get update
-    #DEBIAN_FRONTEND=noninteractive apt-get -y install ...
+    apt-get update
+    DEBIAN_FRONTEND=noninteractive apt-get -y install libcurl4-openssl-dev
 
   ###
   # Test that code can be built with gcc/clang without warnings
diff --git a/config/programs.m4 b/config/programs.m4
index 7b55c2664a6..061b13376ac 100644
--- a/config/programs.m4
+++ b/config/programs.m4
@@ -274,3 +274,68 @@ AC_DEFUN([PGAC_CHECK_STRIP],
   AC_SUBST(STRIP_STATIC_LIB)
   AC_SUBST(STRIP_SHARED_LIB)
 ])# PGAC_CHECK_STRIP
+
+
+
+# PGAC_CHECK_LIBCURL
+# ------------------
+# Check for required libraries and headers, and test to see whether the current
+# installation of libcurl is thread-safe.
+
+AC_DEFUN([PGAC_CHECK_LIBCURL],
+[
+  AC_CHECK_HEADER(curl/curl.h, [],
+				  [AC_MSG_ERROR([header file <curl/curl.h> is required for --with-libcurl])])
+  AC_CHECK_LIB(curl, curl_multi_init, [],
+			   [AC_MSG_ERROR([library 'curl' does not provide curl_multi_init])])
+
+  # Check to see whether the current platform supports threadsafe Curl
+  # initialization.
+  AC_CACHE_CHECK([for curl_global_init thread safety], [pgac_cv__libcurl_threadsafe_init],
+  [AC_RUN_IFELSE([AC_LANG_PROGRAM([
+#include <curl/curl.h>
+],[
+    curl_version_info_data *info;
+
+    if (curl_global_init(CURL_GLOBAL_ALL))
+        return -1;
+
+    info = curl_version_info(CURLVERSION_NOW);
+#ifdef CURL_VERSION_THREADSAFE
+    if (info->features & CURL_VERSION_THREADSAFE)
+        return 0;
+#endif
+
+    return 1;
+])],
+  [pgac_cv__libcurl_threadsafe_init=yes],
+  [pgac_cv__libcurl_threadsafe_init=no],
+  [pgac_cv__libcurl_threadsafe_init=unknown])])
+  if test x"$pgac_cv__libcurl_threadsafe_init" = xyes ; then
+    AC_DEFINE(HAVE_THREADSAFE_CURL_GLOBAL_INIT, 1,
+              [Define to 1 if curl_global_init() is guaranteed to be thread-safe.])
+  fi
+
+  # Warn if a thread-friendly DNS resolver isn't built.
+  AC_CACHE_CHECK([for curl support for asynchronous DNS], [pgac_cv__libcurl_async_dns],
+  [AC_RUN_IFELSE([AC_LANG_PROGRAM([
+#include <curl/curl.h>
+],[
+    curl_version_info_data *info;
+
+    if (curl_global_init(CURL_GLOBAL_ALL))
+        return -1;
+
+    info = curl_version_info(CURLVERSION_NOW);
+    return (info->features & CURL_VERSION_ASYNCHDNS) ? 0 : 1;
+])],
+  [pgac_cv__libcurl_async_dns=yes],
+  [pgac_cv__libcurl_async_dns=no],
+  [pgac_cv__libcurl_async_dns=unknown])])
+  if test x"$pgac_cv__libcurl_async_dns" != xyes ; then
+    AC_MSG_WARN([
+*** The installed version of libcurl does not support asynchronous DNS
+*** lookups. Connection timeouts will not be honored during DNS resolution,
+*** which may lead to hangs in client programs.])
+  fi
+])# PGAC_CHECK_LIBCURL
diff --git a/configure b/configure
index 0ffcaeb4367..93fddd69981 100755
--- a/configure
+++ b/configure
@@ -708,6 +708,9 @@ XML2_LIBS
 XML2_CFLAGS
 XML2_CONFIG
 with_libxml
+LIBCURL_LIBS
+LIBCURL_CFLAGS
+with_libcurl
 with_uuid
 with_readline
 with_systemd
@@ -864,6 +867,7 @@ with_readline
 with_libedit_preferred
 with_uuid
 with_ossp_uuid
+with_libcurl
 with_libxml
 with_libxslt
 with_system_tzdata
@@ -894,6 +898,8 @@ PKG_CONFIG_PATH
 PKG_CONFIG_LIBDIR
 ICU_CFLAGS
 ICU_LIBS
+LIBCURL_CFLAGS
+LIBCURL_LIBS
 XML2_CONFIG
 XML2_CFLAGS
 XML2_LIBS
@@ -1574,6 +1580,7 @@ Optional Packages:
                           prefer BSD Libedit over GNU Readline
   --with-uuid=LIB         build contrib/uuid-ossp using LIB (bsd,e2fs,ossp)
   --with-ossp-uuid        obsolete spelling of --with-uuid=ossp
+  --with-libcurl          build with libcurl support
   --with-libxml           build with XML support
   --with-libxslt          use XSLT support when building contrib/xml2
   --with-system-tzdata=DIR
@@ -1607,6 +1614,10 @@ Some influential environment variables:
               path overriding pkg-config's built-in search path
   ICU_CFLAGS  C compiler flags for ICU, overriding pkg-config
   ICU_LIBS    linker flags for ICU, overriding pkg-config
+  LIBCURL_CFLAGS
+              C compiler flags for LIBCURL, overriding pkg-config
+  LIBCURL_LIBS
+              linker flags for LIBCURL, overriding pkg-config
   XML2_CONFIG path to xml2-config utility
   XML2_CFLAGS C compiler flags for XML2, overriding pkg-config
   XML2_LIBS   linker flags for XML2, overriding pkg-config
@@ -8762,6 +8773,157 @@ fi
 
 
 
+#
+# libcurl
+#
+{ $as_echo "$as_me:${as_lineno-$LINENO}: checking whether to build with libcurl support" >&5
+$as_echo_n "checking whether to build with libcurl support... " >&6; }
+
+
+
+# Check whether --with-libcurl was given.
+if test "${with_libcurl+set}" = set; then :
+  withval=$with_libcurl;
+  case $withval in
+    yes)
+
+$as_echo "#define USE_LIBCURL 1" >>confdefs.h
+
+      ;;
+    no)
+      :
+      ;;
+    *)
+      as_fn_error $? "no argument expected for --with-libcurl option" "$LINENO" 5
+      ;;
+  esac
+
+else
+  with_libcurl=no
+
+fi
+
+
+{ $as_echo "$as_me:${as_lineno-$LINENO}: result: $with_libcurl" >&5
+$as_echo "$with_libcurl" >&6; }
+
+
+if test "$with_libcurl" = yes ; then
+  # Check for libcurl 7.61.0 or higher (corresponding to RHEL8 and the ability
+  # to explicitly set TLS 1.3 ciphersuites).
+
+pkg_failed=no
+{ $as_echo "$as_me:${as_lineno-$LINENO}: checking for libcurl >= 7.61.0" >&5
+$as_echo_n "checking for libcurl >= 7.61.0... " >&6; }
+
+if test -n "$LIBCURL_CFLAGS"; then
+    pkg_cv_LIBCURL_CFLAGS="$LIBCURL_CFLAGS"
+ elif test -n "$PKG_CONFIG"; then
+    if test -n "$PKG_CONFIG" && \
+    { { $as_echo "$as_me:${as_lineno-$LINENO}: \$PKG_CONFIG --exists --print-errors \"libcurl >= 7.61.0\""; } >&5
+  ($PKG_CONFIG --exists --print-errors "libcurl >= 7.61.0") 2>&5
+  ac_status=$?
+  $as_echo "$as_me:${as_lineno-$LINENO}: \$? = $ac_status" >&5
+  test $ac_status = 0; }; then
+  pkg_cv_LIBCURL_CFLAGS=`$PKG_CONFIG --cflags "libcurl >= 7.61.0" 2>/dev/null`
+		      test "x$?" != "x0" && pkg_failed=yes
+else
+  pkg_failed=yes
+fi
+ else
+    pkg_failed=untried
+fi
+if test -n "$LIBCURL_LIBS"; then
+    pkg_cv_LIBCURL_LIBS="$LIBCURL_LIBS"
+ elif test -n "$PKG_CONFIG"; then
+    if test -n "$PKG_CONFIG" && \
+    { { $as_echo "$as_me:${as_lineno-$LINENO}: \$PKG_CONFIG --exists --print-errors \"libcurl >= 7.61.0\""; } >&5
+  ($PKG_CONFIG --exists --print-errors "libcurl >= 7.61.0") 2>&5
+  ac_status=$?
+  $as_echo "$as_me:${as_lineno-$LINENO}: \$? = $ac_status" >&5
+  test $ac_status = 0; }; then
+  pkg_cv_LIBCURL_LIBS=`$PKG_CONFIG --libs "libcurl >= 7.61.0" 2>/dev/null`
+		      test "x$?" != "x0" && pkg_failed=yes
+else
+  pkg_failed=yes
+fi
+ else
+    pkg_failed=untried
+fi
+
+
+
+if test $pkg_failed = yes; then
+        { $as_echo "$as_me:${as_lineno-$LINENO}: result: no" >&5
+$as_echo "no" >&6; }
+
+if $PKG_CONFIG --atleast-pkgconfig-version 0.20; then
+        _pkg_short_errors_supported=yes
+else
+        _pkg_short_errors_supported=no
+fi
+        if test $_pkg_short_errors_supported = yes; then
+	        LIBCURL_PKG_ERRORS=`$PKG_CONFIG --short-errors --print-errors --cflags --libs "libcurl >= 7.61.0" 2>&1`
+        else
+	        LIBCURL_PKG_ERRORS=`$PKG_CONFIG --print-errors --cflags --libs "libcurl >= 7.61.0" 2>&1`
+        fi
+	# Put the nasty error message in config.log where it belongs
+	echo "$LIBCURL_PKG_ERRORS" >&5
+
+	as_fn_error $? "Package requirements (libcurl >= 7.61.0) were not met:
+
+$LIBCURL_PKG_ERRORS
+
+Consider adjusting the PKG_CONFIG_PATH environment variable if you
+installed software in a non-standard prefix.
+
+Alternatively, you may set the environment variables LIBCURL_CFLAGS
+and LIBCURL_LIBS to avoid the need to call pkg-config.
+See the pkg-config man page for more details." "$LINENO" 5
+elif test $pkg_failed = untried; then
+        { $as_echo "$as_me:${as_lineno-$LINENO}: result: no" >&5
+$as_echo "no" >&6; }
+	{ { $as_echo "$as_me:${as_lineno-$LINENO}: error: in \`$ac_pwd':" >&5
+$as_echo "$as_me: error: in \`$ac_pwd':" >&2;}
+as_fn_error $? "The pkg-config script could not be found or is too old.  Make sure it
+is in your PATH or set the PKG_CONFIG environment variable to the full
+path to pkg-config.
+
+Alternatively, you may set the environment variables LIBCURL_CFLAGS
+and LIBCURL_LIBS to avoid the need to call pkg-config.
+See the pkg-config man page for more details.
+
+To get pkg-config, see <http://pkg-config.freedesktop.org/>.
+See \`config.log' for more details" "$LINENO" 5; }
+else
+	LIBCURL_CFLAGS=$pkg_cv_LIBCURL_CFLAGS
+	LIBCURL_LIBS=$pkg_cv_LIBCURL_LIBS
+        { $as_echo "$as_me:${as_lineno-$LINENO}: result: yes" >&5
+$as_echo "yes" >&6; }
+
+fi
+
+  # We only care about -I, -D, and -L switches;
+  # note that -lcurl will be added by PGAC_CHECK_LIBCURL below.
+  for pgac_option in $LIBCURL_CFLAGS; do
+    case $pgac_option in
+      -I*|-D*) CPPFLAGS="$CPPFLAGS $pgac_option";;
+    esac
+  done
+  for pgac_option in $LIBCURL_LIBS; do
+    case $pgac_option in
+      -L*) LDFLAGS="$LDFLAGS $pgac_option";;
+    esac
+  done
+
+  # OAuth requires python for testing
+  if test "$with_python" != yes; then
+    { $as_echo "$as_me:${as_lineno-$LINENO}: WARNING: *** OAuth support tests require --with-python to run" >&5
+$as_echo "$as_me: WARNING: *** OAuth support tests require --with-python to run" >&2;}
+  fi
+fi
+
+
 #
 # XML
 #
@@ -12216,6 +12378,176 @@ fi
 
 fi
 
+# XXX libcurl must link after libgssapi_krb5 on FreeBSD to avoid segfaults
+# during gss_acquire_cred(). This is possibly related to Curl's Heimdal
+# dependency on that platform?
+if test "$with_libcurl" = yes ; then
+
+  ac_fn_c_check_header_mongrel "$LINENO" "curl/curl.h" "ac_cv_header_curl_curl_h" "$ac_includes_default"
+if test "x$ac_cv_header_curl_curl_h" = xyes; then :
+
+else
+  as_fn_error $? "header file <curl/curl.h> is required for --with-libcurl" "$LINENO" 5
+fi
+
+
+  { $as_echo "$as_me:${as_lineno-$LINENO}: checking for curl_multi_init in -lcurl" >&5
+$as_echo_n "checking for curl_multi_init in -lcurl... " >&6; }
+if ${ac_cv_lib_curl_curl_multi_init+:} false; then :
+  $as_echo_n "(cached) " >&6
+else
+  ac_check_lib_save_LIBS=$LIBS
+LIBS="-lcurl  $LIBS"
+cat confdefs.h - <<_ACEOF >conftest.$ac_ext
+/* end confdefs.h.  */
+
+/* Override any GCC internal prototype to avoid an error.
+   Use char because int might match the return type of a GCC
+   builtin and then its argument prototype would still apply.  */
+#ifdef __cplusplus
+extern "C"
+#endif
+char curl_multi_init ();
+int
+main ()
+{
+return curl_multi_init ();
+  ;
+  return 0;
+}
+_ACEOF
+if ac_fn_c_try_link "$LINENO"; then :
+  ac_cv_lib_curl_curl_multi_init=yes
+else
+  ac_cv_lib_curl_curl_multi_init=no
+fi
+rm -f core conftest.err conftest.$ac_objext \
+    conftest$ac_exeext conftest.$ac_ext
+LIBS=$ac_check_lib_save_LIBS
+fi
+{ $as_echo "$as_me:${as_lineno-$LINENO}: result: $ac_cv_lib_curl_curl_multi_init" >&5
+$as_echo "$ac_cv_lib_curl_curl_multi_init" >&6; }
+if test "x$ac_cv_lib_curl_curl_multi_init" = xyes; then :
+  cat >>confdefs.h <<_ACEOF
+#define HAVE_LIBCURL 1
+_ACEOF
+
+  LIBS="-lcurl $LIBS"
+
+else
+  as_fn_error $? "library 'curl' does not provide curl_multi_init" "$LINENO" 5
+fi
+
+
+  # Check to see whether the current platform supports threadsafe Curl
+  # initialization.
+  { $as_echo "$as_me:${as_lineno-$LINENO}: checking for curl_global_init thread safety" >&5
+$as_echo_n "checking for curl_global_init thread safety... " >&6; }
+if ${pgac_cv__libcurl_threadsafe_init+:} false; then :
+  $as_echo_n "(cached) " >&6
+else
+  if test "$cross_compiling" = yes; then :
+  pgac_cv__libcurl_threadsafe_init=unknown
+else
+  cat confdefs.h - <<_ACEOF >conftest.$ac_ext
+/* end confdefs.h.  */
+
+#include <curl/curl.h>
+
+int
+main ()
+{
+
+    curl_version_info_data *info;
+
+    if (curl_global_init(CURL_GLOBAL_ALL))
+        return -1;
+
+    info = curl_version_info(CURLVERSION_NOW);
+#ifdef CURL_VERSION_THREADSAFE
+    if (info->features & CURL_VERSION_THREADSAFE)
+        return 0;
+#endif
+
+    return 1;
+
+  ;
+  return 0;
+}
+_ACEOF
+if ac_fn_c_try_run "$LINENO"; then :
+  pgac_cv__libcurl_threadsafe_init=yes
+else
+  pgac_cv__libcurl_threadsafe_init=no
+fi
+rm -f core *.core core.conftest.* gmon.out bb.out conftest$ac_exeext \
+  conftest.$ac_objext conftest.beam conftest.$ac_ext
+fi
+
+fi
+{ $as_echo "$as_me:${as_lineno-$LINENO}: result: $pgac_cv__libcurl_threadsafe_init" >&5
+$as_echo "$pgac_cv__libcurl_threadsafe_init" >&6; }
+  if test x"$pgac_cv__libcurl_threadsafe_init" = xyes ; then
+
+$as_echo "#define HAVE_THREADSAFE_CURL_GLOBAL_INIT 1" >>confdefs.h
+
+  fi
+
+  # Warn if a thread-friendly DNS resolver isn't built.
+  { $as_echo "$as_me:${as_lineno-$LINENO}: checking for curl support for asynchronous DNS" >&5
+$as_echo_n "checking for curl support for asynchronous DNS... " >&6; }
+if ${pgac_cv__libcurl_async_dns+:} false; then :
+  $as_echo_n "(cached) " >&6
+else
+  if test "$cross_compiling" = yes; then :
+  pgac_cv__libcurl_async_dns=unknown
+else
+  cat confdefs.h - <<_ACEOF >conftest.$ac_ext
+/* end confdefs.h.  */
+
+#include <curl/curl.h>
+
+int
+main ()
+{
+
+    curl_version_info_data *info;
+
+    if (curl_global_init(CURL_GLOBAL_ALL))
+        return -1;
+
+    info = curl_version_info(CURLVERSION_NOW);
+    return (info->features & CURL_VERSION_ASYNCHDNS) ? 0 : 1;
+
+  ;
+  return 0;
+}
+_ACEOF
+if ac_fn_c_try_run "$LINENO"; then :
+  pgac_cv__libcurl_async_dns=yes
+else
+  pgac_cv__libcurl_async_dns=no
+fi
+rm -f core *.core core.conftest.* gmon.out bb.out conftest$ac_exeext \
+  conftest.$ac_objext conftest.beam conftest.$ac_ext
+fi
+
+fi
+{ $as_echo "$as_me:${as_lineno-$LINENO}: result: $pgac_cv__libcurl_async_dns" >&5
+$as_echo "$pgac_cv__libcurl_async_dns" >&6; }
+  if test x"$pgac_cv__libcurl_async_dns" != xyes ; then
+    { $as_echo "$as_me:${as_lineno-$LINENO}: WARNING:
+*** The installed version of libcurl does not support asynchronous DNS
+*** lookups. Connection timeouts will not be honored during DNS resolution,
+*** which may lead to hangs in client programs." >&5
+$as_echo "$as_me: WARNING:
+*** The installed version of libcurl does not support asynchronous DNS
+*** lookups. Connection timeouts will not be honored during DNS resolution,
+*** which may lead to hangs in client programs." >&2;}
+  fi
+
+fi
+
 if test "$with_gssapi" = yes ; then
   if test "$PORTNAME" != "win32"; then
     { $as_echo "$as_me:${as_lineno-$LINENO}: checking for library containing gss_store_cred_into" >&5
diff --git a/configure.ac b/configure.ac
index f56681e0d91..b6d02f5ecc7 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1007,6 +1007,40 @@ fi
 AC_SUBST(with_uuid)
 
 
+#
+# libcurl
+#
+AC_MSG_CHECKING([whether to build with libcurl support])
+PGAC_ARG_BOOL(with, libcurl, no, [build with libcurl support],
+              [AC_DEFINE([USE_LIBCURL], 1, [Define to 1 to build with libcurl support. (--with-libcurl)])])
+AC_MSG_RESULT([$with_libcurl])
+AC_SUBST(with_libcurl)
+
+if test "$with_libcurl" = yes ; then
+  # Check for libcurl 7.61.0 or higher (corresponding to RHEL8 and the ability
+  # to explicitly set TLS 1.3 ciphersuites).
+  PKG_CHECK_MODULES(LIBCURL, [libcurl >= 7.61.0])
+
+  # We only care about -I, -D, and -L switches;
+  # note that -lcurl will be added by PGAC_CHECK_LIBCURL below.
+  for pgac_option in $LIBCURL_CFLAGS; do
+    case $pgac_option in
+      -I*|-D*) CPPFLAGS="$CPPFLAGS $pgac_option";;
+    esac
+  done
+  for pgac_option in $LIBCURL_LIBS; do
+    case $pgac_option in
+      -L*) LDFLAGS="$LDFLAGS $pgac_option";;
+    esac
+  done
+
+  # OAuth requires python for testing
+  if test "$with_python" != yes; then
+    AC_MSG_WARN([*** OAuth support tests require --with-python to run])
+  fi
+fi
+
+
 #
 # XML
 #
@@ -1294,6 +1328,13 @@ failure.  It is possible the compiler isn't looking in the proper directory.
 Use --without-zlib to disable zlib support.])])
 fi
 
+# XXX libcurl must link after libgssapi_krb5 on FreeBSD to avoid segfaults
+# during gss_acquire_cred(). This is possibly related to Curl's Heimdal
+# dependency on that platform?
+if test "$with_libcurl" = yes ; then
+  PGAC_CHECK_LIBCURL
+fi
+
 if test "$with_gssapi" = yes ; then
   if test "$PORTNAME" != "win32"; then
     AC_SEARCH_LIBS(gss_store_cred_into, [gssapi_krb5 gss 'gssapi -lkrb5 -lcrypto'], [],
diff --git a/doc/src/sgml/client-auth.sgml b/doc/src/sgml/client-auth.sgml
index 782b49c85ac..832b616a7bb 100644
--- a/doc/src/sgml/client-auth.sgml
+++ b/doc/src/sgml/client-auth.sgml
@@ -656,6 +656,16 @@ include_dir         <replaceable>directory</replaceable>
          </para>
         </listitem>
        </varlistentry>
+
+       <varlistentry>
+        <term><literal>oauth</literal></term>
+        <listitem>
+         <para>
+          Authorize and optionally authenticate using a third-party OAuth 2.0
+          identity provider. See <xref linkend="auth-oauth"/> for details.
+         </para>
+        </listitem>
+       </varlistentry>
       </variablelist>
 
       </para>
@@ -1143,6 +1153,12 @@ omicron         bryanh                  guest1
       only on OpenBSD).
      </para>
     </listitem>
+    <listitem>
+     <para>
+      <link linkend="auth-oauth">OAuth authorization/authentication</link>,
+      which relies on an external OAuth 2.0 identity provider.
+     </para>
+    </listitem>
    </itemizedlist>
   </para>
 
@@ -2329,6 +2345,242 @@ host ... radius radiusservers="server1,server2" radiussecrets="""secret one"",""
    </note>
   </sect1>
 
+  <sect1 id="auth-oauth">
+   <title>OAuth Authorization/Authentication</title>
+
+   <indexterm zone="auth-oauth">
+    <primary>OAuth Authorization/Authentication</primary>
+   </indexterm>
+
+   <para>
+    OAuth 2.0 is an industry-standard framework, defined in
+    <ulink url="https://datatracker.ietf.org/doc/html/rfc6749">RFC 6749</ulink>,
+    to enable third-party applications to obtain limited access to a protected
+    resource.
+
+    OAuth client support has to be enabled when <productname>PostgreSQL</productname>
+    is built, see <xref linkend="installation"/> for more information.
+   </para>
+
+   <para>
+    This documentation uses the following terminology when discussing the OAuth
+    ecosystem:
+
+    <variablelist>
+
+     <varlistentry>
+      <term>Resource Owner (or End User)</term>
+      <listitem>
+       <para>
+        The user or system who owns protected resources and can grant access to
+        them. This documentation also uses the term <emphasis>end user</emphasis>
+        when the resource owner is a person. When you use
+        <application>psql</application> to connect to the database using OAuth,
+        you are the resource owner/end user.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term>Client</term>
+      <listitem>
+       <para>
+        The system which accesses the protected resources using access
+        tokens. Applications using libpq, such as <application>psql</application>,
+        are the OAuth clients when connecting to a
+        <productname>PostgreSQL</productname> cluster.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term>Resource Server</term>
+      <listitem>
+       <para>
+        The system hosting the protected resources which are
+        accessed by the client. The <productname>PostgreSQL</productname>
+        cluster being connected to is the resource server.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term>Provider</term>
+      <listitem>
+       <para>
+        The organization, product vendor, or other entity which develops and/or
+        administers the OAuth authorization servers and clients for a given application.
+        Different providers typically choose different implementation details
+        for their OAuth systems; a client of one provider is not generally
+        guaranteed to have access to the servers of another.
+       </para>
+       <para>
+        This use of the term "provider" is not standard, but it seems to be in
+        wide use colloquially. (It should not be confused with OpenID's similar
+        term "Identity Provider". While the implementation of OAuth in
+        <productname>PostgreSQL</productname> is intended to be interoperable
+        and compatible with OpenID Connect/OIDC, it is not itself an OIDC client
+        and does not require its use.)
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term>Authorization Server</term>
+      <listitem>
+       <para>
+        The system which receives requests from, and issues access tokens to,
+        the client after the authenticated resource owner has given approval.
+        <productname>PostgreSQL</productname> does not provide an authorization
+        server; it is the responsibility of the OAuth provider.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term id="auth-oauth-issuer">Issuer</term>
+      <listitem>
+       <para>
+        An identifier for an authorization server, printed as an
+        <literal>https://</literal> URL, which provides a trusted "namespace"
+        for OAuth clients and applications. The issuer identifier allows a
+        single authorization server to talk to the clients of mutually
+        untrusting entities, as long as they maintain separate issuers.
+       </para>
+      </listitem>
+     </varlistentry>
+
+    </variablelist>
+
+    <note>
+     <para>
+      For small deployments, there may not be a meaningful distinction between
+      the "provider", "authorization server", and "issuer". However, for more
+      complicated setups, there may be a one-to-many (or many-to-many)
+      relationship: a provider may rent out multiple issuer identifiers to
+      separate tenants, then provide multiple authorization servers, possibly
+      with different supported feature sets, to interact with their clients.
+     </para>
+    </note>
+   </para>
+
+   <para>
+    <productname>PostgreSQL</productname> supports bearer tokens, defined in
+    <ulink url="https://datatracker.ietf.org/doc/html/rfc6750">RFC 6750</ulink>,
+    which are a type of access token used with OAuth 2.0 where the token is an
+    opaque string.  The format of the access token is implementation specific
+    and is chosen by each authorization server.
+   </para>
+
+   <para>
+    The following configuration options are supported for OAuth:
+    <variablelist>
+     <varlistentry>
+      <term><literal>issuer</literal></term>
+      <listitem>
+       <para>
+        An HTTPS URL which is either the exact
+        <link linkend="auth-oauth-issuer">issuer identifier</link> of the
+        authorization server, as defined by its discovery document, or a
+        well-known URI that points directly to that discovery document. This
+        parameter is required.
+       </para>
+       <para>
+        When an OAuth client connects to the server, a URL for the discovery
+        document will be constructed using the issuer identifier. By default,
+        this URL uses the conventions of OpenID Connect Discovery: the path
+        <literal>/.well-known/openid-configuration</literal> will be appended
+        to the end of the issuer identifier. Alternatively, if the
+        <literal>issuer</literal> contains a <literal>/.well-known/</literal>
+        path segment, that URL will be provided to the client as-is.
+       </para>
+       <warning>
+        <para>
+         The OAuth client in libpq requires the server's issuer setting to
+         exactly match the issuer identifier which is provided in the discovery
+         document, which must in turn match the client's
+         <xref linkend="libpq-connect-oauth-issuer"/> setting. No variations in
+         case or formatting are permitted.
+        </para>
+       </warning>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term><literal>scope</literal></term>
+      <listitem>
+       <para>
+        A space-separated list of the OAuth scopes needed for the server to
+        both authorize the client and authenticate the user.  Appropriate values
+        are determined by the authorization server and the OAuth validation
+        module used (see <xref linkend="oauth-validators" /> for more
+        information on validators).  This parameter is required.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term><literal>validator</literal></term>
+      <listitem>
+       <para>
+        The library to use for validating bearer tokens. If given, the name must
+        exactly match one of the libraries listed in
+        <xref linkend="guc-oauth-validator-libraries" />.  This parameter is
+        optional unless <literal>oauth_validator_libraries</literal> contains
+        more than one library, in which case it is required.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term><literal>map</literal></term>
+      <listitem>
+       <para>
+        Allows for mapping between OAuth identity provider and database user
+        names.  See <xref linkend="auth-username-maps"/> for details.  If a
+        map is not specified, the user name associated with the token (as
+        determined by the OAuth validator) must exactly match the role name
+        being requested.  This parameter is optional.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term id="auth-oauth-delegate-ident-mapping" xreflabel="delegate_ident_mapping">
+       <literal>delegate_ident_mapping</literal>
+      </term>
+      <listitem>
+       <para>
+        An advanced option which is not intended for common use.
+       </para>
+       <para>
+        When set to <literal>1</literal>, standard user mapping with
+        <filename>pg_ident.conf</filename> is skipped, and the OAuth validator
+        takes full responsibility for mapping end user identities to database
+        roles.  If the validator authorizes the token, the server trusts that
+        the user is allowed to connect under the requested role, and the
+        connection is allowed to proceed regardless of the authentication
+        status of the user.
+       </para>
+       <para>
+        This parameter is incompatible with <literal>map</literal>.
+       </para>
+       <warning>
+        <para>
+         <literal>delegate_ident_mapping</literal> provides additional
+         flexibility in the design of the authentication system, but it also
+         requires careful implementation of the OAuth validator, which must
+         determine whether the provided token carries sufficient end-user
+         privileges in addition to the <link linkend="oauth-validators">standard
+         checks</link> required of all validators.  Use with caution.
+        </para>
+       </warning>
+      </listitem>
+     </varlistentry>
+    </variablelist>
+   </para>
+  </sect1>
+
   <sect1 id="client-authentication-problems">
    <title>Authentication Problems</title>
 
diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml
index 9eedcf6f0f4..007746a4429 100644
--- a/doc/src/sgml/config.sgml
+++ b/doc/src/sgml/config.sgml
@@ -1209,6 +1209,32 @@ include_dir 'conf.d'
        </para>
       </listitem>
      </varlistentry>
+
+     <varlistentry id="guc-oauth-validator-libraries" xreflabel="oauth_validator_libraries">
+      <term><varname>oauth_validator_libraries</varname> (<type>string</type>)
+      <indexterm>
+       <primary><varname>oauth_validator_libraries</varname> configuration parameter</primary>
+      </indexterm>
+      </term>
+      <listitem>
+       <para>
+        The library/libraries to use for validating OAuth connection tokens. If
+        only one validator library is provided, it will be used by default for
+        any OAuth connections; otherwise, all
+        <link linkend="auth-oauth"><literal>oauth</literal> HBA entries</link>
+        must explicitly set a <literal>validator</literal> chosen from this
+        list. If set to an empty string (the default), OAuth connections will be
+        refused. This parameter can only be set in the
+        <filename>postgresql.conf</filename> file.
+       </para>
+       <para>
+        Validator modules must be implemented/obtained separately;
+        <productname>PostgreSQL</productname> does not ship with any default
+        implementations. For more information on implementing OAuth validators,
+        see <xref linkend="oauth-validators" />.
+       </para>
+      </listitem>
+     </varlistentry>
      </variablelist>
      </sect2>
 
diff --git a/doc/src/sgml/filelist.sgml b/doc/src/sgml/filelist.sgml
index 66e6dccd4c9..25fb99cee69 100644
--- a/doc/src/sgml/filelist.sgml
+++ b/doc/src/sgml/filelist.sgml
@@ -111,6 +111,7 @@
 <!ENTITY generic-wal SYSTEM "generic-wal.sgml">
 <!ENTITY custom-rmgr SYSTEM "custom-rmgr.sgml">
 <!ENTITY backup-manifest SYSTEM "backup-manifest.sgml">
+<!ENTITY oauth-validators SYSTEM "oauth-validators.sgml">
 
 <!-- contrib information -->
 <!ENTITY contrib         SYSTEM "contrib.sgml">
diff --git a/doc/src/sgml/installation.sgml b/doc/src/sgml/installation.sgml
index 3f0a7e9c069..3c95c15a1e4 100644
--- a/doc/src/sgml/installation.sgml
+++ b/doc/src/sgml/installation.sgml
@@ -1143,6 +1143,19 @@ build-postgresql:
        </listitem>
       </varlistentry>
 
+      <varlistentry id="configure-option-with-libcurl">
+       <term><option>--with-libcurl</option></term>
+       <listitem>
+        <para>
+         Build with libcurl support for OAuth 2.0 client flows.
+         Libcurl version 7.61.0 or later is required for this feature.
+         Building with this will check for the required header files
+         and libraries to make sure that your <productname>curl</productname>
+         installation is sufficient before proceeding.
+        </para>
+       </listitem>
+      </varlistentry>
+
       <varlistentry id="configure-option-with-libxml">
        <term><option>--with-libxml</option></term>
        <listitem>
@@ -2584,6 +2597,20 @@ ninja install
       </listitem>
      </varlistentry>
 
+     <varlistentry id="configure-with-libcurl-meson">
+      <term><option>-Dlibcurl={ auto | enabled | disabled }</option></term>
+      <listitem>
+       <para>
+        Build with libcurl support for OAuth 2.0 client flows.
+        Libcurl version 7.61.0 or later is required for this feature.
+        Building with this will check for the required header files
+        and libraries to make sure that your <productname>Curl</productname>
+        installation is sufficient before proceeding. The default for this
+        option is auto.
+       </para>
+      </listitem>
+     </varlistentry>
+
      <varlistentry id="configure-with-libxml-meson">
       <term><option>-Dlibxml={ auto | enabled | disabled }</option></term>
       <listitem>
diff --git a/doc/src/sgml/libpq.sgml b/doc/src/sgml/libpq.sgml
index c49e975b082..ddb3596df83 100644
--- a/doc/src/sgml/libpq.sgml
+++ b/doc/src/sgml/libpq.sgml
@@ -1385,6 +1385,15 @@ postgresql://%2Fvar%2Flib%2Fpostgresql/dbname
           </listitem>
          </varlistentry>
 
+         <varlistentry>
+          <term><literal>oauth</literal></term>
+          <listitem>
+           <para>
+            The server must request an OAuth bearer token from the client.
+           </para>
+          </listitem>
+         </varlistentry>
+
          <varlistentry>
           <term><literal>none</literal></term>
           <listitem>
@@ -2373,6 +2382,107 @@ postgresql://%2Fvar%2Flib%2Fpostgresql/dbname
        </para>
       </listitem>
      </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-issuer" xreflabel="oauth_issuer">
+      <term><literal>oauth_issuer</literal></term>
+      <listitem>
+       <para>
+        The HTTPS URL of a trusted issuer to contact if the server requests an
+        OAuth token for the connection. This parameter is required for all OAuth
+        connections; it should exactly match the <literal>issuer</literal>
+        setting in <link linkend="auth-oauth">the server's HBA configuration</link>.
+       </para>
+       <para>
+        As part of the standard authentication handshake, <application>libpq</application>
+        will ask the server for a <emphasis>discovery document:</emphasis> a URL
+        providing a set of OAuth configuration parameters. The server must
+        provide a URL that is directly constructed from the components of the
+        <literal>oauth_issuer</literal>, and this value must exactly match the
+        issuer identifier that is declared in the discovery document itself, or
+        the connection will fail. This is required to prevent a class of
+        <ulink url="https://mailarchive.ietf.org/arch/msg/oauth/JIVxFBGsJBVtm7ljwJhPUm3Fr-w/">
+        "mix-up attacks"</ulink> on OAuth clients.
+       </para>
+       <para>
+        You may also explicitly set <literal>oauth_issuer</literal> to the
+        <literal>/.well-known/</literal> URI used for OAuth discovery. In this
+        case, if the server asks for a different URL, the connection will fail,
+        but a <link linkend="libpq-oauth-authdata-hooks">custom OAuth flow</link>
+        may be able to speed up the standard handshake by using previously
+        cached tokens. (In this case, it is recommended that
+        <xref linkend="libpq-connect-oauth-scope"/> be set as well, since the
+        client will not have a chance to ask the server for a correct scope
+        setting, and the default scopes for a token may not be sufficient to
+        connect.) <application>libpq</application> currently supports the
+        following well-known endpoints:
+        <itemizedlist spacing="compact">
+         <listitem><para><literal>/.well-known/openid-configuration</literal></para></listitem>
+         <listitem><para><literal>/.well-known/oauth-authorization-server</literal></para></listitem>
+        </itemizedlist>
+       </para>
+       <warning>
+        <para>
+         Issuers are highly privileged during the OAuth connection handshake. As
+         a rule of thumb, if you would not trust the operator of a URL to handle
+         access to your servers, or to impersonate you directly, that URL should
+         not be trusted as an <literal>oauth_issuer</literal>.
+        </para>
+       </warning>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-client-id" xreflabel="oauth_client_id">
+      <term><literal>oauth_client_id</literal></term>
+      <listitem>
+       <para>
+        An OAuth 2.0 client identifier, as issued by the authorization server.
+        If the <productname>PostgreSQL</productname> server
+        <link linkend="auth-oauth">requests an OAuth token</link> for the
+        connection (and if no <link linkend="libpq-oauth-authdata-hooks">custom
+        OAuth hook</link> is installed to provide one), then this parameter must
+        be set; otherwise, the connection will fail.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-client-secret" xreflabel="oauth_client_secret">
+      <term><literal>oauth_client_secret</literal></term>
+      <listitem>
+       <para>
+        The client password, if any, to use when contacting the OAuth
+        authorization server. Whether this parameter is required or not is
+        determined by the OAuth provider; "public" clients generally do not use
+        a secret, whereas "confidential" clients generally do.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-scope" xreflabel="oauth_scope">
+      <term><literal>oauth_scope</literal></term>
+      <listitem>
+       <para>
+        The scope of the access request sent to the authorization server,
+        specified as a (possibly empty) space-separated list of OAuth scope
+        identifiers. This parameter is optional and intended for advanced usage.
+       </para>
+       <para>
+        Usually the client will obtain appropriate scope settings from the
+        <productname>PostgreSQL</productname> server. If this parameter is used,
+        the server's requested scope list will be ignored. This can prevent a
+        less-trusted server from requesting inappropriate access scopes from the
+        end user. However, if the client's scope setting does not contain the
+        server's required scopes, the server is likely to reject the issued
+        token, and the connection will fail.
+       </para>
+       <para>
+        The meaning of an empty scope list is provider-dependent. An OAuth
+        authorization server may choose to issue a token with "default scope",
+        whatever that happens to be, or it may reject the token request
+        entirely.
+       </para>
+      </listitem>
+     </varlistentry>
+
     </variablelist>
    </para>
   </sect2>
@@ -10020,6 +10130,329 @@ void PQinitSSL(int do_ssl);
 
  </sect1>
 
+ <sect1 id="libpq-oauth">
+  <title>OAuth Support</title>
+
+  <para>
+   libpq implements support for the OAuth v2 Device Authorization client flow,
+   documented in
+   <ulink url="https://datatracker.ietf.org/doc/html/rfc8628">RFC 8628</ulink>,
+   which it will attempt to use by default if the server
+   <link linkend="auth-oauth">requests a bearer token</link> during
+   authentication. This flow can be utilized even if the system running the
+   client application does not have a usable web browser, for example when
+   running a client via <application>SSH</application>. Client applications may implement their own flows
+   instead; see <xref linkend="libpq-oauth-authdata-hooks"/>.
+  </para>
+  <para>
+   The builtin flow will, by default, print a URL to visit and a user code to
+   enter there:
+<programlisting>
+$ psql 'dbname=postgres oauth_issuer=https://example.com oauth_client_id=...'
+Visit https://example.com/device and enter the code: ABCD-EFGH
+</programlisting>
+   (This prompt may be
+   <link linkend="libpq-oauth-authdata-prompt-oauth-device">customized</link>.)
+   The user will then log into their OAuth provider, which will ask whether
+   to allow libpq and the server to perform actions on their behalf. It is always
+   a good idea to carefully review the URL and permissions displayed, to ensure
+   they match expectations, before continuing. Permissions should not be given
+   to untrusted third parties.
+  </para>
+  <para>
+   For an OAuth client flow to be usable, the connection string must at minimum
+   contain <xref linkend="libpq-connect-oauth-issuer"/> and
+   <xref linkend="libpq-connect-oauth-client-id"/>. (These settings are
+   determined by your organization's OAuth provider.) The builtin flow
+   additionally requires the OAuth authorization server to publish a device
+   authorization endpoint.
+  </para>
+
+  <note>
+   <para>
+    The builtin Device Authorization flow is not currently supported on Windows.
+    Custom client flows may still be implemented.
+   </para>
+  </note>
+
+  <sect2 id="libpq-oauth-authdata-hooks">
+   <title>Authdata Hooks</title>
+
+   <para>
+    The behavior of the OAuth flow may be modified or replaced by a client using
+    the following hook API:
+
+    <variablelist>
+     <varlistentry id="libpq-PQsetAuthDataHook">
+      <term><function>PQsetAuthDataHook</function><indexterm><primary>PQsetAuthDataHook</primary></indexterm></term>
+
+      <listitem>
+       <para>
+        Sets the <symbol>PGauthDataHook</symbol>, overriding
+        <application>libpq</application>'s handling of one or more aspects of
+        its OAuth client flow.
+<synopsis>
+void PQsetAuthDataHook(PQauthDataHook_type hook);
+</synopsis>
+        If <replaceable>hook</replaceable> is <literal>NULL</literal>, the
+        default handler will be reinstalled. Otherwise, the application passes
+        a pointer to a callback function with the signature:
+<programlisting>
+int hook_fn(PGauthData type, PGconn *conn, void *data);
+</programlisting>
+        which <application>libpq</application> will call when an action is
+        required of the application. <replaceable>type</replaceable> describes
+        the request being made, <replaceable>conn</replaceable> is the
+        connection handle being authenticated, and <replaceable>data</replaceable>
+        points to request-specific metadata. The contents of this pointer are
+        determined by <replaceable>type</replaceable>; see
+        <xref linkend="libpq-oauth-authdata-hooks-types"/> for the supported
+        list.
+       </para>
+       <para>
+        Hooks can be chained together to allow cooperative and/or fallback
+        behavior. In general, a hook implementation should examine the incoming
+        <replaceable>type</replaceable> (and, potentially, the request metadata
+        and/or the settings for the particular <replaceable>conn</replaceable>
+        in use) to decide whether or not to handle a specific piece of authdata.
+        If not, it should delegate to the previous hook in the chain
+        (retrievable via <function>PQgetAuthDataHook</function>).
+       </para>
+       <para>
+        Success is indicated by returning an integer greater than zero.
+        Returning a negative integer signals an error condition and abandons the
+        connection attempt. (A zero value is reserved for the default
+        implementation.)
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="libpq-PQgetAuthDataHook">
+      <term><function>PQgetAuthDataHook</function><indexterm><primary>PQgetAuthDataHook</primary></indexterm></term>
+
+      <listitem>
+       <para>
+        Retrieves the current value of <symbol>PGauthDataHook</symbol>.
+<synopsis>
+PQauthDataHook_type PQgetAuthDataHook(void);
+</synopsis>
+        At initialization time (before the first call to
+        <function>PQsetAuthDataHook</function>), this function will return
+        <symbol>PQdefaultAuthDataHook</symbol>.
+       </para>
+      </listitem>
+     </varlistentry>
+    </variablelist>
+   </para>
+
+   <sect3 id="libpq-oauth-authdata-hooks-types">
+    <title>Hook Types</title>
+    <para>
+     The following <symbol>PGauthData</symbol> types and their corresponding
+     <replaceable>data</replaceable> structures are defined:
+
+     <variablelist>
+      <varlistentry id="libpq-oauth-authdata-prompt-oauth-device">
+       <term>
+        <symbol>PQAUTHDATA_PROMPT_OAUTH_DEVICE</symbol>
+        <indexterm><primary>PQAUTHDATA_PROMPT_OAUTH_DEVICE</primary></indexterm>
+       </term>
+       <listitem>
+        <para>
+         Replaces the default user prompt during the builtin device
+         authorization client flow. <replaceable>data</replaceable> points to
+         an instance of <symbol>PGpromptOAuthDevice</symbol>:
+<synopsis>
+typedef struct _PGpromptOAuthDevice
+{
+    const char *verification_uri;   /* verification URI to visit */
+    const char *user_code;          /* user code to enter */
+    const char *verification_uri_complete;  /* optional combination of URI and
+                                             * code, or NULL */
+    int         expires_in;         /* seconds until user code expires */
+} PGpromptOAuthDevice;
+</synopsis>
+        </para>
+        <para>
+         The OAuth Device Authorization flow included in <application>libpq</application>
+         requires the end user to visit a URL with a browser, then enter a code
+         which permits <application>libpq</application> to connect to the server
+         on their behalf. The default prompt simply prints the
+         <literal>verification_uri</literal> and <literal>user_code</literal>
+         on standard error. Replacement implementations may display this
+         information using any preferred method, for example with a GUI.
+        </para>
+        <para>
+         This callback is only invoked during the builtin device
+         authorization flow. If the application installs a
+         <link linkend="libpq-oauth-authdata-oauth-bearer-token">custom OAuth
+         flow</link>, this authdata type will not be used.
+        </para>
+        <para>
+         If a non-NULL <structfield>verification_uri_complete</structfield> is
+         provided, it may optionally be used for non-textual verification (for
+         example, by displaying a QR code). The URL and user code should still
+         be displayed to the end user in this case, because the code will be
+         manually confirmed by the provider, and the URL lets users continue
+         even if they can't use the non-textual method. For more information,
+         see section 3.3.1 in
+         <ulink url="https://datatracker.ietf.org/doc/html/rfc8628#section-3.3.1">RFC 8628</ulink>.
+        </para>
+       </listitem>
+      </varlistentry>
+
+      <varlistentry id="libpq-oauth-authdata-oauth-bearer-token">
+       <term>
+        <symbol>PQAUTHDATA_OAUTH_BEARER_TOKEN</symbol>
+        <indexterm><primary>PQAUTHDATA_OAUTH_BEARER_TOKEN</primary></indexterm>
+       </term>
+       <listitem>
+        <para>
+         Replaces the entire OAuth flow with a custom implementation. The hook
+         should either directly return a Bearer token for the current
+         user/issuer/scope combination, if one is available without blocking, or
+         else set up an asynchronous callback to retrieve one.
+        </para>
+        <para>
+         <replaceable>data</replaceable> points to an instance
+         of <symbol>PGoauthBearerRequest</symbol>, which should be filled in
+         by the implementation:
+<synopsis>
+typedef struct _PGoauthBearerRequest
+{
+    /* Hook inputs (constant across all calls) */
+    const char *const openid_configuration; /* OIDC discovery URL */
+    const char *const scope;                /* required scope(s), or NULL */
+
+    /* Hook outputs */
+
+    /* Callback implementing a custom asynchronous OAuth flow. */
+    PostgresPollingStatusType (*async) (PGconn *conn,
+                                        struct _PGoauthBearerRequest *request,
+                                        SOCKTYPE *altsock);
+
+    /* Callback to clean up custom allocations. */
+    void        (*cleanup) (PGconn *conn, struct _PGoauthBearerRequest *request);
+
+    char       *token;   /* acquired Bearer token */
+    void       *user;    /* hook-defined allocated data */
+} PGoauthBearerRequest;
+</synopsis>
+        </para>
+        <para>
+         Two pieces of information are provided to the hook by
+         <application>libpq</application>:
+         <replaceable>openid_configuration</replaceable> contains the URL of an
+         OAuth discovery document describing the authorization server's
+         supported flows, and <replaceable>scope</replaceable> contains a
+         (possibly empty) space-separated list of OAuth scopes which are
+         required to access the server. Either or both may be
+         <literal>NULL</literal> to indicate that the information was not
+         discoverable. (In this case, implementations may be able to establish
+         the requirements using some other preconfigured knowledge, or they may
+         choose to fail.)
+        </para>
+        <para>
+         The final output of the hook is <replaceable>token</replaceable>, which
+         must point to a valid Bearer token for use on the connection. (This
+         token should be issued by the
+         <xref linkend="libpq-connect-oauth-issuer"/> and hold the requested
+         scopes, or the connection will be rejected by the server's validator
+         module.) The allocated token string must remain valid until
+         <application>libpq</application> is finished connecting; the hook
+         should set a <replaceable>cleanup</replaceable> callback which will be
+         called when <application>libpq</application> no longer requires it.
+        </para>
+        <para>
+         If an implementation cannot immediately produce a
+         <replaceable>token</replaceable> during the initial call to the hook,
+         it should set the <replaceable>async</replaceable> callback to handle
+         nonblocking communication with the authorization server.
+         <footnote>
+          <para>
+           Performing blocking operations during the
+           <symbol>PQAUTHDATA_OAUTH_BEARER_TOKEN</symbol> hook callback will
+           interfere with nonblocking connection APIs such as
+           <function>PQconnectPoll</function> and prevent concurrent connections
+           from making progress. Applications which only ever use the
+           synchronous connection primitives, such as
+           <function>PQconnectdb</function>, may synchronously retrieve a token
+           during the hook instead of implementing the
+           <replaceable>async</replaceable> callback, but they will necessarily
+           be limited to one connection at a time.
+          </para>
+         </footnote>
+         This will be called to begin the flow immediately upon return from the
+         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
+         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>
+         after setting <replaceable>token</replaceable> when the flow is
+         complete, or <symbol>PGRES_POLLING_FAILED</symbol> to indicate failure.
+        </para>
+        <para>
+         Implementations may wish to store additional data for bookkeeping
+         across calls to the <replaceable>async</replaceable> and
+         <replaceable>cleanup</replaceable> callbacks. The
+         <replaceable>user</replaceable> pointer is provided for this purpose;
+         <application>libpq</application> will not touch its contents and the
+         application may use it at its convenience. (Remember to free any
+         allocations during token cleanup.)
+        </para>
+       </listitem>
+      </varlistentry>
+     </variablelist>
+    </para>
+   </sect3>
+  </sect2>
+
+  <sect2 id="libpq-oauth-debugging">
+   <title>Debugging and Developer Settings</title>
+
+   <para>
+    A "dangerous debugging mode" may be enabled by setting the environment
+    variable <envar>PGOAUTHDEBUG=UNSAFE</envar>. This functionality is provided
+    for ease of local development and testing only. It does several things that
+    you will not want a production system to do:
+
+    <itemizedlist spacing="compact">
+     <listitem>
+      <para>
+       permits the use of unencrypted HTTP during the OAuth provider exchange
+      </para>
+     </listitem>
+     <listitem>
+      <para>
+       allows the system's trusted CA list to be completely replaced using the
+       <envar>PGOAUTHCAFILE</envar> environment variable
+      </para>
+     </listitem>
+     <listitem>
+      <para>
+       prints HTTP traffic (containing several critical secrets) to standard
+       error during the OAuth flow
+      </para>
+     </listitem>
+     <listitem>
+      <para>
+       permits the use of zero-second retry intervals, which can cause the
+       client to busy-loop and pointlessly consume CPU
+      </para>
+     </listitem>
+    </itemizedlist>
+   </para>
+   <warning>
+    <para>
+     Do not share the output of the OAuth flow traffic with third parties. It
+     contains secrets that can be used to attack your clients and servers.
+    </para>
+   </warning>
+  </sect2>
+ </sect1>
+
 
  <sect1 id="libpq-threading">
   <title>Behavior in Threaded Programs</title>
@@ -10092,6 +10525,18 @@ int PQisthreadsafe();
    <application>libpq</application> source code for a way to do cooperative
    locking between <application>libpq</application> and your application.
   </para>
+
+  <para>
+   Similarly, if you are using <productname>Curl</productname> inside your application,
+   <emphasis>and</emphasis> you do not already
+   <ulink url="https://curl.se/libcurl/c/curl_global_init.html">initialize
+   libcurl globally</ulink> before starting new threads, you will need to
+   cooperatively lock (again via <function>PQregisterThreadLock</function>)
+   around any code that may initialize libcurl. This restriction is lifted for
+   more recent versions of <productname>Curl</productname> that are built to support thread-safe
+   initialization; those builds can be identified by the advertisement of a
+   <literal>threadsafe</literal> feature in their version metadata.
+  </para>
  </sect1>
 
 
diff --git a/doc/src/sgml/oauth-validators.sgml b/doc/src/sgml/oauth-validators.sgml
new file mode 100644
index 00000000000..356f11d3bd8
--- /dev/null
+++ b/doc/src/sgml/oauth-validators.sgml
@@ -0,0 +1,414 @@
+<!-- doc/src/sgml/oauth-validators.sgml -->
+
+<chapter id="oauth-validators">
+ <title>OAuth Validator Modules</title>
+ <indexterm zone="oauth-validators">
+  <primary>OAuth Validators</primary>
+ </indexterm>
+ <para>
+  <productname>PostgreSQL</productname> provides infrastructure for creating
+  custom modules to perform server-side validation of OAuth bearer tokens.
+  Because OAuth implementations vary so wildly, and bearer token validation is
+  heavily dependent on the issuing party, the server cannot check the token
+  itself; validator modules provide the integration layer between the server
+  and the OAuth provider in use.
+ </para>
+ <para>
+  OAuth validator modules must at least consist of an initialization function
+  (see <xref linkend="oauth-validator-init"/>) and the required callback for
+  performing validation (see <xref linkend="oauth-validator-callback-validate"/>).
+ </para>
+ <warning>
+  <para>
+   Since a misbehaving validator might let unauthorized users into the database,
+   correct implementation is crucial for server safety. See
+   <xref linkend="oauth-validator-design"/> for design considerations.
+  </para>
+ </warning>
+
+ <sect1 id="oauth-validator-design">
+  <title>Safely Designing a Validator Module</title>
+  <warning>
+   <para>
+    Read and understand the entirety of this section before implementing a
+    validator module. A malfunctioning validator is potentially worse than no
+    authentication at all, both because of the false sense of security it
+    provides, and because it may contribute to attacks against other pieces of
+    an OAuth ecosystem.
+   </para>
+  </warning>
+
+  <sect2 id="oauth-validator-design-responsibilities">
+   <title>Validator Responsibilities</title>
+   <para>
+    Although different modules may take very different approaches to token
+    validation, implementations generally need to perform three separate
+    actions:
+   </para>
+   <variablelist>
+    <varlistentry>
+     <term>Validate the Token</term>
+     <listitem>
+      <para>
+       The validator must first ensure that the presented token is in fact a
+       valid Bearer token for use in client authentication. The correct way to
+       do this depends on the provider, but it generally involves either
+       cryptographic operations to prove that the token was created by a trusted
+       party (offline validation), or the presentation of the token to that
+       trusted party so that it can perform validation for you (online
+       validation).
+      </para>
+      <para>
+       Online validation, usually implemented via
+       <ulink url="https://datatracker.ietf.org/doc/html/rfc7662">OAuth Token
+       Introspection</ulink>, requires fewer steps of a validator module and
+       allows central revocation of a token in the event that it is stolen
+       or misissued. However, it does require the module to make at least one
+       network call per authentication attempt (all of which must complete
+       within the configured <xref linkend="guc-authentication-timeout"/>).
+       Additionally, your provider may not provide introspection endpoints for
+       use by external resource servers.
+      </para>
+      <para>
+       Offline validation is much more involved, typically requiring a validator
+       to maintain a list of trusted signing keys for a provider and then
+       check the token's cryptographic signature along with its contents.
+       Implementations must follow the provider's instructions to the letter,
+       including any verification of issuer ("where is this token from?"),
+       audience ("who is this token for?"), and validity period ("when can this
+       token be used?"). Since there is no communication between the module and
+       the provider, tokens cannot be centrally revoked using this method;
+       offline validator implementations may wish to place restrictions on the
+       maximum length of a token's validity period.
+      </para>
+      <para>
+       If the token cannot be validated, the module should immediately fail.
+       Further authentication/authorization is pointless if the bearer token
+       wasn't issued by a trusted party.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Authorize the Client</term>
+     <listitem>
+      <para>
+       Next the validator must ensure that the end user has given the client
+       permission to access the server on their behalf. This generally involves
+       checking the scopes that have been assigned to the token, to make sure
+       that they cover database access for the current HBA parameters.
+      </para>
+      <para>
+       The purpose of this step is to prevent an OAuth client from obtaining a
+       token under false pretenses. If the validator requires all tokens to
+       carry scopes that cover database access, the provider should then loudly
+       prompt the user to grant that access during the flow. This gives them the
+       opportunity to reject the request if the client isn't supposed to be
+       using their credentials to connect to databases.
+      </para>
+      <para>
+       While it is possible to establish client authorization without explicit
+       scopes by using out-of-band knowledge of the deployed architecture, doing
+       so removes the user from the loop, which prevents them from catching
+       deployment mistakes and allows any such mistakes to be exploited
+       silently. Access to the database must be tightly restricted to only
+       trusted clients
+       <footnote>
+        <para>
+         That is, "trusted" in the sense that the OAuth client and the
+         <productname>PostgreSQL</productname> server are controlled by the same
+         entity. Notably, the Device Authorization client flow supported by
+         libpq does not usually meet this bar, since it's designed for use by
+         public/untrusted clients.
+        </para>
+       </footnote>
+       if users are not prompted for additional scopes.
+      </para>
+      <para>
+       Even if authorization fails, a module may choose to continue to pull
+       authentication information from the token for use in auditing and
+       debugging.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Authenticate the End User</term>
+     <listitem>
+      <para>
+       Finally, the validator should determine a user identifier for the token,
+       either by asking the provider for this information or by extracting it
+       from the token itself, and return that identifier to the server (which
+       will then make a final authorization decision using the HBA
+       configuration). This identifier will be available within the session via
+       <link linkend="functions-info-session-table"><function>system_user</function></link>
+       and recorded in the server logs if <xref linkend="guc-log-connections"/>
+       is enabled.
+      </para>
+      <para>
+       Different providers may record a variety of different authentication
+       information for an end user, typically referred to as
+       <emphasis>claims</emphasis>. Providers usually document which of these
+       claims are trustworthy enough to use for authorization decisions and
+       which are not. (For instance, it would probably not be wise to use an
+       end user's full name as the identifier for authentication, since many
+       providers allow users to change their display names arbitrarily.)
+       Ultimately, the choice of which claim (or combination of claims) to use
+       comes down to the provider implementation and application requirements.
+      </para>
+      <para>
+       Note that anonymous/pseudonymous login is possible as well, by enabling
+       usermap delegation; see
+       <xref linkend="oauth-validator-design-usermap-delegation"/>.
+      </para>
+     </listitem>
+    </varlistentry>
+   </variablelist>
+  </sect2>
+
+  <sect2 id="oauth-validator-design-guidelines">
+   <title>General Coding Guidelines</title>
+   <para>
+    Developers should keep the following in mind when implementing token
+    validation:
+   </para>
+   <variablelist>
+    <varlistentry>
+     <term>Token Confidentiality</term>
+     <listitem>
+      <para>
+       Modules should not write tokens, or pieces of tokens, into the server
+       log. This is true even if the module considers the token invalid; an
+       attacker who confuses a client into communicating with the wrong provider
+       should not be able to retrieve that (otherwise valid) token from the
+       disk.
+      </para>
+      <para>
+       Implementations that send tokens over the network (for example, to
+       perform online token validation with a provider) must authenticate the
+       peer and ensure that strong transport security is in use.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Logging</term>
+     <listitem>
+      <para>
+       Modules may use the same <link linkend="error-message-reporting">logging
+       facilities</link> as standard extensions; however, the rules for emitting
+       log entries to the client are subtly different during the authentication
+       phase of the connection. Generally speaking, modules should log
+       verification problems at the <symbol>COMMERROR</symbol> level and return
+       normally, instead of using <symbol>ERROR</symbol>/<symbol>FATAL</symbol>
+       to unwind the stack, to avoid leaking information to unauthenticated
+       clients.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Interruptibility</term>
+     <listitem>
+      <para>
+       Modules must remain interruptible by signals so that the server can
+       correctly handle authentication timeouts and shutdown signals from
+       <application>pg_ctl</application>. For example, a module receiving
+       <symbol>EINTR</symbol>/<symbol>EAGAIN</symbol> from a blocking call
+       should call <function>CHECK_FOR_INTERRUPTS()</function> before retrying.
+       The same should be done during any long-running loops. Failure to follow
+       this guidance may result in unresponsive backend sessions.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Testing</term>
+     <listitem>
+      <para>
+       The breadth of testing an OAuth system is well beyond the scope of this
+       documentation, but at minimum, negative testing should be considered
+       mandatory. It's trivial to design a module that lets authorized users in;
+       the whole point of the system is to keep unauthorized users out.
+      </para>
+     </listitem>
+    </varlistentry>
+    <varlistentry>
+     <term>Documentation</term>
+     <listitem>
+      <para>
+       Validator implementations should document the contents and format of the
+       authenticated ID that is reported to the server for each end user, since
+       DBAs may need to use this information to construct pg_ident maps. (For
+       instance, is it an email address? an organizational ID number? a UUID?)
+       They should also document whether or not it is safe to use the module in
+       <symbol>delegate_ident_mapping=1</symbol> mode, and what additional
+       configuration is required in order to do so.
+      </para>
+     </listitem>
+    </varlistentry>
+   </variablelist>
+  </sect2>
+
+  <sect2 id="oauth-validator-design-usermap-delegation">
+   <title>Authorizing Users (Usermap Delegation)</title>
+   <para>
+    The standard deliverable of a validation module is the user identifier,
+    which the server will then compare to any configured
+    <link linkend="auth-username-maps"><filename>pg_ident.conf</filename>
+    mappings</link> and determine whether the end user is authorized to connect.
+    However, OAuth is itself an authorization framework, and tokens may carry
+    information about user privileges. For example, a token may be associated
+    with the organizational groups that a user belongs to, or list the roles
+    that a user may assume, and duplicating that knowledge into local usermaps
+    for every server may not be desirable.
+   </para>
+   <para>
+    To bypass username mapping entirely, and have the validator module assume
+    the additional responsibility of authorizing user connections, the HBA may
+    be configured with <xref linkend="auth-oauth-delegate-ident-mapping"/>.
+    The module may then use token scopes or an equivalent method to decide
+    whether the user is allowed to connect under their desired role. The user
+    identifier will still be recorded by the server, but it plays no part in
+    determining whether to continue the connection.
+   </para>
+   <para>
+    Using this scheme, authentication itself is optional. As long as the module
+    reports that the connection is authorized, login will continue even if there
+    is no recorded user identifier at all. This makes it possible to implement
+    anonymous or pseudonymous access to the database, where the third-party
+    provider performs all necessary authentication but does not provide any
+    user-identifying information to the server. (Some providers may create an
+    anonymized ID number that can be recorded instead, for later auditing.)
+   </para>
+   <para>
+    Usermap delegation provides the most architectural flexibility, but it turns
+    the validator module into a single point of failure for connection
+    authorization. Use with caution.
+   </para>
+  </sect2>
+ </sect1>
+
+ <sect1 id="oauth-validator-init">
+  <title>Initialization Functions</title>
+  <indexterm zone="oauth-validator-init">
+   <primary>_PG_oauth_validator_module_init</primary>
+  </indexterm>
+  <para>
+   OAuth validator modules are dynamically loaded from the shared
+   libraries listed in <xref linkend="guc-oauth-validator-libraries"/>.
+   Modules are loaded on demand when requested from a login in progress.
+   The normal library search path is used to locate the library. To
+   provide the validator callbacks and to indicate that the library is an OAuth
+   validator module a function named
+   <function>_PG_oauth_validator_module_init</function> must be provided. The
+   return value of the function must be a pointer to a struct of type
+   <structname>OAuthValidatorCallbacks</structname>, which contains a magic
+   number and pointers to the module's token validation functions. The returned
+   pointer must be of server lifetime, which is typically achieved by defining
+   it as a <literal>static const</literal> variable in global scope.
+<programlisting>
+typedef struct OAuthValidatorCallbacks
+{
+    uint32        magic;            /* must be set to PG_OAUTH_VALIDATOR_MAGIC */
+
+    ValidatorStartupCB startup_cb;
+    ValidatorShutdownCB shutdown_cb;
+    ValidatorValidateCB validate_cb;
+} OAuthValidatorCallbacks;
+
+typedef const OAuthValidatorCallbacks *(*OAuthValidatorModuleInit) (void);
+</programlisting>
+
+   Only the <function>validate_cb</function> callback is required, the others
+   are optional.
+  </para>
+ </sect1>
+
+ <sect1 id="oauth-validator-callbacks">
+  <title>OAuth Validator Callbacks</title>
+  <para>
+   OAuth validator modules implement their functionality by defining a set of
+   callbacks. The server will call them as required to process the
+   authentication request from the user.
+  </para>
+
+  <sect2 id="oauth-validator-callback-startup">
+   <title>Startup Callback</title>
+   <para>
+    The <function>startup_cb</function> callback is executed directly after
+    loading the module. This callback can be used to set up local state and
+    perform additional initialization if required. If the validator module
+    has state it can use <structfield>state->private_data</structfield> to
+    store it.
+
+<programlisting>
+typedef void (*ValidatorStartupCB) (ValidatorModuleState *state);
+</programlisting>
+   </para>
+  </sect2>
+
+  <sect2 id="oauth-validator-callback-validate">
+   <title>Validate Callback</title>
+   <para>
+    The <function>validate_cb</function> callback is executed during the OAuth
+    exchange when a user attempts to authenticate using OAuth.  Any state set in
+    previous calls will be available in <structfield>state->private_data</structfield>.
+
+<programlisting>
+typedef bool (*ValidatorValidateCB) (const ValidatorModuleState *state,
+                                     const char *token, const char *role,
+                                     ValidatorModuleResult *result);
+</programlisting>
+
+    <replaceable>token</replaceable> will contain the bearer token to validate.
+    <application>PostgreSQL</application> has ensured that the token is well-formed syntactically, but no
+    other validation has been performed.  <replaceable>role</replaceable> will
+    contain the role the user has requested to log in as.  The callback must
+    set output parameters in the <literal>result</literal> struct, which is
+    defined as below:
+
+<programlisting>
+typedef struct ValidatorModuleResult
+{
+    bool        authorized;
+    char       *authn_id;
+} ValidatorModuleResult;
+</programlisting>
+
+    The connection will only proceed if the module sets
+    <structfield>result->authorized</structfield> to <literal>true</literal>.  To
+    authenticate the user, the authenticated user name (as determined using the
+    token) shall be palloc'd and returned in the <structfield>result->authn_id</structfield>
+    field.  Alternatively, <structfield>result->authn_id</structfield> may be set to
+    NULL if the token is valid but the associated user identity cannot be
+    determined.
+   </para>
+   <para>
+    A validator may return <literal>false</literal> to signal an internal error,
+    in which case any result parameters are ignored and the connection fails.
+    Otherwise the validator should return <literal>true</literal> to indicate
+    that it has processed the token and made an authorization decision.
+   </para>
+   <para>
+    The behavior after <function>validate_cb</function> returns depends on the
+    specific HBA setup.  Normally, the <structfield>result->authn_id</structfield> user
+    name must exactly match the role that the user is logging in as.  (This
+    behavior may be modified with a usermap.)  But when authenticating against
+    an HBA rule with <literal>delegate_ident_mapping</literal> turned on,
+    <productname>PostgreSQL</productname> will not perform any checks on the value of
+    <structfield>result->authn_id</structfield> at all; in this case it is up to the
+    validator to ensure that the token carries enough privileges for the user to
+    log in under the indicated <replaceable>role</replaceable>.
+   </para>
+  </sect2>
+
+  <sect2 id="oauth-validator-callback-shutdown">
+   <title>Shutdown Callback</title>
+   <para>
+    The <function>shutdown_cb</function> callback is executed when the backend
+    process associated with the connection exits. If the validator module has
+    any allocated state, this callback should free it to avoid resource leaks.
+<programlisting>
+typedef void (*ValidatorShutdownCB) (ValidatorModuleState *state);
+</programlisting>
+   </para>
+  </sect2>
+
+ </sect1>
+</chapter>
diff --git a/doc/src/sgml/postgres.sgml b/doc/src/sgml/postgres.sgml
index 7be25c58507..af476c82fcc 100644
--- a/doc/src/sgml/postgres.sgml
+++ b/doc/src/sgml/postgres.sgml
@@ -229,6 +229,7 @@ break is not needed in a wider output rendering.
   &logicaldecoding;
   &replication-origins;
   &archive-modules;
+  &oauth-validators;
 
  </part>
 
diff --git a/doc/src/sgml/protocol.sgml b/doc/src/sgml/protocol.sgml
index fb5dec1172e..3bd9e68e6ce 100644
--- a/doc/src/sgml/protocol.sgml
+++ b/doc/src/sgml/protocol.sgml
@@ -1688,11 +1688,11 @@ SELCT 1/0;<!-- this typo is intentional -->
 
   <para>
    <firstterm>SASL</firstterm> is a framework for authentication in connection-oriented
-   protocols. At the moment, <productname>PostgreSQL</productname> implements two SASL
-   authentication mechanisms, SCRAM-SHA-256 and SCRAM-SHA-256-PLUS. More
-   might be added in the future. The below steps illustrate how SASL
-   authentication is performed in general, while the next subsection gives
-   more details on SCRAM-SHA-256 and SCRAM-SHA-256-PLUS.
+   protocols. At the moment, <productname>PostgreSQL</productname> implements three
+   SASL authentication mechanisms: SCRAM-SHA-256, SCRAM-SHA-256-PLUS, and
+   OAUTHBEARER. More might be added in the future. The below steps illustrate how SASL
+   authentication is performed in general, while the next subsections give
+   more details on particular mechanisms.
   </para>
 
   <procedure>
@@ -1727,7 +1727,7 @@ SELCT 1/0;<!-- this typo is intentional -->
    <step id="sasl-auth-end">
     <para>
      Finally, when the authentication exchange is completed successfully, the
-     server sends an AuthenticationSASLFinal message, followed
+     server sends an optional AuthenticationSASLFinal message, followed
      immediately by an AuthenticationOk message. The AuthenticationSASLFinal
      contains additional server-to-client data, whose content is particular to the
      selected authentication mechanism. If the authentication mechanism doesn't
@@ -1746,9 +1746,9 @@ SELCT 1/0;<!-- this typo is intentional -->
    <title>SCRAM-SHA-256 Authentication</title>
 
    <para>
-    The implemented SASL mechanisms at the moment
-    are <literal>SCRAM-SHA-256</literal> and its variant with channel
-    binding <literal>SCRAM-SHA-256-PLUS</literal>. They are described in
+    <literal>SCRAM-SHA-256</literal>, and its variant with channel
+    binding <literal>SCRAM-SHA-256-PLUS</literal>, are password-based
+    authentication mechanisms. They are described in
     detail in <ulink url="https://datatracker.ietf.org/doc/html/rfc7677">RFC 7677</ulink>
     and <ulink url="https://datatracker.ietf.org/doc/html/rfc5802">RFC 5802</ulink>.
    </para>
@@ -1850,6 +1850,121 @@ SELCT 1/0;<!-- this typo is intentional -->
     </step>
    </procedure>
   </sect2>
+
+  <sect2 id="sasl-oauthbearer">
+   <title>OAUTHBEARER Authentication</title>
+
+   <para>
+    <literal>OAUTHBEARER</literal> is a token-based mechanism for federated
+    authentication. It is described in detail in
+    <ulink url="https://datatracker.ietf.org/doc/html/rfc7628">RFC 7628</ulink>.
+   </para>
+
+   <para>
+    A typical exchange differs depending on whether or not the client already
+    has a bearer token cached for the current user. If it does not, the exchange
+    will take place over two connections: the first "discovery" connection to
+    obtain OAuth metadata from the server, and the second connection to send
+    the token after the client has obtained it. (libpq does not currently
+    implement a caching method as part of its builtin flow, so it uses the
+    two-connection exchange.)
+   </para>
+
+   <para>
+    This mechanism is client-initiated, like SCRAM. The client initial response
+    consists of the standard "GS2" header used by SCRAM, followed by a list of
+    <literal>key=value</literal> pairs. The only key currently supported by
+    the server is <literal>auth</literal>, which contains the bearer token.
+    <literal>OAUTHBEARER</literal> additionally specifies three optional
+    components of the client initial response (the <literal>authzid</literal> of
+    the GS2 header, and the <structfield>host</structfield> and
+    <structfield>port</structfield> keys) which are currently ignored by the
+    server.
+   </para>
+
+   <para>
+    <literal>OAUTHBEARER</literal> does not support channel binding, and there
+    is no "OAUTHBEARER-PLUS" mechanism. This mechanism does not make use of
+    server data during a successful authentication, so the
+    AuthenticationSASLFinal message is not used in the exchange.
+   </para>
+
+   <procedure>
+    <title>Example</title>
+    <step>
+     <para>
+      During the first exchange, the server sends an AuthenticationSASL message
+      with the <literal>OAUTHBEARER</literal> mechanism advertised.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      The client responds by sending a SASLInitialResponse message which
+      indicates the <literal>OAUTHBEARER</literal> mechanism. Assuming the
+      client does not already have a valid bearer token for the current user,
+      the <structfield>auth</structfield> field is empty, indicating a discovery
+      connection.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      Server sends an AuthenticationSASLContinue message containing an error
+      <literal>status</literal> alongside a well-known URI and scopes that the
+      client should use to conduct an OAuth flow.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      Client sends a SASLResponse message containing the empty set (a single
+      <literal>0x01</literal> byte) to finish its half of the discovery
+      exchange.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      Server sends an ErrorMessage to fail the first exchange.
+     </para>
+     <para>
+      At this point, the client conducts one of many possible OAuth flows to
+      obtain a bearer token, using any metadata that it has been configured with
+      in addition to that provided by the server. (This description is left
+      deliberately vague; <literal>OAUTHBEARER</literal> does not specify or
+      mandate any particular method for obtaining a token.)
+     </para>
+     <para>
+      Once it has a token, the client reconnects to the server for the final
+      exchange:
+     </para>
+    </step>
+
+    <step>
+     <para>
+      The server once again sends an AuthenticationSASL message with the
+      <literal>OAUTHBEARER</literal> mechanism advertised.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      The client responds by sending a SASLInitialResponse message, but this
+      time the <structfield>auth</structfield> field in the message contains the
+      bearer token that was obtained during the client flow.
+     </para>
+    </step>
+
+    <step>
+     <para>
+      The server validates the token according to the instructions of the
+      token provider. If the client is authorized to connect, it sends an
+      AuthenticationOk message to end the SASL exchange.
+     </para>
+    </step>
+   </procedure>
+  </sect2>
  </sect1>
 
  <sect1 id="protocol-replication">
diff --git a/doc/src/sgml/regress.sgml b/doc/src/sgml/regress.sgml
index 7c474559bdf..0e5e8e8f309 100644
--- a/doc/src/sgml/regress.sgml
+++ b/doc/src/sgml/regress.sgml
@@ -347,6 +347,16 @@ make check-world PG_TEST_EXTRA='kerberos ldap ssl load_balance libpq_encryption'
       </para>
      </listitem>
     </varlistentry>
+
+    <varlistentry>
+     <term><literal>oauth</literal></term>
+     <listitem>
+      <para>
+       Runs the test suite under <filename>src/test/modules/oauth_validator</filename>.
+       This opens TCP/IP listen sockets for a test-server running HTTPS.
+      </para>
+     </listitem>
+    </varlistentry>
    </variablelist>
 
    Tests for features that are not supported by the current build
diff --git a/meson.build b/meson.build
index 7dd7110318d..574f992ed49 100644
--- a/meson.build
+++ b/meson.build
@@ -855,6 +855,101 @@ endif
 
 
 
+###############################################################
+# Library: libcurl
+###############################################################
+
+libcurlopt = get_option('libcurl')
+if not libcurlopt.disabled()
+  # Check for libcurl 7.61.0 or higher (corresponding to RHEL8 and the ability
+  # to explicitly set TLS 1.3 ciphersuites).
+  libcurl = dependency('libcurl', version: '>= 7.61.0', required: libcurlopt)
+  if libcurl.found()
+    cdata.set('USE_LIBCURL', 1)
+
+    # Check to see whether the current platform supports thread-safe Curl
+    # initialization.
+    libcurl_threadsafe_init = false
+
+    if not meson.is_cross_build()
+      r = cc.run('''
+        #include <curl/curl.h>
+
+        int main(void)
+        {
+            curl_version_info_data *info;
+
+            if (curl_global_init(CURL_GLOBAL_ALL))
+                return -1;
+
+            info = curl_version_info(CURLVERSION_NOW);
+        #ifdef CURL_VERSION_THREADSAFE
+            if (info->features & CURL_VERSION_THREADSAFE)
+                return 0;
+        #endif
+
+            return 1;
+        }''',
+        name: 'test for curl_global_init thread safety',
+        dependencies: libcurl,
+      )
+
+      assert(r.compiled())
+      if r.returncode() == 0
+        libcurl_threadsafe_init = true
+        message('curl_global_init is thread-safe')
+      elif r.returncode() == 1
+        message('curl_global_init is not thread-safe')
+      else
+        message('curl_global_init failed; assuming not thread-safe')
+      endif
+    endif
+
+    if libcurl_threadsafe_init
+      cdata.set('HAVE_THREADSAFE_CURL_GLOBAL_INIT', 1)
+    endif
+
+    # Warn if a thread-friendly DNS resolver isn't built.
+    libcurl_async_dns = false
+
+    if not meson.is_cross_build()
+      r = cc.run('''
+        #include <curl/curl.h>
+
+        int main(void)
+        {
+            curl_version_info_data *info;
+
+            if (curl_global_init(CURL_GLOBAL_ALL))
+                return -1;
+
+            info = curl_version_info(CURLVERSION_NOW);
+            return (info->features & CURL_VERSION_ASYNCHDNS) ? 0 : 1;
+        }''',
+        name: 'test for curl support for asynchronous DNS',
+        dependencies: libcurl,
+      )
+
+      assert(r.compiled())
+      if r.returncode() == 0
+        libcurl_async_dns = true
+      endif
+    endif
+
+    if not libcurl_async_dns
+      warning('''
+*** The installed version of libcurl does not support asynchronous DNS
+*** lookups. Connection timeouts will not be honored during DNS resolution,
+*** which may lead to hangs in client programs.''')
+    endif
+  endif
+
+else
+  libcurl = not_found_dep
+endif
+
+
+
 ###############################################################
 # Library: libxml
 ###############################################################
@@ -3045,6 +3140,10 @@ libpq_deps += [
 
   gssapi,
   ldap_r,
+  # XXX libcurl must link after libgssapi_krb5 on FreeBSD to avoid segfaults
+  # during gss_acquire_cred(). This is possibly related to Curl's Heimdal
+  # dependency on that platform?
+  libcurl,
   libintl,
   ssl,
 ]
@@ -3721,6 +3820,7 @@ if meson.version().version_compare('>=0.57')
       'gss': gssapi,
       'icu': icu,
       'ldap': ldap,
+      'libcurl': libcurl,
       'libxml': libxml,
       'libxslt': libxslt,
       'llvm': llvm,
diff --git a/meson_options.txt b/meson_options.txt
index d9c7ddccbc4..702c4517145 100644
--- a/meson_options.txt
+++ b/meson_options.txt
@@ -100,6 +100,9 @@ option('icu', type: 'feature', value: 'auto',
 option('ldap', type: 'feature', value: 'auto',
   description: 'LDAP support')
 
+option('libcurl', type : 'feature', value: 'auto',
+  description: 'libcurl support')
+
 option('libedit_preferred', type: 'boolean', value: false,
   description: 'Prefer BSD Libedit over GNU Readline')
 
diff --git a/src/Makefile.global.in b/src/Makefile.global.in
index bbe11e75bf0..3b620bac5ac 100644
--- a/src/Makefile.global.in
+++ b/src/Makefile.global.in
@@ -190,6 +190,7 @@ with_systemd	= @with_systemd@
 with_gssapi	= @with_gssapi@
 with_krb_srvnam	= @with_krb_srvnam@
 with_ldap	= @with_ldap@
+with_libcurl	= @with_libcurl@
 with_libxml	= @with_libxml@
 with_libxslt	= @with_libxslt@
 with_llvm	= @with_llvm@
diff --git a/src/backend/libpq/Makefile b/src/backend/libpq/Makefile
index 6d385fd6a45..98eb2a8242d 100644
--- a/src/backend/libpq/Makefile
+++ b/src/backend/libpq/Makefile
@@ -15,6 +15,7 @@ include $(top_builddir)/src/Makefile.global
 # be-fsstubs is here for historical reasons, probably belongs elsewhere
 
 OBJS = \
+	auth-oauth.o \
 	auth-sasl.o \
 	auth-scram.o \
 	auth.o \
diff --git a/src/backend/libpq/auth-oauth.c b/src/backend/libpq/auth-oauth.c
new file mode 100644
index 00000000000..27f7af7be00
--- /dev/null
+++ b/src/backend/libpq/auth-oauth.c
@@ -0,0 +1,894 @@
+/*-------------------------------------------------------------------------
+ *
+ * auth-oauth.c
+ *	  Server-side implementation of the SASL OAUTHBEARER mechanism.
+ *
+ * See the following RFC for more details:
+ * - RFC 7628: https://datatracker.ietf.org/doc/html/rfc7628
+ *
+ * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/backend/libpq/auth-oauth.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include <unistd.h>
+#include <fcntl.h>
+
+#include "common/oauth-common.h"
+#include "fmgr.h"
+#include "lib/stringinfo.h"
+#include "libpq/auth.h"
+#include "libpq/hba.h"
+#include "libpq/oauth.h"
+#include "libpq/sasl.h"
+#include "storage/fd.h"
+#include "storage/ipc.h"
+#include "utils/json.h"
+#include "utils/varlena.h"
+
+/* GUC */
+char	   *oauth_validator_libraries_string = NULL;
+
+static void oauth_get_mechanisms(Port *port, StringInfo buf);
+static void *oauth_init(Port *port, const char *selected_mech, const char *shadow_pass);
+static int	oauth_exchange(void *opaq, const char *input, int inputlen,
+						   char **output, int *outputlen, const char **logdetail);
+
+static void load_validator_library(const char *libname);
+static void shutdown_validator_library(void *arg);
+
+static ValidatorModuleState *validator_module_state;
+static const OAuthValidatorCallbacks *ValidatorCallbacks;
+
+/* Mechanism declaration */
+const pg_be_sasl_mech pg_be_oauth_mech = {
+	.get_mechanisms = oauth_get_mechanisms,
+	.init = oauth_init,
+	.exchange = oauth_exchange,
+
+	.max_message_length = PG_MAX_AUTH_TOKEN_LENGTH,
+};
+
+/* Valid states for the oauth_exchange() machine. */
+enum oauth_state
+{
+	OAUTH_STATE_INIT = 0,
+	OAUTH_STATE_ERROR,
+	OAUTH_STATE_FINISHED,
+};
+
+/* Mechanism callback state. */
+struct oauth_ctx
+{
+	enum oauth_state state;
+	Port	   *port;
+	const char *issuer;
+	const char *scope;
+};
+
+static char *sanitize_char(char c);
+static char *parse_kvpairs_for_auth(char **input);
+static void generate_error_response(struct oauth_ctx *ctx, char **output, int *outputlen);
+static bool validate(Port *port, const char *auth);
+
+/* Constants seen in an OAUTHBEARER client initial response. */
+#define KVSEP 0x01				/* separator byte for key/value pairs */
+#define AUTH_KEY "auth"			/* key containing the Authorization header */
+#define BEARER_SCHEME "Bearer " /* required header scheme (case-insensitive!) */
+
+/*
+ * Retrieves the OAUTHBEARER mechanism list (currently a single item).
+ *
+ * For a full description of the API, see libpq/sasl.h.
+ */
+static void
+oauth_get_mechanisms(Port *port, StringInfo buf)
+{
+	/* Only OAUTHBEARER is supported. */
+	appendStringInfoString(buf, OAUTHBEARER_NAME);
+	appendStringInfoChar(buf, '\0');
+}
+
+/*
+ * Initializes mechanism state and loads the configured validator module.
+ *
+ * For a full description of the API, see libpq/sasl.h.
+ */
+static void *
+oauth_init(Port *port, const char *selected_mech, const char *shadow_pass)
+{
+	struct oauth_ctx *ctx;
+
+	if (strcmp(selected_mech, OAUTHBEARER_NAME) != 0)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("client selected an invalid SASL authentication mechanism"));
+
+	ctx = palloc0(sizeof(*ctx));
+
+	ctx->state = OAUTH_STATE_INIT;
+	ctx->port = port;
+
+	Assert(port->hba);
+	ctx->issuer = port->hba->oauth_issuer;
+	ctx->scope = port->hba->oauth_scope;
+
+	load_validator_library(port->hba->oauth_validator);
+
+	return ctx;
+}
+
+/*
+ * Implements the OAUTHBEARER SASL exchange (RFC 7628, Sec. 3.2). This pulls
+ * apart the client initial response and validates the Bearer token. It also
+ * handles the dummy error response for a failed handshake, as described in
+ * Sec. 3.2.3.
+ *
+ * For a full description of the API, see libpq/sasl.h.
+ */
+static int
+oauth_exchange(void *opaq, const char *input, int inputlen,
+			   char **output, int *outputlen, const char **logdetail)
+{
+	char	   *input_copy;
+	char	   *p;
+	char		cbind_flag;
+	char	   *auth;
+	int			status;
+
+	struct oauth_ctx *ctx = opaq;
+
+	*output = NULL;
+	*outputlen = -1;
+
+	/*
+	 * If the client didn't include an "Initial Client Response" in the
+	 * SASLInitialResponse message, send an empty challenge, to which the
+	 * client will respond with the same data that usually comes in the
+	 * Initial Client Response.
+	 */
+	if (input == NULL)
+	{
+		Assert(ctx->state == OAUTH_STATE_INIT);
+
+		*output = pstrdup("");
+		*outputlen = 0;
+		return PG_SASL_EXCHANGE_CONTINUE;
+	}
+
+	/*
+	 * Check that the input length agrees with the string length of the input.
+	 */
+	if (inputlen == 0)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("The message is empty."));
+	if (inputlen != strlen(input))
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message length does not match input length."));
+
+	switch (ctx->state)
+	{
+		case OAUTH_STATE_INIT:
+			/* Handle this case below. */
+			break;
+
+		case OAUTH_STATE_ERROR:
+
+			/*
+			 * Only one response is valid for the client during authentication
+			 * failure: a single kvsep.
+			 */
+			if (inputlen != 1 || *input != KVSEP)
+				ereport(ERROR,
+						errcode(ERRCODE_PROTOCOL_VIOLATION),
+						errmsg("malformed OAUTHBEARER message"),
+						errdetail("Client did not send a kvsep response."));
+
+			/* The (failed) handshake is now complete. */
+			ctx->state = OAUTH_STATE_FINISHED;
+			return PG_SASL_EXCHANGE_FAILURE;
+
+		default:
+			elog(ERROR, "invalid OAUTHBEARER exchange state");
+			return PG_SASL_EXCHANGE_FAILURE;
+	}
+
+	/* Handle the client's initial message. */
+	p = input_copy = pstrdup(input);
+
+	/*
+	 * OAUTHBEARER does not currently define a channel binding (so there is no
+	 * OAUTHBEARER-PLUS, and we do not accept a 'p' specifier). We accept a
+	 * 'y' specifier purely for the remote chance that a future specification
+	 * could define one; then future clients can still interoperate with this
+	 * server implementation. 'n' is the expected case.
+	 */
+	cbind_flag = *p;
+	switch (cbind_flag)
+	{
+		case 'p':
+			ereport(ERROR,
+					errcode(ERRCODE_PROTOCOL_VIOLATION),
+					errmsg("malformed OAUTHBEARER message"),
+					errdetail("The server does not support channel binding for OAuth, but the client message includes channel binding data."));
+			break;
+
+		case 'y':				/* fall through */
+		case 'n':
+			p++;
+			if (*p != ',')
+				ereport(ERROR,
+						errcode(ERRCODE_PROTOCOL_VIOLATION),
+						errmsg("malformed OAUTHBEARER message"),
+						errdetail("Comma expected, but found character \"%s\".",
+								  sanitize_char(*p)));
+			p++;
+			break;
+
+		default:
+			ereport(ERROR,
+					errcode(ERRCODE_PROTOCOL_VIOLATION),
+					errmsg("malformed OAUTHBEARER message"),
+					errdetail("Unexpected channel-binding flag \"%s\".",
+							  sanitize_char(cbind_flag)));
+	}
+
+	/*
+	 * Forbid optional authzid (authorization identity).  We don't support it.
+	 */
+	if (*p == 'a')
+		ereport(ERROR,
+				errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				errmsg("client uses authorization identity, but it is not supported"));
+	if (*p != ',')
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Unexpected attribute \"%s\" in client-first-message.",
+						  sanitize_char(*p)));
+	p++;
+
+	/* All remaining fields are separated by the RFC's kvsep (\x01). */
+	if (*p != KVSEP)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Key-value separator expected, but found character \"%s\".",
+						  sanitize_char(*p)));
+	p++;
+
+	auth = parse_kvpairs_for_auth(&p);
+	if (!auth)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message does not contain an auth value."));
+
+	/* We should be at the end of our message. */
+	if (*p)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message contains additional data after the final terminator."));
+
+	if (!validate(ctx->port, auth))
+	{
+		generate_error_response(ctx, output, outputlen);
+
+		ctx->state = OAUTH_STATE_ERROR;
+		status = PG_SASL_EXCHANGE_CONTINUE;
+	}
+	else
+	{
+		ctx->state = OAUTH_STATE_FINISHED;
+		status = PG_SASL_EXCHANGE_SUCCESS;
+	}
+
+	/* Don't let extra copies of the bearer token hang around. */
+	explicit_bzero(input_copy, inputlen);
+
+	return status;
+}
+
+/*
+ * Convert an arbitrary byte to printable form.  For error messages.
+ *
+ * If it's a printable ASCII character, print it as a single character.
+ * otherwise, print it in hex.
+ *
+ * The returned pointer points to a static buffer.
+ */
+static char *
+sanitize_char(char c)
+{
+	static char buf[5];
+
+	if (c >= 0x21 && c <= 0x7E)
+		snprintf(buf, sizeof(buf), "'%c'", c);
+	else
+		snprintf(buf, sizeof(buf), "0x%02x", (unsigned char) c);
+	return buf;
+}
+
+/*
+ * Performs syntactic validation of a key and value from the initial client
+ * response. (Semantic validation of interesting values must be performed
+ * later.)
+ */
+static void
+validate_kvpair(const char *key, const char *val)
+{
+	/*-----
+	 * From Sec 3.1:
+	 *     key            = 1*(ALPHA)
+	 */
+	static const char *key_allowed_set =
+		"abcdefghijklmnopqrstuvwxyz"
+		"ABCDEFGHIJKLMNOPQRSTUVWXYZ";
+
+	size_t		span;
+
+	if (!key[0])
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message contains an empty key name."));
+
+	span = strspn(key, key_allowed_set);
+	if (key[span] != '\0')
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message contains an invalid key name."));
+
+	/*-----
+	 * From Sec 3.1:
+	 *     value          = *(VCHAR / SP / HTAB / CR / LF )
+	 *
+	 * The VCHAR (visible character) class is large; a loop is more
+	 * straightforward than strspn().
+	 */
+	for (; *val; ++val)
+	{
+		if (0x21 <= *val && *val <= 0x7E)
+			continue;			/* VCHAR */
+
+		switch (*val)
+		{
+			case ' ':
+			case '\t':
+			case '\r':
+			case '\n':
+				continue;		/* SP, HTAB, CR, LF */
+
+			default:
+				ereport(ERROR,
+						errcode(ERRCODE_PROTOCOL_VIOLATION),
+						errmsg("malformed OAUTHBEARER message"),
+						errdetail("Message contains an invalid value."));
+		}
+	}
+}
+
+/*
+ * Consumes all kvpairs in an OAUTHBEARER exchange message. If the "auth" key is
+ * found, its value is returned.
+ */
+static char *
+parse_kvpairs_for_auth(char **input)
+{
+	char	   *pos = *input;
+	char	   *auth = NULL;
+
+	/*----
+	 * The relevant ABNF, from Sec. 3.1:
+	 *
+	 *     kvsep          = %x01
+	 *     key            = 1*(ALPHA)
+	 *     value          = *(VCHAR / SP / HTAB / CR / LF )
+	 *     kvpair         = key "=" value kvsep
+	 *   ;;gs2-header     = See RFC 5801
+	 *     client-resp    = (gs2-header kvsep *kvpair kvsep) / kvsep
+	 *
+	 * By the time we reach this code, the gs2-header and initial kvsep have
+	 * already been validated. We start at the beginning of the first kvpair.
+	 */
+
+	while (*pos)
+	{
+		char	   *end;
+		char	   *sep;
+		char	   *key;
+		char	   *value;
+
+		/*
+		 * Find the end of this kvpair. Note that input is null-terminated by
+		 * the SASL code, so the strchr() is bounded.
+		 */
+		end = strchr(pos, KVSEP);
+		if (!end)
+			ereport(ERROR,
+					errcode(ERRCODE_PROTOCOL_VIOLATION),
+					errmsg("malformed OAUTHBEARER message"),
+					errdetail("Message contains an unterminated key/value pair."));
+		*end = '\0';
+
+		if (pos == end)
+		{
+			/* Empty kvpair, signifying the end of the list. */
+			*input = pos + 1;
+			return auth;
+		}
+
+		/*
+		 * Find the end of the key name.
+		 */
+		sep = strchr(pos, '=');
+		if (!sep)
+			ereport(ERROR,
+					errcode(ERRCODE_PROTOCOL_VIOLATION),
+					errmsg("malformed OAUTHBEARER message"),
+					errdetail("Message contains a key without a value."));
+		*sep = '\0';
+
+		/* Both key and value are now safely terminated. */
+		key = pos;
+		value = sep + 1;
+		validate_kvpair(key, value);
+
+		if (strcmp(key, AUTH_KEY) == 0)
+		{
+			if (auth)
+				ereport(ERROR,
+						errcode(ERRCODE_PROTOCOL_VIOLATION),
+						errmsg("malformed OAUTHBEARER message"),
+						errdetail("Message contains multiple auth values."));
+
+			auth = value;
+		}
+		else
+		{
+			/*
+			 * The RFC also defines the host and port keys, but they are not
+			 * required for OAUTHBEARER and we do not use them. Also, per Sec.
+			 * 3.1, any key/value pairs we don't recognize must be ignored.
+			 */
+		}
+
+		/* Move to the next pair. */
+		pos = end + 1;
+	}
+
+	ereport(ERROR,
+			errcode(ERRCODE_PROTOCOL_VIOLATION),
+			errmsg("malformed OAUTHBEARER message"),
+			errdetail("Message did not contain a final terminator."));
+
+	pg_unreachable();
+	return NULL;
+}
+
+/*
+ * Builds the JSON response for failed authentication (RFC 7628, Sec. 3.2.2).
+ * This contains the required scopes for entry and a pointer to the OAuth/OpenID
+ * discovery document, which the client may use to conduct its OAuth flow.
+ */
+static void
+generate_error_response(struct oauth_ctx *ctx, char **output, int *outputlen)
+{
+	StringInfoData buf;
+	StringInfoData issuer;
+
+	/*
+	 * The admin needs to set an issuer and scope for OAuth to work. There's
+	 * not really a way to hide this from the user, either, because we can't
+	 * choose a "default" issuer, so be honest in the failure message. (In
+	 * practice such configurations are rejected during HBA parsing.)
+	 */
+	if (!ctx->issuer || !ctx->scope)
+		ereport(FATAL,
+				errcode(ERRCODE_INTERNAL_ERROR),
+				errmsg("OAuth is not properly configured for this user"),
+				errdetail_log("The issuer and scope parameters must be set in pg_hba.conf."));
+
+	/*
+	 * Build a default .well-known URI based on our issuer, unless the HBA has
+	 * already provided one.
+	 */
+	initStringInfo(&issuer);
+	appendStringInfoString(&issuer, ctx->issuer);
+	if (strstr(ctx->issuer, "/.well-known/") == NULL)
+		appendStringInfoString(&issuer, "/.well-known/openid-configuration");
+
+	initStringInfo(&buf);
+
+	/*
+	 * Escaping the string here is belt-and-suspenders defensive programming
+	 * since escapable characters aren't valid in either the issuer URI or the
+	 * scope list, but the HBA doesn't enforce that yet.
+	 */
+	appendStringInfoString(&buf, "{ \"status\": \"invalid_token\", ");
+
+	appendStringInfoString(&buf, "\"openid-configuration\": ");
+	escape_json(&buf, issuer.data);
+	pfree(issuer.data);
+
+	appendStringInfoString(&buf, ", \"scope\": ");
+	escape_json(&buf, ctx->scope);
+
+	appendStringInfoString(&buf, " }");
+
+	*output = buf.data;
+	*outputlen = buf.len;
+}
+
+/*-----
+ * Validates the provided Authorization header and returns the token from
+ * within it. NULL is returned on validation failure.
+ *
+ * Only Bearer tokens are accepted. The ABNF is defined in RFC 6750, Sec.
+ * 2.1:
+ *
+ *      b64token    = 1*( ALPHA / DIGIT /
+ *                        "-" / "." / "_" / "~" / "+" / "/" ) *"="
+ *      credentials = "Bearer" 1*SP b64token
+ *
+ * The "credentials" construction is what we receive in our auth value.
+ *
+ * Since that spec is subordinate to HTTP (i.e. the HTTP Authorization
+ * header format; RFC 9110 Sec. 11), the "Bearer" scheme string must be
+ * compared case-insensitively. (This is not mentioned in RFC 6750, but the
+ * OAUTHBEARER spec points it out: RFC 7628 Sec. 4.)
+ *
+ * Invalid formats are technically a protocol violation, but we shouldn't
+ * reflect any information about the sensitive Bearer token back to the
+ * client; log at COMMERROR instead.
+ */
+static const char *
+validate_token_format(const char *header)
+{
+	size_t		span;
+	const char *token;
+	static const char *const b64token_allowed_set =
+		"abcdefghijklmnopqrstuvwxyz"
+		"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+		"0123456789-._~+/";
+
+	/* Missing auth headers should be handled by the caller. */
+	Assert(header);
+
+	if (header[0] == '\0')
+	{
+		/*
+		 * A completely empty auth header represents a query for
+		 * authentication parameters. The client expects it to fail; there's
+		 * no need to make any extra noise in the logs.
+		 *
+		 * TODO: should we find a way to return STATUS_EOF at the top level,
+		 * to suppress the authentication error entirely?
+		 */
+		return NULL;
+	}
+
+	if (pg_strncasecmp(header, BEARER_SCHEME, strlen(BEARER_SCHEME)))
+	{
+		ereport(COMMERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAuth bearer token"),
+				errdetail_log("Client response indicated a non-Bearer authentication scheme."));
+		return NULL;
+	}
+
+	/* Pull the bearer token out of the auth value. */
+	token = header + strlen(BEARER_SCHEME);
+
+	/* Swallow any additional spaces. */
+	while (*token == ' ')
+		token++;
+
+	/* Tokens must not be empty. */
+	if (!*token)
+	{
+		ereport(COMMERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAuth bearer token"),
+				errdetail_log("Bearer token is empty."));
+		return NULL;
+	}
+
+	/*
+	 * Make sure the token contains only allowed characters. Tokens may end
+	 * with any number of '=' characters.
+	 */
+	span = strspn(token, b64token_allowed_set);
+	while (token[span] == '=')
+		span++;
+
+	if (token[span] != '\0')
+	{
+		/*
+		 * This error message could be more helpful by printing the
+		 * problematic character(s), but that'd be a bit like printing a piece
+		 * of someone's password into the logs.
+		 */
+		ereport(COMMERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAuth bearer token"),
+				errdetail_log("Bearer token is not in the correct format."));
+		return NULL;
+	}
+
+	return token;
+}
+
+/*
+ * Checks that the "auth" kvpair in the client response contains a syntactically
+ * valid Bearer token, then passes it along to the loaded validator module for
+ * authorization. Returns true if validation succeeds.
+ */
+static bool
+validate(Port *port, const char *auth)
+{
+	int			map_status;
+	ValidatorModuleResult *ret;
+	const char *token;
+	bool		status;
+
+	/* Ensure that we have a correct token to validate */
+	if (!(token = validate_token_format(auth)))
+		return false;
+
+	/*
+	 * Ensure that we have a validation library loaded, this should always be
+	 * the case and an error here is indicative of a bug.
+	 */
+	if (!ValidatorCallbacks || !ValidatorCallbacks->validate_cb)
+		ereport(FATAL,
+				errcode(ERRCODE_INTERNAL_ERROR),
+				errmsg("validation of OAuth token requested without a validator loaded"));
+
+	/* Call the validation function from the validator module */
+	ret = palloc0(sizeof(ValidatorModuleResult));
+	if (!ValidatorCallbacks->validate_cb(validator_module_state, token,
+										 port->user_name, ret))
+	{
+		ereport(WARNING,
+				errcode(ERRCODE_INTERNAL_ERROR),
+				errmsg("internal error in OAuth validator module"));
+		return false;
+	}
+
+	/*
+	 * Log any authentication results even if the token isn't authorized; it
+	 * might be useful for auditing or troubleshooting.
+	 */
+	if (ret->authn_id)
+		set_authn_id(port, ret->authn_id);
+
+	if (!ret->authorized)
+	{
+		ereport(LOG,
+				errmsg("OAuth bearer authentication failed for user \"%s\"",
+					   port->user_name),
+				errdetail_log("Validator failed to authorize the provided token."));
+
+		status = false;
+		goto cleanup;
+	}
+
+	if (port->hba->oauth_skip_usermap)
+	{
+		/*
+		 * If the validator is our authorization authority, we're done.
+		 * Authentication may or may not have been performed depending on the
+		 * validator implementation; all that matters is that the validator
+		 * says the user can log in with the target role.
+		 */
+		status = true;
+		goto cleanup;
+	}
+
+	/* Make sure the validator authenticated the user. */
+	if (ret->authn_id == NULL || ret->authn_id[0] == '\0')
+	{
+		ereport(LOG,
+				errmsg("OAuth bearer authentication failed for user \"%s\"",
+					   port->user_name),
+				errdetail_log("Validator provided no identity."));
+
+		status = false;
+		goto cleanup;
+	}
+
+	/* Finally, check the user map. */
+	map_status = check_usermap(port->hba->usermap, port->user_name,
+							   MyClientConnectionInfo.authn_id, false);
+	status = (map_status == STATUS_OK);
+
+cleanup:
+
+	/*
+	 * Clear and free the validation result from the validator module once
+	 * we're done with it.
+	 */
+	if (ret->authn_id != NULL)
+		pfree(ret->authn_id);
+	pfree(ret);
+
+	return status;
+}
+
+/*
+ * load_validator_library
+ *
+ * Load the configured validator library in order to perform token validation.
+ * There is no built-in fallback since validation is implementation specific. If
+ * no validator library is configured, or if it fails to load, then error out
+ * since token validation won't be possible.
+ */
+static void
+load_validator_library(const char *libname)
+{
+	OAuthValidatorModuleInit validator_init;
+	MemoryContextCallback *mcb;
+
+	/*
+	 * The presence, and validity, of libname has already been established by
+	 * check_oauth_validator so we don't need to perform more than Assert
+	 * level checking here.
+	 */
+	Assert(libname && *libname);
+
+	validator_init = (OAuthValidatorModuleInit)
+		load_external_function(libname, "_PG_oauth_validator_module_init",
+							   false, NULL);
+
+	/*
+	 * The validator init function is required since it will set the callbacks
+	 * for the validator library.
+	 */
+	if (validator_init == NULL)
+		ereport(ERROR,
+				errmsg("%s module \"%s\" must define the symbol %s",
+					   "OAuth validator", libname, "_PG_oauth_validator_module_init"));
+
+	ValidatorCallbacks = (*validator_init) ();
+	Assert(ValidatorCallbacks);
+
+	/*
+	 * Check the magic number, to protect against break-glass scenarios where
+	 * the ABI must change within a major version. load_external_function()
+	 * already checks for compatibility across major versions.
+	 */
+	if (ValidatorCallbacks->magic != PG_OAUTH_VALIDATOR_MAGIC)
+		ereport(ERROR,
+				errmsg("%s module \"%s\": magic number mismatch",
+					   "OAuth validator", libname),
+				errdetail("Server has magic number 0x%08X, module has 0x%08X.",
+						  PG_OAUTH_VALIDATOR_MAGIC, ValidatorCallbacks->magic));
+
+	/*
+	 * Make sure all required callbacks are present in the ValidatorCallbacks
+	 * structure. Right now only the validation callback is required.
+	 */
+	if (ValidatorCallbacks->validate_cb == NULL)
+		ereport(ERROR,
+				errmsg("%s module \"%s\" must provide a %s callback",
+					   "OAuth validator", libname, "validate_cb"));
+
+	/* Allocate memory for validator library private state data */
+	validator_module_state = (ValidatorModuleState *) palloc0(sizeof(ValidatorModuleState));
+	validator_module_state->sversion = PG_VERSION_NUM;
+
+	if (ValidatorCallbacks->startup_cb != NULL)
+		ValidatorCallbacks->startup_cb(validator_module_state);
+
+	/* Shut down the library before cleaning up its state. */
+	mcb = palloc0(sizeof(*mcb));
+	mcb->func = shutdown_validator_library;
+
+	MemoryContextRegisterResetCallback(CurrentMemoryContext, mcb);
+}
+
+/*
+ * Call the validator module's shutdown callback, if one is provided. This is
+ * invoked during memory context reset.
+ */
+static void
+shutdown_validator_library(void *arg)
+{
+	if (ValidatorCallbacks->shutdown_cb != NULL)
+		ValidatorCallbacks->shutdown_cb(validator_module_state);
+}
+
+/*
+ * Ensure an OAuth validator named in the HBA is permitted by the configuration.
+ *
+ * If the validator is currently unset and exactly one library is declared in
+ * oauth_validator_libraries, then that library will be used as the validator.
+ * Otherwise the name must be present in the list of oauth_validator_libraries.
+ */
+bool
+check_oauth_validator(HbaLine *hbaline, int elevel, char **err_msg)
+{
+	int			line_num = hbaline->linenumber;
+	const char *file_name = hbaline->sourcefile;
+	char	   *rawstring;
+	List	   *elemlist = NIL;
+
+	*err_msg = NULL;
+
+	if (oauth_validator_libraries_string[0] == '\0')
+	{
+		ereport(elevel,
+				errcode(ERRCODE_CONFIG_FILE_ERROR),
+				errmsg("oauth_validator_libraries must be set for authentication method %s",
+					   "oauth"),
+				errcontext("line %d of configuration file \"%s\"",
+						   line_num, file_name));
+		*err_msg = psprintf("oauth_validator_libraries must be set for authentication method %s",
+							"oauth");
+		return false;
+	}
+
+	/* SplitDirectoriesString needs a modifiable copy */
+	rawstring = pstrdup(oauth_validator_libraries_string);
+
+	if (!SplitDirectoriesString(rawstring, ',', &elemlist))
+	{
+		/* syntax error in list */
+		ereport(elevel,
+				errcode(ERRCODE_CONFIG_FILE_ERROR),
+				errmsg("invalid list syntax in parameter \"%s\"",
+					   "oauth_validator_libraries"));
+		*err_msg = psprintf("invalid list syntax in parameter \"%s\"",
+							"oauth_validator_libraries");
+		goto done;
+	}
+
+	if (!hbaline->oauth_validator)
+	{
+		if (elemlist->length == 1)
+		{
+			hbaline->oauth_validator = pstrdup(linitial(elemlist));
+			goto done;
+		}
+
+		ereport(elevel,
+				errcode(ERRCODE_CONFIG_FILE_ERROR),
+				errmsg("authentication method \"oauth\" requires argument \"validator\" to be set when oauth_validator_libraries contains multiple options"),
+				errcontext("line %d of configuration file \"%s\"",
+						   line_num, file_name));
+		*err_msg = "authentication method \"oauth\" requires argument \"validator\" to be set when oauth_validator_libraries contains multiple options";
+		goto done;
+	}
+
+	foreach_ptr(char, allowed, elemlist)
+	{
+		if (strcmp(allowed, hbaline->oauth_validator) == 0)
+			goto done;
+	}
+
+	ereport(elevel,
+			errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+			errmsg("validator \"%s\" is not permitted by %s",
+				   hbaline->oauth_validator, "oauth_validator_libraries"),
+			errcontext("line %d of configuration file \"%s\"",
+					   line_num, file_name));
+	*err_msg = psprintf("validator \"%s\" is not permitted by %s",
+						hbaline->oauth_validator, "oauth_validator_libraries");
+
+done:
+	list_free_deep(elemlist);
+	pfree(rawstring);
+
+	return (*err_msg == NULL);
+}
diff --git a/src/backend/libpq/auth.c b/src/backend/libpq/auth.c
index d6ef32cc823..0f65014e64f 100644
--- a/src/backend/libpq/auth.c
+++ b/src/backend/libpq/auth.c
@@ -29,6 +29,7 @@
 #include "libpq/auth.h"
 #include "libpq/crypt.h"
 #include "libpq/libpq.h"
+#include "libpq/oauth.h"
 #include "libpq/pqformat.h"
 #include "libpq/sasl.h"
 #include "libpq/scram.h"
@@ -45,7 +46,6 @@
  */
 static void auth_failed(Port *port, int status, const char *logdetail);
 static char *recv_password_packet(Port *port);
-static void set_authn_id(Port *port, const char *id);
 
 
 /*----------------------------------------------------------------
@@ -289,6 +289,9 @@ auth_failed(Port *port, int status, const char *logdetail)
 		case uaRADIUS:
 			errstr = gettext_noop("RADIUS authentication failed for user \"%s\"");
 			break;
+		case uaOAuth:
+			errstr = gettext_noop("OAuth bearer authentication failed for user \"%s\"");
+			break;
 		default:
 			errstr = gettext_noop("authentication failed for user \"%s\": invalid authentication method");
 			break;
@@ -324,7 +327,7 @@ auth_failed(Port *port, int status, const char *logdetail)
  * lifetime of MyClientConnectionInfo, so it is safe to pass a string that is
  * managed by an external library.
  */
-static void
+void
 set_authn_id(Port *port, const char *id)
 {
 	Assert(id);
@@ -611,6 +614,9 @@ ClientAuthentication(Port *port)
 		case uaTrust:
 			status = STATUS_OK;
 			break;
+		case uaOAuth:
+			status = CheckSASLAuth(&pg_be_oauth_mech, port, NULL, NULL);
+			break;
 	}
 
 	if ((status == STATUS_OK && port->hba->clientcert == clientCertFull)
diff --git a/src/backend/libpq/hba.c b/src/backend/libpq/hba.c
index 510c9ffc6d7..332fad27835 100644
--- a/src/backend/libpq/hba.c
+++ b/src/backend/libpq/hba.c
@@ -32,6 +32,7 @@
 #include "libpq/hba.h"
 #include "libpq/ifaddr.h"
 #include "libpq/libpq-be.h"
+#include "libpq/oauth.h"
 #include "postmaster/postmaster.h"
 #include "regex/regex.h"
 #include "replication/walsender.h"
@@ -114,7 +115,8 @@ static const char *const UserAuthName[] =
 	"ldap",
 	"cert",
 	"radius",
-	"peer"
+	"peer",
+	"oauth",
 };
 
 /*
@@ -1747,6 +1749,8 @@ parse_hba_line(TokenizedAuthLine *tok_line, int elevel)
 #endif
 	else if (strcmp(token->string, "radius") == 0)
 		parsedline->auth_method = uaRADIUS;
+	else if (strcmp(token->string, "oauth") == 0)
+		parsedline->auth_method = uaOAuth;
 	else
 	{
 		ereport(elevel,
@@ -2039,6 +2043,36 @@ parse_hba_line(TokenizedAuthLine *tok_line, int elevel)
 		parsedline->clientcert = clientCertFull;
 	}
 
+	/*
+	 * Enforce proper configuration of OAuth authentication.
+	 */
+	if (parsedline->auth_method == uaOAuth)
+	{
+		MANDATORY_AUTH_ARG(parsedline->oauth_scope, "scope", "oauth");
+		MANDATORY_AUTH_ARG(parsedline->oauth_issuer, "issuer", "oauth");
+
+		/* Ensure a validator library is set and permitted by the config. */
+		if (!check_oauth_validator(parsedline, elevel, err_msg))
+			return NULL;
+
+		/*
+		 * Supplying a usermap combined with the option to skip usermapping is
+		 * nonsensical and indicates a configuration error.
+		 */
+		if (parsedline->oauth_skip_usermap && parsedline->usermap != NULL)
+		{
+			ereport(elevel,
+					errcode(ERRCODE_CONFIG_FILE_ERROR),
+			/* translator: strings are replaced with hba options */
+					errmsg("%s cannot be used in combination with %s",
+						   "map", "delegate_ident_mapping"),
+					errcontext("line %d of configuration file \"%s\"",
+							   line_num, file_name));
+			*err_msg = "map cannot be used in combination with delegate_ident_mapping";
+			return NULL;
+		}
+	}
+
 	return parsedline;
 }
 
@@ -2066,8 +2100,9 @@ parse_hba_auth_opt(char *name, char *val, HbaLine *hbaline,
 			hbaline->auth_method != uaPeer &&
 			hbaline->auth_method != uaGSS &&
 			hbaline->auth_method != uaSSPI &&
-			hbaline->auth_method != uaCert)
-			INVALID_AUTH_OPTION("map", gettext_noop("ident, peer, gssapi, sspi, and cert"));
+			hbaline->auth_method != uaCert &&
+			hbaline->auth_method != uaOAuth)
+			INVALID_AUTH_OPTION("map", gettext_noop("ident, peer, gssapi, sspi, cert, and oauth"));
 		hbaline->usermap = pstrdup(val);
 	}
 	else if (strcmp(name, "clientcert") == 0)
@@ -2450,6 +2485,29 @@ parse_hba_auth_opt(char *name, char *val, HbaLine *hbaline,
 		hbaline->radiusidentifiers = parsed_identifiers;
 		hbaline->radiusidentifiers_s = pstrdup(val);
 	}
+	else if (strcmp(name, "issuer") == 0)
+	{
+		REQUIRE_AUTH_OPTION(uaOAuth, "issuer", "oauth");
+		hbaline->oauth_issuer = pstrdup(val);
+	}
+	else if (strcmp(name, "scope") == 0)
+	{
+		REQUIRE_AUTH_OPTION(uaOAuth, "scope", "oauth");
+		hbaline->oauth_scope = pstrdup(val);
+	}
+	else if (strcmp(name, "validator") == 0)
+	{
+		REQUIRE_AUTH_OPTION(uaOAuth, "validator", "oauth");
+		hbaline->oauth_validator = pstrdup(val);
+	}
+	else if (strcmp(name, "delegate_ident_mapping") == 0)
+	{
+		REQUIRE_AUTH_OPTION(uaOAuth, "delegate_ident_mapping", "oauth");
+		if (strcmp(val, "1") == 0)
+			hbaline->oauth_skip_usermap = true;
+		else
+			hbaline->oauth_skip_usermap = false;
+	}
 	else
 	{
 		ereport(elevel,
diff --git a/src/backend/libpq/meson.build b/src/backend/libpq/meson.build
index 0f0421037e4..31aa2faae1e 100644
--- a/src/backend/libpq/meson.build
+++ b/src/backend/libpq/meson.build
@@ -1,6 +1,7 @@
 # Copyright (c) 2022-2025, PostgreSQL Global Development Group
 
 backend_sources += files(
+  'auth-oauth.c',
   'auth-sasl.c',
   'auth-scram.c',
   'auth.c',
diff --git a/src/backend/libpq/pg_hba.conf.sample b/src/backend/libpq/pg_hba.conf.sample
index bad13497a34..b64c8dea97c 100644
--- a/src/backend/libpq/pg_hba.conf.sample
+++ b/src/backend/libpq/pg_hba.conf.sample
@@ -53,8 +53,8 @@
 # directly connected to.
 #
 # METHOD can be "trust", "reject", "md5", "password", "scram-sha-256",
-# "gss", "sspi", "ident", "peer", "pam", "ldap", "radius" or "cert".
-# Note that "password" sends passwords in clear text; "md5" or
+# "gss", "sspi", "ident", "peer", "pam", "oauth", "ldap", "radius" or
+# "cert".  Note that "password" sends passwords in clear text; "md5" or
 # "scram-sha-256" are preferred since they send encrypted passwords.
 #
 # OPTIONS are a set of options for the authentication in the format
diff --git a/src/backend/utils/adt/hbafuncs.c b/src/backend/utils/adt/hbafuncs.c
index 03c38e8c451..b62c3d944cf 100644
--- a/src/backend/utils/adt/hbafuncs.c
+++ b/src/backend/utils/adt/hbafuncs.c
@@ -152,6 +152,25 @@ get_hba_options(HbaLine *hba)
 				CStringGetTextDatum(psprintf("radiusports=%s", hba->radiusports_s));
 	}
 
+	if (hba->auth_method == uaOAuth)
+	{
+		if (hba->oauth_issuer)
+			options[noptions++] =
+				CStringGetTextDatum(psprintf("issuer=%s", hba->oauth_issuer));
+
+		if (hba->oauth_scope)
+			options[noptions++] =
+				CStringGetTextDatum(psprintf("scope=%s", hba->oauth_scope));
+
+		if (hba->oauth_validator)
+			options[noptions++] =
+				CStringGetTextDatum(psprintf("validator=%s", hba->oauth_validator));
+
+		if (hba->oauth_skip_usermap)
+			options[noptions++] =
+				CStringGetTextDatum(psprintf("delegate_ident_mapping=true"));
+	}
+
 	/* If you add more options, consider increasing MAX_HBA_OPTIONS. */
 	Assert(noptions <= MAX_HBA_OPTIONS);
 
diff --git a/src/backend/utils/misc/guc_tables.c b/src/backend/utils/misc/guc_tables.c
index 3cde94a1759..03a6dd49154 100644
--- a/src/backend/utils/misc/guc_tables.c
+++ b/src/backend/utils/misc/guc_tables.c
@@ -49,6 +49,7 @@
 #include "jit/jit.h"
 #include "libpq/auth.h"
 #include "libpq/libpq.h"
+#include "libpq/oauth.h"
 #include "libpq/scram.h"
 #include "nodes/queryjumble.h"
 #include "optimizer/cost.h"
@@ -4873,6 +4874,17 @@ struct config_string ConfigureNamesString[] =
 		check_restrict_nonsystem_relation_kind, assign_restrict_nonsystem_relation_kind, NULL
 	},
 
+	{
+		{"oauth_validator_libraries", PGC_SIGHUP, CONN_AUTH_AUTH,
+			gettext_noop("Lists libraries that may be called to validate OAuth v2 bearer tokens."),
+			NULL,
+			GUC_LIST_INPUT | GUC_LIST_QUOTE | GUC_SUPERUSER_ONLY
+		},
+		&oauth_validator_libraries_string,
+		"",
+		NULL, NULL, NULL
+	},
+
 	/* End-of-list marker */
 	{
 		{NULL, 0, 0, NULL, NULL}, NULL, NULL, NULL, NULL, NULL
diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample
index 415f253096c..5362ff80519 100644
--- a/src/backend/utils/misc/postgresql.conf.sample
+++ b/src/backend/utils/misc/postgresql.conf.sample
@@ -121,6 +121,9 @@
 #ssl_passphrase_command = ''
 #ssl_passphrase_command_supports_reload = off
 
+# OAuth
+#oauth_validator_libraries = ''	# comma-separated list of trusted validator modules
+
 
 #------------------------------------------------------------------------------
 # RESOURCE USAGE (except WAL)
diff --git a/src/include/common/oauth-common.h b/src/include/common/oauth-common.h
new file mode 100644
index 00000000000..5fb559d84b2
--- /dev/null
+++ b/src/include/common/oauth-common.h
@@ -0,0 +1,19 @@
+/*-------------------------------------------------------------------------
+ *
+ * oauth-common.h
+ *		Declarations for helper functions used for OAuth/OIDC authentication
+ *
+ * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/include/common/oauth-common.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef OAUTH_COMMON_H
+#define OAUTH_COMMON_H
+
+/* Name of SASL mechanism per IANA */
+#define OAUTHBEARER_NAME "OAUTHBEARER"
+
+#endif							/* OAUTH_COMMON_H */
diff --git a/src/include/libpq/auth.h b/src/include/libpq/auth.h
index 902c5f6de32..25b5742068f 100644
--- a/src/include/libpq/auth.h
+++ b/src/include/libpq/auth.h
@@ -39,6 +39,7 @@ extern PGDLLIMPORT bool pg_gss_accept_delegation;
 extern void ClientAuthentication(Port *port);
 extern void sendAuthRequest(Port *port, AuthRequest areq, const char *extradata,
 							int extralen);
+extern void set_authn_id(Port *port, const char *id);
 
 /* Hook for plugins to get control in ClientAuthentication() */
 typedef void (*ClientAuthentication_hook_type) (Port *, int);
diff --git a/src/include/libpq/hba.h b/src/include/libpq/hba.h
index b20d0051f7d..3657f182db3 100644
--- a/src/include/libpq/hba.h
+++ b/src/include/libpq/hba.h
@@ -39,7 +39,8 @@ typedef enum UserAuth
 	uaCert,
 	uaRADIUS,
 	uaPeer,
-#define USER_AUTH_LAST uaPeer	/* Must be last value of this enum */
+	uaOAuth,
+#define USER_AUTH_LAST uaOAuth	/* Must be last value of this enum */
 } UserAuth;
 
 /*
@@ -135,6 +136,10 @@ typedef struct HbaLine
 	char	   *radiusidentifiers_s;
 	List	   *radiusports;
 	char	   *radiusports_s;
+	char	   *oauth_issuer;
+	char	   *oauth_scope;
+	char	   *oauth_validator;
+	bool		oauth_skip_usermap;
 } HbaLine;
 
 typedef struct IdentLine
diff --git a/src/include/libpq/oauth.h b/src/include/libpq/oauth.h
new file mode 100644
index 00000000000..2c6892ffba4
--- /dev/null
+++ b/src/include/libpq/oauth.h
@@ -0,0 +1,101 @@
+/*-------------------------------------------------------------------------
+ *
+ * oauth.h
+ *	  Interface to libpq/auth-oauth.c
+ *
+ * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/include/libpq/oauth.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PG_OAUTH_H
+#define PG_OAUTH_H
+
+#include "libpq/libpq-be.h"
+#include "libpq/sasl.h"
+
+extern PGDLLIMPORT char *oauth_validator_libraries_string;
+
+typedef struct ValidatorModuleState
+{
+	/* Holds the server's PG_VERSION_NUM. Reserved for future extensibility. */
+	int			sversion;
+
+	/*
+	 * Private data pointer for use by a validator module. This can be used to
+	 * store state for the module that will be passed to each of its
+	 * callbacks.
+	 */
+	void	   *private_data;
+} ValidatorModuleState;
+
+typedef struct ValidatorModuleResult
+{
+	/*
+	 * Should be set to true if the token carries sufficient permissions for
+	 * the bearer to connect.
+	 */
+	bool		authorized;
+
+	/*
+	 * If the token authenticates the user, this should be set to a palloc'd
+	 * string containing the SYSTEM_USER to use for HBA mapping. Consider
+	 * setting this even if result->authorized is false so that DBAs may use
+	 * the logs to match end users to token failures.
+	 *
+	 * This is required if the module is not configured for ident mapping
+	 * delegation. See the validator module documentation for details.
+	 */
+	char	   *authn_id;
+} ValidatorModuleResult;
+
+/*
+ * Validator module callbacks
+ *
+ * These callback functions should be defined by validator modules and returned
+ * via _PG_oauth_validator_module_init().  ValidatorValidateCB is the only
+ * required callback. For more information about the purpose of each callback,
+ * refer to the OAuth validator modules documentation.
+ */
+typedef void (*ValidatorStartupCB) (ValidatorModuleState *state);
+typedef void (*ValidatorShutdownCB) (ValidatorModuleState *state);
+typedef bool (*ValidatorValidateCB) (const ValidatorModuleState *state,
+									 const char *token, const char *role,
+									 ValidatorModuleResult *result);
+
+/*
+ * Identifies the compiled ABI version of the validator module. Since the server
+ * already enforces the PG_MODULE_MAGIC number for modules across major
+ * versions, this is reserved for emergency use within a stable release line.
+ * May it never need to change.
+ */
+#define PG_OAUTH_VALIDATOR_MAGIC 0x20250220
+
+typedef struct OAuthValidatorCallbacks
+{
+	uint32		magic;			/* must be set to PG_OAUTH_VALIDATOR_MAGIC */
+
+	ValidatorStartupCB startup_cb;
+	ValidatorShutdownCB shutdown_cb;
+	ValidatorValidateCB validate_cb;
+} OAuthValidatorCallbacks;
+
+/*
+ * Type of the shared library symbol _PG_oauth_validator_module_init which is
+ * required for all validator modules.  This function will be invoked during
+ * module loading.
+ */
+typedef const OAuthValidatorCallbacks *(*OAuthValidatorModuleInit) (void);
+extern PGDLLEXPORT const OAuthValidatorCallbacks *_PG_oauth_validator_module_init(void);
+
+/* Implementation */
+extern const pg_be_sasl_mech pg_be_oauth_mech;
+
+/*
+ * Ensure a validator named in the HBA is permitted by the configuration.
+ */
+extern bool check_oauth_validator(HbaLine *hba, int elevel, char **err_msg);
+
+#endif							/* PG_OAUTH_H */
diff --git a/src/include/pg_config.h.in b/src/include/pg_config.h.in
index 07b2f798abd..db6454090d2 100644
--- a/src/include/pg_config.h.in
+++ b/src/include/pg_config.h.in
@@ -229,6 +229,9 @@
 /* Define to 1 if you have the `crypto' library (-lcrypto). */
 #undef HAVE_LIBCRYPTO
 
+/* Define to 1 if you have the `curl' library (-lcurl). */
+#undef HAVE_LIBCURL
+
 /* Define to 1 if you have the `ldap' library (-lldap). */
 #undef HAVE_LIBLDAP
 
@@ -442,6 +445,9 @@
 /* Define to 1 if you have the <termios.h> header file. */
 #undef HAVE_TERMIOS_H
 
+/* Define to 1 if curl_global_init() is guaranteed to be thread-safe. */
+#undef HAVE_THREADSAFE_CURL_GLOBAL_INIT
+
 /* Define to 1 if your compiler understands `typeof' or something similar. */
 #undef HAVE_TYPEOF
 
@@ -663,6 +669,9 @@
 /* Define to 1 to build with LDAP support. (--with-ldap) */
 #undef USE_LDAP
 
+/* Define to 1 to build with libcurl support. (--with-libcurl) */
+#undef USE_LIBCURL
+
 /* Define to 1 to build with XML support. (--with-libxml) */
 #undef USE_LIBXML
 
diff --git a/src/interfaces/libpq/Makefile b/src/interfaces/libpq/Makefile
index 701810a272a..90b0b65db6f 100644
--- a/src/interfaces/libpq/Makefile
+++ b/src/interfaces/libpq/Makefile
@@ -31,6 +31,7 @@ endif
 
 OBJS = \
 	$(WIN32RES) \
+	fe-auth-oauth.o \
 	fe-auth-scram.o \
 	fe-cancel.o \
 	fe-connect.o \
@@ -63,6 +64,10 @@ OBJS += \
 	fe-secure-gssapi.o
 endif
 
+ifeq ($(with_libcurl),yes)
+OBJS += fe-auth-oauth-curl.o
+endif
+
 ifeq ($(PORTNAME), cygwin)
 override shlib = cyg$(NAME)$(DLSUFFIX)
 endif
@@ -81,7 +86,7 @@ endif
 # that are built correctly for use in a shlib.
 SHLIB_LINK_INTERNAL = -lpgcommon_shlib -lpgport_shlib
 ifneq ($(PORTNAME), win32)
-SHLIB_LINK += $(filter -lcrypt -ldes -lcom_err -lcrypto -lk5crypto -lkrb5 -lgssapi_krb5 -lgss -lgssapi -lssl -lsocket -lnsl -lresolv -lintl -lm, $(LIBS)) $(LDAP_LIBS_FE) $(PTHREAD_LIBS)
+SHLIB_LINK += $(filter -lcrypt -ldes -lcom_err -lcrypto -lk5crypto -lkrb5 -lgssapi_krb5 -lgss -lgssapi -lssl -lcurl -lsocket -lnsl -lresolv -lintl -lm, $(LIBS)) $(LDAP_LIBS_FE) $(PTHREAD_LIBS)
 else
 SHLIB_LINK += $(filter -lcrypt -ldes -lcom_err -lcrypto -lk5crypto -lkrb5 -lgssapi32 -lssl -lsocket -lnsl -lresolv -lintl -lm $(PTHREAD_LIBS), $(LIBS)) $(LDAP_LIBS_FE)
 endif
@@ -110,6 +115,8 @@ backend_src = $(top_srcdir)/src/backend
 # which seems to insert references to that even in pure C code. Excluding
 # __tsan_func_exit is necessary when using ThreadSanitizer data race detector
 # which use this function for instrumentation of function exit.
+# libcurl registers an exit handler in the memory debugging code when running
+# with LeakSanitizer.
 # Skip the test when profiling, as gcc may insert exit() calls for that.
 # Also skip the test on platforms where libpq infrastructure may be provided
 # by statically-linked libraries, as we can't expect them to honor this
@@ -117,7 +124,7 @@ backend_src = $(top_srcdir)/src/backend
 libpq-refs-stamp: $(shlib)
 ifneq ($(enable_coverage), yes)
 ifeq (,$(filter solaris,$(PORTNAME)))
-	@if nm -A -u $< 2>/dev/null | grep -v -e __cxa_atexit -e __tsan_func_exit | grep exit; then \
+	@if nm -A -u $< 2>/dev/null | grep -v -e __cxa_atexit -e __tsan_func_exit -e _atexit | grep exit; then \
 		echo 'libpq must not be calling any function which invokes exit'; exit 1; \
 	fi
 endif
diff --git a/src/interfaces/libpq/exports.txt b/src/interfaces/libpq/exports.txt
index 2ad2cbf5ca3..9b789cbec0b 100644
--- a/src/interfaces/libpq/exports.txt
+++ b/src/interfaces/libpq/exports.txt
@@ -206,3 +206,6 @@ PQsocketPoll              203
 PQsetChunkedRowsMode      204
 PQgetCurrentTimeUSec      205
 PQservice                 206
+PQsetAuthDataHook         207
+PQgetAuthDataHook         208
+PQdefaultAuthDataHook     209
diff --git a/src/interfaces/libpq/fe-auth-oauth-curl.c b/src/interfaces/libpq/fe-auth-oauth-curl.c
new file mode 100644
index 00000000000..a80e2047bb7
--- /dev/null
+++ b/src/interfaces/libpq/fe-auth-oauth-curl.c
@@ -0,0 +1,2883 @@
+/*-------------------------------------------------------------------------
+ *
+ * fe-auth-oauth-curl.c
+ *	   The libcurl implementation of OAuth/OIDC authentication, using the
+ *	   OAuth Device Authorization Grant (RFC 8628).
+ *
+ * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/interfaces/libpq/fe-auth-oauth-curl.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres_fe.h"
+
+#include <curl/curl.h>
+#include <math.h>
+#ifdef HAVE_SYS_EPOLL_H
+#include <sys/epoll.h>
+#include <sys/timerfd.h>
+#endif
+#ifdef HAVE_SYS_EVENT_H
+#include <sys/event.h>
+#endif
+#include <unistd.h>
+
+#include "common/jsonapi.h"
+#include "fe-auth.h"
+#include "fe-auth-oauth.h"
+#include "libpq-int.h"
+#include "mb/pg_wchar.h"
+
+/*
+ * It's generally prudent to set a maximum response size to buffer in memory,
+ * but it's less clear what size to choose. The biggest of our expected
+ * responses is the server metadata JSON, which will only continue to grow in
+ * size; the number of IANA-registered parameters in that document is up to 78
+ * as of February 2025.
+ *
+ * Even if every single parameter were to take up 2k on average (a previously
+ * common limit on the size of a URL), 256k gives us 128 parameter values before
+ * we give up. (That's almost certainly complete overkill in practice; 2-4k
+ * appears to be common among popular providers at the moment.)
+ */
+#define MAX_OAUTH_RESPONSE_SIZE (256 * 1024)
+
+/*
+ * Parsed JSON Representations
+ *
+ * As a general rule, we parse and cache only the fields we're currently using.
+ * When adding new fields, ensure the corresponding free_*() function is updated
+ * too.
+ */
+
+/*
+ * The OpenID Provider configuration (alternatively named "authorization server
+ * metadata") jointly described by OpenID Connect Discovery 1.0 and RFC 8414:
+ *
+ *     https://openid.net/specs/openid-connect-discovery-1_0.html
+ *     https://www.rfc-editor.org/rfc/rfc8414#section-3.2
+ */
+struct provider
+{
+	char	   *issuer;
+	char	   *token_endpoint;
+	char	   *device_authorization_endpoint;
+	struct curl_slist *grant_types_supported;
+};
+
+static void
+free_provider(struct provider *provider)
+{
+	free(provider->issuer);
+	free(provider->token_endpoint);
+	free(provider->device_authorization_endpoint);
+	curl_slist_free_all(provider->grant_types_supported);
+}
+
+/*
+ * The Device Authorization response, described by RFC 8628:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc8628#section-3.2
+ */
+struct device_authz
+{
+	char	   *device_code;
+	char	   *user_code;
+	char	   *verification_uri;
+	char	   *verification_uri_complete;
+	char	   *expires_in_str;
+	char	   *interval_str;
+
+	/* Fields below are parsed from the corresponding string above. */
+	int			expires_in;
+	int			interval;
+};
+
+static void
+free_device_authz(struct device_authz *authz)
+{
+	free(authz->device_code);
+	free(authz->user_code);
+	free(authz->verification_uri);
+	free(authz->verification_uri_complete);
+	free(authz->expires_in_str);
+	free(authz->interval_str);
+}
+
+/*
+ * The Token Endpoint error response, as described by RFC 6749:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc6749#section-5.2
+ *
+ * Note that this response type can also be returned from the Device
+ * Authorization Endpoint.
+ */
+struct token_error
+{
+	char	   *error;
+	char	   *error_description;
+};
+
+static void
+free_token_error(struct token_error *err)
+{
+	free(err->error);
+	free(err->error_description);
+}
+
+/*
+ * The Access Token response, as described by RFC 6749:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc6749#section-4.1.4
+ *
+ * During the Device Authorization flow, several temporary errors are expected
+ * as part of normal operation. To make it easy to handle these in the happy
+ * path, this contains an embedded token_error that is filled in if needed.
+ */
+struct token
+{
+	/* for successful responses */
+	char	   *access_token;
+	char	   *token_type;
+
+	/* for error responses */
+	struct token_error err;
+};
+
+static void
+free_token(struct token *tok)
+{
+	free(tok->access_token);
+	free(tok->token_type);
+	free_token_error(&tok->err);
+}
+
+/*
+ * Asynchronous State
+ */
+
+/* States for the overall async machine. */
+enum OAuthStep
+{
+	OAUTH_STEP_INIT = 0,
+	OAUTH_STEP_DISCOVERY,
+	OAUTH_STEP_DEVICE_AUTHORIZATION,
+	OAUTH_STEP_TOKEN_REQUEST,
+	OAUTH_STEP_WAIT_INTERVAL,
+};
+
+/*
+ * The async_ctx holds onto state that needs to persist across multiple calls
+ * to pg_fe_run_oauth_flow(). Almost everything interacts with this in some
+ * way.
+ */
+struct async_ctx
+{
+	enum OAuthStep step;		/* where are we in the flow? */
+
+	int			timerfd;		/* descriptor for signaling async timeouts */
+	pgsocket	mux;			/* the multiplexer socket containing all
+								 * descriptors tracked by libcurl, plus the
+								 * timerfd */
+	CURLM	   *curlm;			/* top-level multi handle for libcurl
+								 * operations */
+	CURL	   *curl;			/* the (single) easy handle for serial
+								 * requests */
+
+	struct curl_slist *headers; /* common headers for all requests */
+	PQExpBufferData work_data;	/* scratch buffer for general use (remember to
+								 * clear out prior contents first!) */
+
+	/*------
+	 * Since a single logical operation may stretch across multiple calls to
+	 * our entry point, errors have three parts:
+	 *
+	 * - errctx:	an optional static string, describing the global operation
+	 *				currently in progress. It'll be translated for you.
+	 *
+	 * - errbuf:	contains the actual error message. Generally speaking, use
+	 *				actx_error[_str] to manipulate this. This must be filled
+	 *				with something useful on an error.
+	 *
+	 * - curl_err:	an optional static error buffer used by libcurl to put
+	 *				detailed information about failures. Unfortunately
+	 *				untranslatable.
+	 *
+	 * These pieces will be combined into a single error message looking
+	 * something like the following, with errctx and/or curl_err omitted when
+	 * absent:
+	 *
+	 *     connection to server ... failed: errctx: errbuf (libcurl: curl_err)
+	 */
+	const char *errctx;			/* not freed; must point to static allocation */
+	PQExpBufferData errbuf;
+	char		curl_err[CURL_ERROR_SIZE];
+
+	/*
+	 * These documents need to survive over multiple calls, and are therefore
+	 * cached directly in the async_ctx.
+	 */
+	struct provider provider;
+	struct device_authz authz;
+
+	int			running;		/* is asynchronous work in progress? */
+	bool		user_prompted;	/* have we already sent the authz prompt? */
+	bool		used_basic_auth;	/* did we send a client secret? */
+	bool		debugging;		/* can we give unsafe developer assistance? */
+};
+
+/*
+ * Tears down the Curl handles and frees the async_ctx.
+ */
+static void
+free_async_ctx(PGconn *conn, struct async_ctx *actx)
+{
+	/*
+	 * In general, none of the error cases below should ever happen if we have
+	 * no bugs above. But if we do hit them, surfacing those errors somehow
+	 * might be the only way to have a chance to debug them.
+	 *
+	 * TODO: At some point it'd be nice to have a standard way to warn about
+	 * teardown failures. Appending to the connection's error message only
+	 * helps if the bug caused a connection failure; otherwise it'll be
+	 * buried...
+	 */
+
+	if (actx->curlm && actx->curl)
+	{
+		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));
+	}
+
+	if (actx->curl)
+	{
+		/*
+		 * curl_multi_cleanup() doesn't free any associated easy handles; we
+		 * need to do that separately. We only ever have one easy handle per
+		 * multi handle.
+		 */
+		curl_easy_cleanup(actx->curl);
+	}
+
+	if (actx->curlm)
+	{
+		CURLMcode	err = curl_multi_cleanup(actx->curlm);
+
+		if (err)
+			libpq_append_conn_error(conn,
+									"libcurl multi handle cleanup failed: %s",
+									curl_multi_strerror(err));
+	}
+
+	free_provider(&actx->provider);
+	free_device_authz(&actx->authz);
+
+	curl_slist_free_all(actx->headers);
+	termPQExpBuffer(&actx->work_data);
+	termPQExpBuffer(&actx->errbuf);
+
+	if (actx->mux != PGINVALID_SOCKET)
+		close(actx->mux);
+	if (actx->timerfd >= 0)
+		close(actx->timerfd);
+
+	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.
+ */
+void
+pg_fe_cleanup_oauth_flow(PGconn *conn)
+{
+	fe_oauth_state *state = conn->sasl_state;
+
+	if (state->async_ctx)
+	{
+		free_async_ctx(conn, state->async_ctx);
+		state->async_ctx = NULL;
+	}
+
+	conn->altsock = PGINVALID_SOCKET;
+}
+
+/*
+ * Macros for manipulating actx->errbuf. actx_error() translates and formats a
+ * string for you; actx_error_str() appends a string directly without
+ * translation.
+ */
+
+#define actx_error(ACTX, FMT, ...) \
+	appendPQExpBuffer(&(ACTX)->errbuf, libpq_gettext(FMT), ##__VA_ARGS__)
+
+#define actx_error_str(ACTX, S) \
+	appendPQExpBufferStr(&(ACTX)->errbuf, S)
+
+/*
+ * Macros for getting and setting state for the connection's two libcurl
+ * handles, so you don't have to write out the error handling every time.
+ */
+
+#define CHECK_MSETOPT(ACTX, OPT, VAL, FAILACTION) \
+	do { \
+		struct async_ctx *_actx = (ACTX); \
+		CURLMcode	_setopterr = curl_multi_setopt(_actx->curlm, OPT, VAL); \
+		if (_setopterr) { \
+			actx_error(_actx, "failed to set %s on OAuth connection: %s",\
+					   #OPT, curl_multi_strerror(_setopterr)); \
+			FAILACTION; \
+		} \
+	} while (0)
+
+#define CHECK_SETOPT(ACTX, OPT, VAL, FAILACTION) \
+	do { \
+		struct async_ctx *_actx = (ACTX); \
+		CURLcode	_setopterr = curl_easy_setopt(_actx->curl, OPT, VAL); \
+		if (_setopterr) { \
+			actx_error(_actx, "failed to set %s on OAuth connection: %s",\
+					   #OPT, curl_easy_strerror(_setopterr)); \
+			FAILACTION; \
+		} \
+	} while (0)
+
+#define CHECK_GETINFO(ACTX, INFO, OUT, FAILACTION) \
+	do { \
+		struct async_ctx *_actx = (ACTX); \
+		CURLcode	_getinfoerr = curl_easy_getinfo(_actx->curl, INFO, OUT); \
+		if (_getinfoerr) { \
+			actx_error(_actx, "failed to get %s from OAuth response: %s",\
+					   #INFO, curl_easy_strerror(_getinfoerr)); \
+			FAILACTION; \
+		} \
+	} while (0)
+
+/*
+ * General JSON Parsing for OAuth Responses
+ */
+
+/*
+ * Represents a single name/value pair in a JSON object. This is the primary
+ * interface to parse_oauth_json().
+ *
+ * All fields are stored internally as strings or lists of strings, so clients
+ * have to explicitly parse other scalar types (though they will have gone
+ * through basic lexical validation). Storing nested objects is not currently
+ * supported, nor is parsing arrays of anything other than strings.
+ */
+struct json_field
+{
+	const char *name;			/* name (key) of the member */
+
+	JsonTokenType type;			/* currently supports JSON_TOKEN_STRING,
+								 * JSON_TOKEN_NUMBER, and
+								 * JSON_TOKEN_ARRAY_START */
+	union
+	{
+		char	  **scalar;		/* for all scalar types */
+		struct curl_slist **array;	/* for type == JSON_TOKEN_ARRAY_START */
+	}			target;
+
+	bool		required;		/* REQUIRED field, or just OPTIONAL? */
+};
+
+/* Documentation macros for json_field.required. */
+#define REQUIRED true
+#define OPTIONAL false
+
+/* Parse state for parse_oauth_json(). */
+struct oauth_parse
+{
+	PQExpBuffer errbuf;			/* detail message for JSON_SEM_ACTION_FAILED */
+	int			nested;			/* nesting level (zero is the top) */
+
+	const struct json_field *fields;	/* field definition array */
+	const struct json_field *active;	/* points inside the fields array */
+};
+
+#define oauth_parse_set_error(ctx, fmt, ...) \
+	appendPQExpBuffer((ctx)->errbuf, libpq_gettext(fmt), ##__VA_ARGS__)
+
+static void
+report_type_mismatch(struct oauth_parse *ctx)
+{
+	char	   *msgfmt;
+
+	Assert(ctx->active);
+
+	/*
+	 * At the moment, the only fields we're interested in are strings,
+	 * numbers, and arrays of strings.
+	 */
+	switch (ctx->active->type)
+	{
+		case JSON_TOKEN_STRING:
+			msgfmt = "field \"%s\" must be a string";
+			break;
+
+		case JSON_TOKEN_NUMBER:
+			msgfmt = "field \"%s\" must be a number";
+			break;
+
+		case JSON_TOKEN_ARRAY_START:
+			msgfmt = "field \"%s\" must be an array of strings";
+			break;
+
+		default:
+			Assert(false);
+			msgfmt = "field \"%s\" has unexpected type";
+	}
+
+	oauth_parse_set_error(ctx, msgfmt, ctx->active->name);
+}
+
+static JsonParseErrorType
+oauth_json_object_start(void *state)
+{
+	struct oauth_parse *ctx = state;
+
+	if (ctx->active)
+	{
+		/*
+		 * Currently, none of the fields we're interested in can be or contain
+		 * objects, so we can reject this case outright.
+		 */
+		report_type_mismatch(ctx);
+		return JSON_SEM_ACTION_FAILED;
+	}
+
+	++ctx->nested;
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_object_field_start(void *state, char *name, bool isnull)
+{
+	struct oauth_parse *ctx = state;
+
+	/* We care only about the top-level fields. */
+	if (ctx->nested == 1)
+	{
+		const struct json_field *field = ctx->fields;
+
+		/*
+		 * We should never start parsing a new field while a previous one is
+		 * still active.
+		 */
+		if (ctx->active)
+		{
+			Assert(false);
+			oauth_parse_set_error(ctx,
+								  "internal error: started field '%s' before field '%s' was finished",
+								  name, ctx->active->name);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		while (field->name)
+		{
+			if (strcmp(name, field->name) == 0)
+			{
+				ctx->active = field;
+				break;
+			}
+
+			++field;
+		}
+
+		/*
+		 * We don't allow duplicate field names; error out if the target has
+		 * already been set.
+		 */
+		if (ctx->active)
+		{
+			field = ctx->active;
+
+			if ((field->type == JSON_TOKEN_ARRAY_START && *field->target.array)
+				|| (field->type != JSON_TOKEN_ARRAY_START && *field->target.scalar))
+			{
+				oauth_parse_set_error(ctx, "field \"%s\" is duplicated",
+									  field->name);
+				return JSON_SEM_ACTION_FAILED;
+			}
+		}
+	}
+
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_object_end(void *state)
+{
+	struct oauth_parse *ctx = state;
+
+	--ctx->nested;
+
+	/*
+	 * All fields should be fully processed by the end of the top-level
+	 * object.
+	 */
+	if (!ctx->nested && ctx->active)
+	{
+		Assert(false);
+		oauth_parse_set_error(ctx,
+							  "internal error: field '%s' still active at end of object",
+							  ctx->active->name);
+		return JSON_SEM_ACTION_FAILED;
+	}
+
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_array_start(void *state)
+{
+	struct oauth_parse *ctx = state;
+
+	if (!ctx->nested)
+	{
+		oauth_parse_set_error(ctx, "top-level element must be an object");
+		return JSON_SEM_ACTION_FAILED;
+	}
+
+	if (ctx->active)
+	{
+		if (ctx->active->type != JSON_TOKEN_ARRAY_START
+		/* The arrays we care about must not have arrays as values. */
+			|| ctx->nested > 1)
+		{
+			report_type_mismatch(ctx);
+			return JSON_SEM_ACTION_FAILED;
+		}
+	}
+
+	++ctx->nested;
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_array_end(void *state)
+{
+	struct oauth_parse *ctx = state;
+
+	if (ctx->active)
+	{
+		/*
+		 * Clear the target (which should be an array inside the top-level
+		 * object). For this to be safe, no target arrays can contain other
+		 * arrays; we check for that in the array_start callback.
+		 */
+		if (ctx->nested != 2 || ctx->active->type != JSON_TOKEN_ARRAY_START)
+		{
+			Assert(false);
+			oauth_parse_set_error(ctx,
+								  "internal error: found unexpected array end while parsing field '%s'",
+								  ctx->active->name);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		ctx->active = NULL;
+	}
+
+	--ctx->nested;
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_scalar(void *state, char *token, JsonTokenType type)
+{
+	struct oauth_parse *ctx = state;
+
+	if (!ctx->nested)
+	{
+		oauth_parse_set_error(ctx, "top-level element must be an object");
+		return JSON_SEM_ACTION_FAILED;
+	}
+
+	if (ctx->active)
+	{
+		const struct json_field *field = ctx->active;
+		JsonTokenType expected = field->type;
+
+		/* Make sure this matches what the active field expects. */
+		if (expected == JSON_TOKEN_ARRAY_START)
+		{
+			/* Are we actually inside an array? */
+			if (ctx->nested < 2)
+			{
+				report_type_mismatch(ctx);
+				return JSON_SEM_ACTION_FAILED;
+			}
+
+			/* Currently, arrays can only contain strings. */
+			expected = JSON_TOKEN_STRING;
+		}
+
+		if (type != expected)
+		{
+			report_type_mismatch(ctx);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		if (field->type != JSON_TOKEN_ARRAY_START)
+		{
+			/* Ensure that we're parsing the top-level keys... */
+			if (ctx->nested != 1)
+			{
+				Assert(false);
+				oauth_parse_set_error(ctx,
+									  "internal error: scalar target found at nesting level %d",
+									  ctx->nested);
+				return JSON_SEM_ACTION_FAILED;
+			}
+
+			/* ...and that a result has not already been set. */
+			if (*field->target.scalar)
+			{
+				Assert(false);
+				oauth_parse_set_error(ctx,
+									  "internal error: scalar field '%s' would be assigned twice",
+									  ctx->active->name);
+				return JSON_SEM_ACTION_FAILED;
+			}
+
+			*field->target.scalar = strdup(token);
+			if (!*field->target.scalar)
+				return JSON_OUT_OF_MEMORY;
+
+			ctx->active = NULL;
+
+			return JSON_SUCCESS;
+		}
+		else
+		{
+			struct curl_slist *temp;
+
+			/* The target array should be inside the top-level object. */
+			if (ctx->nested != 2)
+			{
+				Assert(false);
+				oauth_parse_set_error(ctx,
+									  "internal error: array member found at nesting level %d",
+									  ctx->nested);
+				return JSON_SEM_ACTION_FAILED;
+			}
+
+			/* Note that curl_slist_append() makes a copy of the token. */
+			temp = curl_slist_append(*field->target.array, token);
+			if (!temp)
+				return JSON_OUT_OF_MEMORY;
+
+			*field->target.array = temp;
+		}
+	}
+	else
+	{
+		/* otherwise we just ignore it */
+	}
+
+	return JSON_SUCCESS;
+}
+
+/*
+ * Checks the Content-Type header against the expected type. Parameters are
+ * allowed but ignored.
+ */
+static bool
+check_content_type(struct async_ctx *actx, const char *type)
+{
+	const size_t type_len = strlen(type);
+	char	   *content_type;
+
+	CHECK_GETINFO(actx, CURLINFO_CONTENT_TYPE, &content_type, return false);
+
+	if (!content_type)
+	{
+		actx_error(actx, "no content type was provided");
+		return false;
+	}
+
+	/*
+	 * We need to perform a length limited comparison and not compare the
+	 * whole string.
+	 */
+	if (pg_strncasecmp(content_type, type, type_len) != 0)
+		goto fail;
+
+	/* On an exact match, we're done. */
+	Assert(strlen(content_type) >= type_len);
+	if (content_type[type_len] == '\0')
+		return true;
+
+	/*
+	 * Only a semicolon (optionally preceded by HTTP optional whitespace) is
+	 * acceptable after the prefix we checked. This marks the start of media
+	 * type parameters, which we currently have no use for.
+	 */
+	for (size_t i = type_len; content_type[i]; ++i)
+	{
+		switch (content_type[i])
+		{
+			case ';':
+				return true;	/* success! */
+
+			case ' ':
+			case '\t':
+				/* HTTP optional whitespace allows only spaces and htabs. */
+				break;
+
+			default:
+				goto fail;
+		}
+	}
+
+fail:
+	actx_error(actx, "unexpected content type: \"%s\"", content_type);
+	return false;
+}
+
+/*
+ * A helper function for general JSON parsing. fields is the array of field
+ * definitions with their backing pointers. The response will be parsed from
+ * actx->curl and actx->work_data (as set up by start_request()), and any
+ * parsing errors will be placed into actx->errbuf.
+ */
+static bool
+parse_oauth_json(struct async_ctx *actx, const struct json_field *fields)
+{
+	PQExpBuffer resp = &actx->work_data;
+	JsonLexContext lex = {0};
+	JsonSemAction sem = {0};
+	JsonParseErrorType err;
+	struct oauth_parse ctx = {0};
+	bool		success = false;
+
+	if (!check_content_type(actx, "application/json"))
+		return false;
+
+	if (strlen(resp->data) != resp->len)
+	{
+		actx_error(actx, "response contains embedded NULLs");
+		return false;
+	}
+
+	/*
+	 * pg_parse_json doesn't validate the incoming UTF-8, so we have to check
+	 * that up front.
+	 */
+	if (pg_encoding_verifymbstr(PG_UTF8, resp->data, resp->len) != resp->len)
+	{
+		actx_error(actx, "response is not valid UTF-8");
+		return false;
+	}
+
+	makeJsonLexContextCstringLen(&lex, resp->data, resp->len, PG_UTF8, true);
+	setJsonLexContextOwnsTokens(&lex, true);	/* must not leak on error */
+
+	ctx.errbuf = &actx->errbuf;
+	ctx.fields = fields;
+	sem.semstate = &ctx;
+
+	sem.object_start = oauth_json_object_start;
+	sem.object_field_start = oauth_json_object_field_start;
+	sem.object_end = oauth_json_object_end;
+	sem.array_start = oauth_json_array_start;
+	sem.array_end = oauth_json_array_end;
+	sem.scalar = oauth_json_scalar;
+
+	err = pg_parse_json(&lex, &sem);
+
+	if (err != JSON_SUCCESS)
+	{
+		/*
+		 * For JSON_SEM_ACTION_FAILED, we've already written the error
+		 * message. Other errors come directly from pg_parse_json(), already
+		 * translated.
+		 */
+		if (err != JSON_SEM_ACTION_FAILED)
+			actx_error_str(actx, json_errdetail(err, &lex));
+
+		goto cleanup;
+	}
+
+	/* Check all required fields. */
+	while (fields->name)
+	{
+		if (fields->required
+			&& !*fields->target.scalar
+			&& !*fields->target.array)
+		{
+			actx_error(actx, "field \"%s\" is missing", fields->name);
+			goto cleanup;
+		}
+
+		fields++;
+	}
+
+	success = true;
+
+cleanup:
+	freeJsonLexContext(&lex);
+	return success;
+}
+
+/*
+ * JSON Parser Definitions
+ */
+
+/*
+ * Parses authorization server metadata. Fields are defined by OIDC Discovery
+ * 1.0 and RFC 8414.
+ */
+static bool
+parse_provider(struct async_ctx *actx, struct provider *provider)
+{
+	struct json_field fields[] = {
+		{"issuer", JSON_TOKEN_STRING, {&provider->issuer}, REQUIRED},
+		{"token_endpoint", JSON_TOKEN_STRING, {&provider->token_endpoint}, REQUIRED},
+
+		/*----
+		 * The following fields are technically REQUIRED, but we don't use
+		 * them anywhere yet:
+		 *
+		 * - jwks_uri
+		 * - response_types_supported
+		 * - subject_types_supported
+		 * - id_token_signing_alg_values_supported
+		 */
+
+		{"device_authorization_endpoint", JSON_TOKEN_STRING, {&provider->device_authorization_endpoint}, OPTIONAL},
+		{"grant_types_supported", JSON_TOKEN_ARRAY_START, {.array = &provider->grant_types_supported}, OPTIONAL},
+
+		{0},
+	};
+
+	return parse_oauth_json(actx, fields);
+}
+
+/*
+ * Parses a valid JSON number into a double. The input must have come from
+ * pg_parse_json(), so that we know the lexer has validated it; there's no
+ * in-band signal for invalid formats.
+ */
+static double
+parse_json_number(const char *s)
+{
+	double		parsed;
+	int			cnt;
+
+	/*
+	 * The JSON lexer has already validated the number, which is stricter than
+	 * the %f format, so we should be good to use sscanf().
+	 */
+	cnt = sscanf(s, "%lf", &parsed);
+
+	if (cnt != 1)
+	{
+		/*
+		 * Either the lexer screwed up or our assumption above isn't true, and
+		 * either way a developer needs to take a look.
+		 */
+		Assert(false);
+		return 0;
+	}
+
+	return parsed;
+}
+
+/*
+ * Parses the "interval" JSON number, corresponding to the number of seconds to
+ * wait between token endpoint requests.
+ *
+ * RFC 8628 is pretty silent on sanity checks for the interval. As a matter of
+ * practicality, round any fractional intervals up to the next second, and clamp
+ * the result at a minimum of one. (Zero-second intervals would result in an
+ * expensive network polling loop.) Tests may remove the lower bound with
+ * PGOAUTHDEBUG, for improved performance.
+ */
+static int
+parse_interval(struct async_ctx *actx, const char *interval_str)
+{
+	double		parsed;
+
+	parsed = parse_json_number(interval_str);
+	parsed = ceil(parsed);
+
+	if (parsed < 1)
+		return actx->debugging ? 0 : 1;
+
+	else if (parsed >= INT_MAX)
+		return INT_MAX;
+
+	return parsed;
+}
+
+/*
+ * Parses the "expires_in" JSON number, corresponding to the number of seconds
+ * remaining in the lifetime of the device code request.
+ *
+ * Similar to parse_interval, but we have even fewer requirements for reasonable
+ * values since we don't use the expiration time directly (it's passed to the
+ * PQAUTHDATA_PROMPT_OAUTH_DEVICE hook, in case the application wants to do
+ * something with it). We simply round down and clamp to int range.
+ */
+static int
+parse_expires_in(struct async_ctx *actx, const char *expires_in_str)
+{
+	double		parsed;
+
+	parsed = parse_json_number(expires_in_str);
+	parsed = floor(parsed);
+
+	if (parsed >= INT_MAX)
+		return INT_MAX;
+	else if (parsed <= INT_MIN)
+		return INT_MIN;
+
+	return parsed;
+}
+
+/*
+ * Parses the Device Authorization Response (RFC 8628, Sec. 3.2).
+ */
+static bool
+parse_device_authz(struct async_ctx *actx, struct device_authz *authz)
+{
+	struct json_field fields[] = {
+		{"device_code", JSON_TOKEN_STRING, {&authz->device_code}, REQUIRED},
+		{"user_code", JSON_TOKEN_STRING, {&authz->user_code}, REQUIRED},
+		{"verification_uri", JSON_TOKEN_STRING, {&authz->verification_uri}, REQUIRED},
+		{"expires_in", JSON_TOKEN_NUMBER, {&authz->expires_in_str}, REQUIRED},
+
+		/*
+		 * Some services (Google, Azure) spell verification_uri differently.
+		 * We accept either.
+		 */
+		{"verification_url", JSON_TOKEN_STRING, {&authz->verification_uri}, REQUIRED},
+
+		/*
+		 * There is no evidence of verification_uri_complete being spelled
+		 * with "url" instead with any service provider, so only support
+		 * "uri".
+		 */
+		{"verification_uri_complete", JSON_TOKEN_STRING, {&authz->verification_uri_complete}, OPTIONAL},
+		{"interval", JSON_TOKEN_NUMBER, {&authz->interval_str}, OPTIONAL},
+
+		{0},
+	};
+
+	if (!parse_oauth_json(actx, fields))
+		return false;
+
+	/*
+	 * Parse our numeric fields. Lexing has already completed by this time, so
+	 * we at least know they're valid JSON numbers.
+	 */
+	if (authz->interval_str)
+		authz->interval = parse_interval(actx, authz->interval_str);
+	else
+	{
+		/*
+		 * RFC 8628 specifies 5 seconds as the default value if the server
+		 * doesn't provide an interval.
+		 */
+		authz->interval = 5;
+	}
+
+	Assert(authz->expires_in_str);	/* ensured by parse_oauth_json() */
+	authz->expires_in = parse_expires_in(actx, authz->expires_in_str);
+
+	return true;
+}
+
+/*
+ * Parses the device access token error response (RFC 8628, Sec. 3.5, which
+ * uses the error response defined in RFC 6749, Sec. 5.2).
+ */
+static bool
+parse_token_error(struct async_ctx *actx, struct token_error *err)
+{
+	bool		result;
+	struct json_field fields[] = {
+		{"error", JSON_TOKEN_STRING, {&err->error}, REQUIRED},
+
+		{"error_description", JSON_TOKEN_STRING, {&err->error_description}, OPTIONAL},
+
+		{0},
+	};
+
+	result = parse_oauth_json(actx, fields);
+
+	/*
+	 * Since token errors are parsed during other active error paths, only
+	 * override the errctx if parsing explicitly fails.
+	 */
+	if (!result)
+		actx->errctx = "failed to parse token error response";
+
+	return result;
+}
+
+/*
+ * Constructs a message from the token error response and puts it into
+ * actx->errbuf.
+ */
+static void
+record_token_error(struct async_ctx *actx, const struct token_error *err)
+{
+	if (err->error_description)
+		appendPQExpBuffer(&actx->errbuf, "%s ", err->error_description);
+	else
+	{
+		/*
+		 * Try to get some more helpful detail into the error string. A 401
+		 * status in particular implies that the oauth_client_secret is
+		 * missing or wrong.
+		 */
+		long		response_code;
+
+		CHECK_GETINFO(actx, CURLINFO_RESPONSE_CODE, &response_code, response_code = 0);
+
+		if (response_code == 401)
+		{
+			actx_error(actx, actx->used_basic_auth
+					   ? "provider rejected the oauth_client_secret"
+					   : "provider requires client authentication, and no oauth_client_secret is set");
+			actx_error_str(actx, " ");
+		}
+	}
+
+	appendPQExpBuffer(&actx->errbuf, "(%s)", err->error);
+}
+
+/*
+ * Parses the device access token response (RFC 8628, Sec. 3.5, which uses the
+ * success response defined in RFC 6749, Sec. 5.1).
+ */
+static bool
+parse_access_token(struct async_ctx *actx, struct token *tok)
+{
+	struct json_field fields[] = {
+		{"access_token", JSON_TOKEN_STRING, {&tok->access_token}, REQUIRED},
+		{"token_type", JSON_TOKEN_STRING, {&tok->token_type}, REQUIRED},
+
+		/*---
+		 * We currently have no use for the following OPTIONAL fields:
+		 *
+		 * - expires_in: This will be important for maintaining a token cache,
+		 *               but we do not yet implement one.
+		 *
+		 * - refresh_token: Ditto.
+		 *
+		 * - scope: This is only sent when the authorization server sees fit to
+		 *          change our scope request. It's not clear what we should do
+		 *          about this; either it's been done as a matter of policy, or
+		 *          the user has explicitly denied part of the authorization,
+		 *          and either way the server-side validator is in a better
+		 *          place to complain if the change isn't acceptable.
+		 */
+
+		{0},
+	};
+
+	return parse_oauth_json(actx, fields);
+}
+
+/*
+ * libcurl Multi Setup/Callbacks
+ */
+
+/*
+ * Sets up the actx->mux, which is the altsock that PQconnectPoll clients will
+ * select() on instead of the Postgres socket during OAuth negotiation.
+ *
+ * This is just an epoll set or kqueue abstracting multiple other descriptors.
+ * For epoll, the timerfd is always part of the set; it's just disabled when
+ * we're not using it. For kqueue, the "timerfd" is actually a second kqueue
+ * instance which is only added to the set when needed.
+ */
+static bool
+setup_multiplexer(struct async_ctx *actx)
+{
+#ifdef HAVE_SYS_EPOLL_H
+	struct epoll_event ev = {.events = EPOLLIN};
+
+	actx->mux = epoll_create1(EPOLL_CLOEXEC);
+	if (actx->mux < 0)
+	{
+		actx_error(actx, "failed to create epoll set: %m");
+		return false;
+	}
+
+	actx->timerfd = timerfd_create(CLOCK_MONOTONIC, TFD_CLOEXEC);
+	if (actx->timerfd < 0)
+	{
+		actx_error(actx, "failed to create timerfd: %m");
+		return false;
+	}
+
+	if (epoll_ctl(actx->mux, EPOLL_CTL_ADD, actx->timerfd, &ev) < 0)
+	{
+		actx_error(actx, "failed to add timerfd to epoll set: %m");
+		return false;
+	}
+
+	return true;
+#endif
+#ifdef HAVE_SYS_EVENT_H
+	actx->mux = kqueue();
+	if (actx->mux < 0)
+	{
+		/*- translator: the term "kqueue" (kernel queue) should not be translated */
+		actx_error(actx, "failed to create kqueue: %m");
+		return false;
+	}
+
+	/*
+	 * Originally, we set EVFILT_TIMER directly on the top-level multiplexer.
+	 * This makes it difficult to implement timer_expired(), though, so now we
+	 * set EVFILT_TIMER on a separate actx->timerfd, which is chained to
+	 * actx->mux while the timer is active.
+	 */
+	actx->timerfd = kqueue();
+	if (actx->timerfd < 0)
+	{
+		actx_error(actx, "failed to create timer kqueue: %m");
+		return false;
+	}
+
+	return true;
+#endif
+
+	actx_error(actx, "libpq does not support the Device Authorization flow on this platform");
+	return false;
+}
+
+/*
+ * Adds and removes sockets from the multiplexer set, as directed by the
+ * libcurl multi handle.
+ */
+static int
+register_socket(CURL *curl, curl_socket_t socket, int what, void *ctx,
+				void *socketp)
+{
+#ifdef HAVE_SYS_EPOLL_H
+	struct async_ctx *actx = ctx;
+	struct epoll_event ev = {0};
+	int			res;
+	int			op = EPOLL_CTL_ADD;
+
+	switch (what)
+	{
+		case CURL_POLL_IN:
+			ev.events = EPOLLIN;
+			break;
+
+		case CURL_POLL_OUT:
+			ev.events = EPOLLOUT;
+			break;
+
+		case CURL_POLL_INOUT:
+			ev.events = EPOLLIN | EPOLLOUT;
+			break;
+
+		case CURL_POLL_REMOVE:
+			op = EPOLL_CTL_DEL;
+			break;
+
+		default:
+			actx_error(actx, "unknown libcurl socket operation: %d", what);
+			return -1;
+	}
+
+	res = epoll_ctl(actx->mux, op, socket, &ev);
+	if (res < 0 && errno == EEXIST)
+	{
+		/* We already had this socket in the pollset. */
+		op = EPOLL_CTL_MOD;
+		res = epoll_ctl(actx->mux, op, socket, &ev);
+	}
+
+	if (res < 0)
+	{
+		switch (op)
+		{
+			case EPOLL_CTL_ADD:
+				actx_error(actx, "could not add to epoll set: %m");
+				break;
+
+			case EPOLL_CTL_DEL:
+				actx_error(actx, "could not delete from epoll set: %m");
+				break;
+
+			default:
+				actx_error(actx, "could not update epoll set: %m");
+		}
+
+		return -1;
+	}
+
+	return 0;
+#endif
+#ifdef HAVE_SYS_EVENT_H
+	struct async_ctx *actx = ctx;
+	struct kevent ev[2] = {{0}};
+	struct kevent ev_out[2];
+	struct timespec timeout = {0};
+	int			nev = 0;
+	int			res;
+
+	switch (what)
+	{
+		case CURL_POLL_IN:
+			EV_SET(&ev[nev], socket, EVFILT_READ, EV_ADD | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			break;
+
+		case CURL_POLL_OUT:
+			EV_SET(&ev[nev], socket, EVFILT_WRITE, EV_ADD | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			break;
+
+		case CURL_POLL_INOUT:
+			EV_SET(&ev[nev], socket, EVFILT_READ, EV_ADD | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			EV_SET(&ev[nev], socket, EVFILT_WRITE, EV_ADD | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			break;
+
+		case CURL_POLL_REMOVE:
+
+			/*
+			 * We don't know which of these is currently registered, perhaps
+			 * both, so we try to remove both.  This means we need to tolerate
+			 * ENOENT below.
+			 */
+			EV_SET(&ev[nev], socket, EVFILT_READ, EV_DELETE | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			EV_SET(&ev[nev], socket, EVFILT_WRITE, EV_DELETE | EV_RECEIPT, 0, 0, 0);
+			nev++;
+			break;
+
+		default:
+			actx_error(actx, "unknown libcurl socket operation: %d", what);
+			return -1;
+	}
+
+	res = kevent(actx->mux, ev, nev, ev_out, lengthof(ev_out), &timeout);
+	if (res < 0)
+	{
+		actx_error(actx, "could not modify kqueue: %m");
+		return -1;
+	}
+
+	/*
+	 * We can't use the simple errno version of kevent, because we need to
+	 * skip over ENOENT while still allowing a second change to be processed.
+	 * So we need a longer-form error checking loop.
+	 */
+	for (int i = 0; i < res; ++i)
+	{
+		/*
+		 * EV_RECEIPT should guarantee one EV_ERROR result for every change,
+		 * whether successful or not. Failed entries contain a non-zero errno
+		 * in the data field.
+		 */
+		Assert(ev_out[i].flags & EV_ERROR);
+
+		errno = ev_out[i].data;
+		if (errno && errno != ENOENT)
+		{
+			switch (what)
+			{
+				case CURL_POLL_REMOVE:
+					actx_error(actx, "could not delete from kqueue: %m");
+					break;
+				default:
+					actx_error(actx, "could not add to kqueue: %m");
+			}
+			return -1;
+		}
+	}
+
+	return 0;
+#endif
+
+	actx_error(actx, "libpq does not support multiplexer sockets on this platform");
+	return -1;
+}
+
+/*
+ * Enables or disables the timer in the multiplexer set. The timeout value is
+ * in milliseconds (negative values disable the timer).
+ *
+ * For epoll, rather than continually adding and removing the timer, we keep it
+ * in the set at all times and just disarm it when it's not needed. For kqueue,
+ * the timer is removed completely when disabled to prevent stale timeouts from
+ * remaining in the queue.
+ */
+static bool
+set_timer(struct async_ctx *actx, long timeout)
+{
+#if HAVE_SYS_EPOLL_H
+	struct itimerspec spec = {0};
+
+	if (timeout < 0)
+	{
+		/* the zero itimerspec will disarm the timer below */
+	}
+	else if (timeout == 0)
+	{
+		/*
+		 * A zero timeout means libcurl wants us to call back immediately.
+		 * That's not technically an option for timerfd, but we can make the
+		 * timeout ridiculously short.
+		 */
+		spec.it_value.tv_nsec = 1;
+	}
+	else
+	{
+		spec.it_value.tv_sec = timeout / 1000;
+		spec.it_value.tv_nsec = (timeout % 1000) * 1000000;
+	}
+
+	if (timerfd_settime(actx->timerfd, 0 /* no flags */ , &spec, NULL) < 0)
+	{
+		actx_error(actx, "setting timerfd to %ld: %m", timeout);
+		return false;
+	}
+
+	return true;
+#endif
+#ifdef HAVE_SYS_EVENT_H
+	struct kevent ev;
+
+	/* Enable/disable the timer itself. */
+	EV_SET(&ev, 1, EVFILT_TIMER, timeout < 0 ? EV_DELETE : (EV_ADD | EV_ONESHOT),
+		   0, timeout, 0);
+	if (kevent(actx->timerfd, &ev, 1, NULL, 0, NULL) < 0 && errno != ENOENT)
+	{
+		actx_error(actx, "setting kqueue timer to %ld: %m", timeout);
+		return false;
+	}
+
+	/*
+	 * Add/remove the timer to/from the mux. (In contrast with epoll, if we
+	 * allowed the timer to remain registered here after being disabled, the
+	 * mux queue would retain any previous stale timeout notifications and
+	 * remain readable.)
+	 */
+	EV_SET(&ev, actx->timerfd, EVFILT_READ, timeout < 0 ? EV_DELETE : EV_ADD,
+		   0, 0, 0);
+	if (kevent(actx->mux, &ev, 1, NULL, 0, NULL) < 0 && errno != ENOENT)
+	{
+		actx_error(actx, "could not update timer on kqueue: %m");
+		return false;
+	}
+
+	return true;
+#endif
+
+	actx_error(actx, "libpq does not support timers on this platform");
+	return false;
+}
+
+/*
+ * Returns 1 if the timeout in the multiplexer set has expired since the last
+ * call to set_timer(), 0 if the timer is still running, or -1 (with an
+ * actx_error() report) if the timer cannot be queried.
+ */
+static int
+timer_expired(struct async_ctx *actx)
+{
+#if HAVE_SYS_EPOLL_H
+	struct itimerspec spec = {0};
+
+	if (timerfd_gettime(actx->timerfd, &spec) < 0)
+	{
+		actx_error(actx, "getting timerfd value: %m");
+		return -1;
+	}
+
+	/*
+	 * This implementation assumes we're using single-shot timers. If you
+	 * change to using intervals, you'll need to reimplement this function
+	 * too, possibly with the read() or select() interfaces for timerfd.
+	 */
+	Assert(spec.it_interval.tv_sec == 0
+		   && spec.it_interval.tv_nsec == 0);
+
+	/* If the remaining time to expiration is zero, we're done. */
+	return (spec.it_value.tv_sec == 0
+			&& spec.it_value.tv_nsec == 0);
+#endif
+#ifdef HAVE_SYS_EVENT_H
+	int			res;
+
+	/* Is the timer queue ready? */
+	res = PQsocketPoll(actx->timerfd, 1 /* forRead */ , 0, 0);
+	if (res < 0)
+	{
+		actx_error(actx, "checking kqueue for timeout: %m");
+		return -1;
+	}
+
+	return (res > 0);
+#endif
+
+	actx_error(actx, "libpq does not support timers on this platform");
+	return -1;
+}
+
+/*
+ * Adds or removes timeouts from the multiplexer set, as directed by the
+ * libcurl multi handle.
+ */
+static int
+register_timer(CURLM *curlm, long timeout, void *ctx)
+{
+	struct async_ctx *actx = ctx;
+
+	/*
+	 * There might be an optimization opportunity here: if timeout == 0, we
+	 * could signal drive_request to immediately call
+	 * curl_multi_socket_action, rather than returning all the way up the
+	 * stack only to come right back. But it's not clear that the additional
+	 * code complexity is worth it.
+	 */
+	if (!set_timer(actx, timeout))
+		return -1;				/* actx_error already called */
+
+	return 0;
+}
+
+/*
+ * Prints Curl request debugging information to stderr.
+ *
+ * Note that this will expose a number of critical secrets, so users have to opt
+ * into this (see PGOAUTHDEBUG).
+ */
+static int
+debug_callback(CURL *handle, curl_infotype type, char *data, size_t size,
+			   void *clientp)
+{
+	const char *prefix;
+	bool		printed_prefix = false;
+	PQExpBufferData buf;
+
+	/* Prefixes are modeled off of the default libcurl debug output. */
+	switch (type)
+	{
+		case CURLINFO_TEXT:
+			prefix = "*";
+			break;
+
+		case CURLINFO_HEADER_IN:	/* fall through */
+		case CURLINFO_DATA_IN:
+			prefix = "<";
+			break;
+
+		case CURLINFO_HEADER_OUT:	/* fall through */
+		case CURLINFO_DATA_OUT:
+			prefix = ">";
+			break;
+
+		default:
+			return 0;
+	}
+
+	initPQExpBuffer(&buf);
+
+	/*
+	 * Split the output into lines for readability; sometimes multiple headers
+	 * are included in a single call. We also don't allow unprintable ASCII
+	 * through without a basic <XX> escape.
+	 */
+	for (int i = 0; i < size; i++)
+	{
+		char		c = data[i];
+
+		if (!printed_prefix)
+		{
+			appendPQExpBuffer(&buf, "[libcurl] %s ", prefix);
+			printed_prefix = true;
+		}
+
+		if (c >= 0x20 && c <= 0x7E)
+			appendPQExpBufferChar(&buf, c);
+		else if ((type == CURLINFO_HEADER_IN
+				  || type == CURLINFO_HEADER_OUT
+				  || type == CURLINFO_TEXT)
+				 && (c == '\r' || c == '\n'))
+		{
+			/*
+			 * Don't bother emitting <0D><0A> for headers and text; it's not
+			 * helpful noise.
+			 */
+		}
+		else
+			appendPQExpBuffer(&buf, "<%02X>", c);
+
+		if (c == '\n')
+		{
+			appendPQExpBufferChar(&buf, c);
+			printed_prefix = false;
+		}
+	}
+
+	if (printed_prefix)
+		appendPQExpBufferChar(&buf, '\n');	/* finish the line */
+
+	fprintf(stderr, "%s", buf.data);
+	termPQExpBuffer(&buf);
+	return 0;
+}
+
+/*
+ * Initializes the two libcurl handles in the async_ctx. The multi handle,
+ * actx->curlm, is what drives the asynchronous engine and tells us what to do
+ * next. The easy handle, actx->curl, encapsulates the state for a single
+ * request/response. It's added to the multi handle as needed, during
+ * start_request().
+ */
+static bool
+setup_curl_handles(struct async_ctx *actx)
+{
+	/*
+	 * Create our multi handle. This encapsulates the entire conversation with
+	 * libcurl for this connection.
+	 */
+	actx->curlm = curl_multi_init();
+	if (!actx->curlm)
+	{
+		/* We don't get a lot of feedback on the failure reason. */
+		actx_error(actx, "failed to create libcurl multi handle");
+		return false;
+	}
+
+	/*
+	 * The multi handle tells us what to wait on using two callbacks. These
+	 * will manipulate actx->mux as needed.
+	 */
+	CHECK_MSETOPT(actx, CURLMOPT_SOCKETFUNCTION, register_socket, return false);
+	CHECK_MSETOPT(actx, CURLMOPT_SOCKETDATA, actx, return false);
+	CHECK_MSETOPT(actx, CURLMOPT_TIMERFUNCTION, register_timer, return false);
+	CHECK_MSETOPT(actx, CURLMOPT_TIMERDATA, actx, return false);
+
+	/*
+	 * Set up an easy handle. All of our requests are made serially, so we
+	 * only ever need to keep track of one.
+	 */
+	actx->curl = curl_easy_init();
+	if (!actx->curl)
+	{
+		actx_error(actx, "failed to create libcurl handle");
+		return false;
+	}
+
+	/*
+	 * Multi-threaded applications must set CURLOPT_NOSIGNAL. This requires us
+	 * to handle the possibility of SIGPIPE ourselves using pq_block_sigpipe;
+	 * see pg_fe_run_oauth_flow().
+	 *
+	 * NB: If libcurl is not built against a friendly DNS resolver (c-ares or
+	 * threaded), setting this option prevents DNS lookups from timing out
+	 * correctly. We warn about this situation at configure time.
+	 *
+	 * TODO: Perhaps there's a clever way to warn the user about synchronous
+	 * DNS at runtime too? It's not immediately clear how to do that in a
+	 * helpful way: for many standard single-threaded use cases, the user
+	 * might not care at all, so spraying warnings to stderr would probably do
+	 * more harm than good.
+	 */
+	CHECK_SETOPT(actx, CURLOPT_NOSIGNAL, 1L, return false);
+
+	if (actx->debugging)
+	{
+		/*
+		 * Set a callback for retrieving error information from libcurl, the
+		 * function only takes effect when CURLOPT_VERBOSE has been set so
+		 * make sure the order is kept.
+		 */
+		CHECK_SETOPT(actx, CURLOPT_DEBUGFUNCTION, debug_callback, return false);
+		CHECK_SETOPT(actx, CURLOPT_VERBOSE, 1L, return false);
+	}
+
+	CHECK_SETOPT(actx, CURLOPT_ERRORBUFFER, actx->curl_err, return false);
+
+	/*
+	 * Only HTTPS is allowed. (Debug mode additionally allows HTTP; this is
+	 * intended for testing only.)
+	 *
+	 * There's a bit of unfortunate complexity around the choice of
+	 * CURLoption. CURLOPT_PROTOCOLS is deprecated in modern Curls, but its
+	 * replacement didn't show up until relatively recently.
+	 */
+	{
+#if CURL_AT_LEAST_VERSION(7, 85, 0)
+		const CURLoption popt = CURLOPT_PROTOCOLS_STR;
+		const char *protos = "https";
+		const char *const unsafe = "https,http";
+#else
+		const CURLoption popt = CURLOPT_PROTOCOLS;
+		long		protos = CURLPROTO_HTTPS;
+		const long	unsafe = CURLPROTO_HTTPS | CURLPROTO_HTTP;
+#endif
+
+		if (actx->debugging)
+			protos = unsafe;
+
+		CHECK_SETOPT(actx, popt, protos, return false);
+	}
+
+	/*
+	 * If we're in debug mode, allow the developer to change the trusted CA
+	 * list. For now, this is not something we expose outside of the UNSAFE
+	 * mode, because it's not clear that it's useful in production: both libpq
+	 * and the user's browser must trust the same authorization servers for
+	 * the flow to work at all, so any changes to the roots are likely to be
+	 * done system-wide.
+	 */
+	if (actx->debugging)
+	{
+		const char *env;
+
+		if ((env = getenv("PGOAUTHCAFILE")) != NULL)
+			CHECK_SETOPT(actx, CURLOPT_CAINFO, env, return false);
+	}
+
+	/*
+	 * Suppress the Accept header to make our request as minimal as possible.
+	 * (Ideally we would set it to "application/json" instead, but OpenID is
+	 * pretty strict when it comes to provider behavior, so we have to check
+	 * what comes back anyway.)
+	 */
+	actx->headers = curl_slist_append(actx->headers, "Accept:");
+	if (actx->headers == NULL)
+	{
+		actx_error(actx, "out of memory");
+		return false;
+	}
+	CHECK_SETOPT(actx, CURLOPT_HTTPHEADER, actx->headers, return false);
+
+	return true;
+}
+
+/*
+ * Generic HTTP Request Handlers
+ */
+
+/*
+ * Response callback from libcurl which appends the response body into
+ * actx->work_data (see start_request()). The maximum size of the data is
+ * defined by CURL_MAX_WRITE_SIZE which by default is 16kb (and can only be
+ * changed by recompiling libcurl).
+ */
+static size_t
+append_data(char *buf, size_t size, size_t nmemb, void *userdata)
+{
+	struct async_ctx *actx = userdata;
+	PQExpBuffer resp = &actx->work_data;
+	size_t		len = size * nmemb;
+
+	/* In case we receive data over the threshold, abort the transfer */
+	if ((resp->len + len) > MAX_OAUTH_RESPONSE_SIZE)
+	{
+		actx_error(actx, "response is too large");
+		return 0;
+	}
+
+	/* The data passed from libcurl is not null-terminated */
+	appendBinaryPQExpBuffer(resp, buf, len);
+
+	/*
+	 * Signal an error in order to abort the transfer in case we ran out of
+	 * memory in accepting the data.
+	 */
+	if (PQExpBufferBroken(resp))
+	{
+		actx_error(actx, "out of memory");
+		return 0;
+	}
+
+	return len;
+}
+
+/*
+ * Begins an HTTP request on the multi handle. The caller should have set up all
+ * request-specific options on actx->curl first. The server's response body will
+ * be accumulated in actx->work_data (which will be reset, so don't store
+ * anything important there across this call).
+ *
+ * Once a request is queued, it can be driven to completion via drive_request().
+ * If actx->running is zero upon return, the request has already finished and
+ * drive_request() can be called without returning control to the client.
+ */
+static bool
+start_request(struct async_ctx *actx)
+{
+	CURLMcode	err;
+
+	resetPQExpBuffer(&actx->work_data);
+	CHECK_SETOPT(actx, CURLOPT_WRITEFUNCTION, append_data, return false);
+	CHECK_SETOPT(actx, CURLOPT_WRITEDATA, actx, return false);
+
+	err = curl_multi_add_handle(actx->curlm, actx->curl);
+	if (err)
+	{
+		actx_error(actx, "failed to queue HTTP request: %s",
+				   curl_multi_strerror(err));
+		return false;
+	}
+
+	/*
+	 * actx->running tracks the number of running handles, so we can
+	 * immediately call back if no waiting is needed.
+	 *
+	 * Even though this is nominally an asynchronous process, there are some
+	 * operations that can synchronously fail by this point (e.g. connections
+	 * to closed local ports) or even synchronously succeed if the stars align
+	 * (all the libcurl connection caches hit and the server is fast).
+	 */
+	err = curl_multi_socket_action(actx->curlm, CURL_SOCKET_TIMEOUT, 0, &actx->running);
+	if (err)
+	{
+		actx_error(actx, "asynchronous HTTP request failed: %s",
+				   curl_multi_strerror(err));
+		return false;
+	}
+
+	return true;
+}
+
+/*
+ * CURL_IGNORE_DEPRECATION was added in 7.87.0. If it's not defined, we can make
+ * it a no-op.
+ */
+#ifndef CURL_IGNORE_DEPRECATION
+#define CURL_IGNORE_DEPRECATION(x) x
+#endif
+
+/*
+ * Drives the multi handle towards completion. The caller should have already
+ * set up an asynchronous request via start_request().
+ */
+static PostgresPollingStatusType
+drive_request(struct async_ctx *actx)
+{
+	CURLMcode	err;
+	CURLMsg    *msg;
+	int			msgs_left;
+	bool		done;
+
+	if (actx->running)
+	{
+		/*---
+		 * There's an async request in progress. Pump the multi handle.
+		 *
+		 * curl_multi_socket_all() is officially deprecated, because it's
+		 * inefficient and pointless if your event loop has already handed you
+		 * the exact sockets that are ready. But that's not our use case --
+		 * our client has no way to tell us which sockets are ready. (They
+		 * don't even know there are sockets to begin with.)
+		 *
+		 * We can grab the list of triggered events from the multiplexer
+		 * ourselves, but that's effectively what curl_multi_socket_all() is
+		 * going to do. And there are currently no plans for the Curl project
+		 * to remove or break this API, so ignore the deprecation. See
+		 *
+		 *    https://curl.se/mail/lib-2024-11/0028.html
+		 *
+		 */
+		CURL_IGNORE_DEPRECATION(
+			err = curl_multi_socket_all(actx->curlm, &actx->running);
+		)
+
+		if (err)
+		{
+			actx_error(actx, "asynchronous HTTP request failed: %s",
+					   curl_multi_strerror(err));
+			return PGRES_POLLING_FAILED;
+		}
+
+		if (actx->running)
+		{
+			/* We'll come back again. */
+			return PGRES_POLLING_READING;
+		}
+	}
+
+	done = false;
+	while ((msg = curl_multi_info_read(actx->curlm, &msgs_left)) != NULL)
+	{
+		if (msg->msg != CURLMSG_DONE)
+		{
+			/*
+			 * Future libcurl versions may define new message types; we don't
+			 * know how to handle them, so we'll ignore them.
+			 */
+			continue;
+		}
+
+		/* First check the status of the request itself. */
+		if (msg->data.result != CURLE_OK)
+		{
+			/*
+			 * If a more specific error hasn't already been reported, use
+			 * libcurl's description.
+			 */
+			if (actx->errbuf.len == 0)
+				actx_error_str(actx, curl_easy_strerror(msg->data.result));
+
+			return PGRES_POLLING_FAILED;
+		}
+
+		/* Now remove the finished handle; we'll add it back later if needed. */
+		err = curl_multi_remove_handle(actx->curlm, msg->easy_handle);
+		if (err)
+		{
+			actx_error(actx, "libcurl easy handle removal failed: %s",
+					   curl_multi_strerror(err));
+			return PGRES_POLLING_FAILED;
+		}
+
+		done = true;
+	}
+
+	/* Sanity check. */
+	if (!done)
+	{
+		actx_error(actx, "no result was retrieved for the finished handle");
+		return PGRES_POLLING_FAILED;
+	}
+
+	return PGRES_POLLING_OK;
+}
+
+/*
+ * URL-Encoding Helpers
+ */
+
+/*
+ * Encodes a string using the application/x-www-form-urlencoded format, and
+ * appends it to the given buffer.
+ */
+static void
+append_urlencoded(PQExpBuffer buf, const char *s)
+{
+	char	   *escaped;
+	char	   *haystack;
+	char	   *match;
+
+	/* The first parameter to curl_easy_escape is deprecated by Curl */
+	escaped = curl_easy_escape(NULL, s, 0);
+	if (!escaped)
+	{
+		termPQExpBuffer(buf);	/* mark the buffer broken */
+		return;
+	}
+
+	/*
+	 * curl_easy_escape() almost does what we want, but we need the
+	 * query-specific flavor which uses '+' instead of '%20' for spaces. The
+	 * Curl command-line tool does this with a simple search-and-replace, so
+	 * follow its lead.
+	 */
+	haystack = escaped;
+
+	while ((match = strstr(haystack, "%20")) != NULL)
+	{
+		/* Append the unmatched portion, followed by the plus sign. */
+		appendBinaryPQExpBuffer(buf, haystack, match - haystack);
+		appendPQExpBufferChar(buf, '+');
+
+		/* Keep searching after the match. */
+		haystack = match + 3 /* strlen("%20") */ ;
+	}
+
+	/* Push the remainder of the string onto the buffer. */
+	appendPQExpBufferStr(buf, haystack);
+
+	curl_free(escaped);
+}
+
+/*
+ * Convenience wrapper for encoding a single string. Returns NULL on allocation
+ * failure.
+ */
+static char *
+urlencode(const char *s)
+{
+	PQExpBufferData buf;
+
+	initPQExpBuffer(&buf);
+	append_urlencoded(&buf, s);
+
+	return PQExpBufferDataBroken(buf) ? NULL : buf.data;
+}
+
+/*
+ * Appends a key/value pair to the end of an application/x-www-form-urlencoded
+ * list.
+ */
+static void
+build_urlencoded(PQExpBuffer buf, const char *key, const char *value)
+{
+	if (buf->len)
+		appendPQExpBufferChar(buf, '&');
+
+	append_urlencoded(buf, key);
+	appendPQExpBufferChar(buf, '=');
+	append_urlencoded(buf, value);
+}
+
+/*
+ * Specific HTTP Request Handlers
+ *
+ * This is finally the beginning of the actual application logic. Generally
+ * speaking, a single request consists of a start_* and a finish_* step, with
+ * drive_request() pumping the machine in between.
+ */
+
+/*
+ * Queue an OpenID Provider Configuration Request:
+ *
+ *     https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationRequest
+ *     https://www.rfc-editor.org/rfc/rfc8414#section-3.1
+ *
+ * This is done first to get the endpoint URIs we need to contact and to make
+ * sure the provider provides a device authorization flow. finish_discovery()
+ * will fill in actx->provider.
+ */
+static bool
+start_discovery(struct async_ctx *actx, const char *discovery_uri)
+{
+	CHECK_SETOPT(actx, CURLOPT_HTTPGET, 1L, return false);
+	CHECK_SETOPT(actx, CURLOPT_URL, discovery_uri, return false);
+
+	return start_request(actx);
+}
+
+static bool
+finish_discovery(struct async_ctx *actx)
+{
+	long		response_code;
+
+	/*----
+	 * Now check the response. OIDC Discovery 1.0 is pretty strict:
+	 *
+	 *     A successful response MUST use the 200 OK HTTP status code and
+	 *     return a JSON object using the application/json content type that
+	 *     contains a set of Claims as its members that are a subset of the
+	 *     Metadata values defined in Section 3.
+	 *
+	 * Compared to standard HTTP semantics, this makes life easy -- we don't
+	 * need to worry about redirections (which would call the Issuer host
+	 * validation into question), or non-authoritative responses, or any other
+	 * complications.
+	 */
+	CHECK_GETINFO(actx, CURLINFO_RESPONSE_CODE, &response_code, return false);
+
+	if (response_code != 200)
+	{
+		actx_error(actx, "unexpected response code %ld", response_code);
+		return false;
+	}
+
+	/*
+	 * Pull the fields we care about from the document.
+	 */
+	actx->errctx = "failed to parse OpenID discovery document";
+	if (!parse_provider(actx, &actx->provider))
+		return false;			/* error message already set */
+
+	/*
+	 * Fill in any defaults for OPTIONAL/RECOMMENDED fields we care about.
+	 */
+	if (!actx->provider.grant_types_supported)
+	{
+		/*
+		 * Per Section 3, the default is ["authorization_code", "implicit"].
+		 */
+		struct curl_slist *temp = actx->provider.grant_types_supported;
+
+		temp = curl_slist_append(temp, "authorization_code");
+		if (temp)
+		{
+			temp = curl_slist_append(temp, "implicit");
+		}
+
+		if (!temp)
+		{
+			actx_error(actx, "out of memory");
+			return false;
+		}
+
+		actx->provider.grant_types_supported = temp;
+	}
+
+	return true;
+}
+
+/*
+ * Ensure that the discovery document is provided by the expected issuer.
+ * Currently, issuers are statically configured in the connection string.
+ */
+static bool
+check_issuer(struct async_ctx *actx, PGconn *conn)
+{
+	const struct provider *provider = &actx->provider;
+
+	Assert(conn->oauth_issuer_id);	/* ensured by setup_oauth_parameters() */
+	Assert(provider->issuer);	/* ensured by parse_provider() */
+
+	/*---
+	 * We require strict equality for issuer identifiers -- no path or case
+	 * normalization, no substitution of default ports and schemes, etc. This
+	 * is done to match the rules in OIDC Discovery Sec. 4.3 for config
+	 * validation:
+	 *
+	 *    The issuer value returned MUST be identical to the Issuer URL that
+	 *    was used as the prefix to /.well-known/openid-configuration to
+	 *    retrieve the configuration information.
+	 *
+	 * as well as the rules set out in RFC 9207 for avoiding mix-up attacks:
+	 *
+	 *    Clients MUST then [...] compare the result to the issuer identifier
+	 *    of the authorization server where the authorization request was
+	 *    sent to. This comparison MUST use simple string comparison as defined
+	 *    in Section 6.2.1 of [RFC3986].
+	 */
+	if (strcmp(conn->oauth_issuer_id, provider->issuer) != 0)
+	{
+		actx_error(actx,
+				   "the issuer identifier (%s) does not match oauth_issuer (%s)",
+				   provider->issuer, conn->oauth_issuer_id);
+		return false;
+	}
+
+	return true;
+}
+
+#define HTTPS_SCHEME "https://"
+#define OAUTH_GRANT_TYPE_DEVICE_CODE "urn:ietf:params:oauth:grant-type:device_code"
+
+/*
+ * Ensure that the provider supports the Device Authorization flow (i.e. it
+ * provides an authorization endpoint, and both the token and authorization
+ * endpoint URLs seem reasonable).
+ */
+static bool
+check_for_device_flow(struct async_ctx *actx)
+{
+	const struct provider *provider = &actx->provider;
+
+	Assert(provider->issuer);	/* ensured by parse_provider() */
+	Assert(provider->token_endpoint);	/* ensured by parse_provider() */
+
+	if (!provider->device_authorization_endpoint)
+	{
+		actx_error(actx,
+				   "issuer \"%s\" does not provide a device authorization endpoint",
+				   provider->issuer);
+		return false;
+	}
+
+	/*
+	 * The original implementation checked that OAUTH_GRANT_TYPE_DEVICE_CODE
+	 * was present in the discovery document's grant_types_supported list. MS
+	 * Entra does not advertise this grant type, though, and since it doesn't
+	 * make sense to stand up a device_authorization_endpoint without also
+	 * accepting device codes at the token_endpoint, that's the only thing we
+	 * currently require.
+	 */
+
+	/*
+	 * Although libcurl will fail later if the URL contains an unsupported
+	 * scheme, that error message is going to be a bit opaque. This is a
+	 * decent time to bail out if we're not using HTTPS for the endpoints
+	 * we'll use for the flow.
+	 */
+	if (!actx->debugging)
+	{
+		if (pg_strncasecmp(provider->device_authorization_endpoint,
+						   HTTPS_SCHEME, strlen(HTTPS_SCHEME)) != 0)
+		{
+			actx_error(actx,
+					   "device authorization endpoint \"%s\" must use HTTPS",
+					   provider->device_authorization_endpoint);
+			return false;
+		}
+
+		if (pg_strncasecmp(provider->token_endpoint,
+						   HTTPS_SCHEME, strlen(HTTPS_SCHEME)) != 0)
+		{
+			actx_error(actx,
+					   "token endpoint \"%s\" must use HTTPS",
+					   provider->token_endpoint);
+			return false;
+		}
+	}
+
+	return true;
+}
+
+/*
+ * Adds the client ID (and secret, if provided) to the current request, using
+ * either HTTP headers or the request body.
+ */
+static bool
+add_client_identification(struct async_ctx *actx, PQExpBuffer reqbody, PGconn *conn)
+{
+	bool		success = false;
+	char	   *username = NULL;
+	char	   *password = NULL;
+
+	if (conn->oauth_client_secret)	/* Zero-length secrets are permitted! */
+	{
+		/*----
+		 * Use HTTP Basic auth to send the client_id and secret. Per RFC 6749,
+		 * Sec. 2.3.1,
+		 *
+		 *   Including the client credentials in the request-body using the
+		 *   two parameters is NOT RECOMMENDED and SHOULD be limited to
+		 *   clients unable to directly utilize the HTTP Basic authentication
+		 *   scheme (or other password-based HTTP authentication schemes).
+		 *
+		 * Additionally:
+		 *
+		 *   The client identifier is encoded using the
+		 *   "application/x-www-form-urlencoded" encoding algorithm per Appendix
+		 *   B, and the encoded value is used as the username; the client
+		 *   password is encoded using the same algorithm and used as the
+		 *   password.
+		 *
+		 * (Appendix B modifies application/x-www-form-urlencoded by requiring
+		 * an initial UTF-8 encoding step. Since the client ID and secret must
+		 * both be 7-bit ASCII -- RFC 6749 Appendix A -- we don't worry about
+		 * that in this function.)
+		 *
+		 * client_id is not added to the request body in this case. Not only
+		 * would it be redundant, but some providers in the wild (e.g. Okta)
+		 * refuse to accept it.
+		 */
+		username = urlencode(conn->oauth_client_id);
+		password = urlencode(conn->oauth_client_secret);
+
+		if (!username || !password)
+		{
+			actx_error(actx, "out of memory");
+			goto cleanup;
+		}
+
+		CHECK_SETOPT(actx, CURLOPT_HTTPAUTH, CURLAUTH_BASIC, goto cleanup);
+		CHECK_SETOPT(actx, CURLOPT_USERNAME, username, goto cleanup);
+		CHECK_SETOPT(actx, CURLOPT_PASSWORD, password, goto cleanup);
+
+		actx->used_basic_auth = true;
+	}
+	else
+	{
+		/*
+		 * If we're not otherwise authenticating, client_id is REQUIRED in the
+		 * request body.
+		 */
+		build_urlencoded(reqbody, "client_id", conn->oauth_client_id);
+
+		CHECK_SETOPT(actx, CURLOPT_HTTPAUTH, CURLAUTH_NONE, goto cleanup);
+		actx->used_basic_auth = false;
+	}
+
+	success = true;
+
+cleanup:
+	free(username);
+	free(password);
+
+	return success;
+}
+
+/*
+ * Queue a Device Authorization Request:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc8628#section-3.1
+ *
+ * This is the second step. We ask the provider to verify the end user out of
+ * band and authorize us to act on their behalf; it will give us the required
+ * nonces for us to later poll the request status, which we'll grab in
+ * finish_device_authz().
+ */
+static bool
+start_device_authz(struct async_ctx *actx, PGconn *conn)
+{
+	const char *device_authz_uri = actx->provider.device_authorization_endpoint;
+	PQExpBuffer work_buffer = &actx->work_data;
+
+	Assert(conn->oauth_client_id);	/* ensured by setup_oauth_parameters() */
+	Assert(device_authz_uri);	/* ensured by check_for_device_flow() */
+
+	/* Construct our request body. */
+	resetPQExpBuffer(work_buffer);
+	if (conn->oauth_scope && conn->oauth_scope[0])
+		build_urlencoded(work_buffer, "scope", conn->oauth_scope);
+
+	if (!add_client_identification(actx, work_buffer, conn))
+		return false;
+
+	if (PQExpBufferBroken(work_buffer))
+	{
+		actx_error(actx, "out of memory");
+		return false;
+	}
+
+	/* Make our request. */
+	CHECK_SETOPT(actx, CURLOPT_URL, device_authz_uri, return false);
+	CHECK_SETOPT(actx, CURLOPT_COPYPOSTFIELDS, work_buffer->data, return false);
+
+	return start_request(actx);
+}
+
+static bool
+finish_device_authz(struct async_ctx *actx)
+{
+	long		response_code;
+
+	CHECK_GETINFO(actx, CURLINFO_RESPONSE_CODE, &response_code, return false);
+
+	/*
+	 * Per RFC 8628, Section 3, a successful device authorization response
+	 * uses 200 OK.
+	 */
+	if (response_code == 200)
+	{
+		actx->errctx = "failed to parse device authorization";
+		if (!parse_device_authz(actx, &actx->authz))
+			return false;		/* error message already set */
+
+		return true;
+	}
+
+	/*
+	 * The device authorization endpoint uses the same error response as the
+	 * token endpoint, so the error handling roughly follows
+	 * finish_token_request(). The key difference is that an error here is
+	 * immediately fatal.
+	 */
+	if (response_code == 400 || response_code == 401)
+	{
+		struct token_error err = {0};
+
+		if (!parse_token_error(actx, &err))
+		{
+			free_token_error(&err);
+			return false;
+		}
+
+		/* Copy the token error into the context error buffer */
+		record_token_error(actx, &err);
+
+		free_token_error(&err);
+		return false;
+	}
+
+	/* Any other response codes are considered invalid */
+	actx_error(actx, "unexpected response code %ld", response_code);
+	return false;
+}
+
+/*
+ * Queue an Access Token Request:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc6749#section-4.1.3
+ *
+ * This is the final step. We continually poll the token endpoint to see if the
+ * user has authorized us yet. finish_token_request() will pull either the token
+ * or a (ideally temporary) error status from the provider.
+ */
+static bool
+start_token_request(struct async_ctx *actx, PGconn *conn)
+{
+	const char *token_uri = actx->provider.token_endpoint;
+	const char *device_code = actx->authz.device_code;
+	PQExpBuffer work_buffer = &actx->work_data;
+
+	Assert(conn->oauth_client_id);	/* ensured by setup_oauth_parameters() */
+	Assert(token_uri);			/* ensured by parse_provider() */
+	Assert(device_code);		/* ensured by parse_device_authz() */
+
+	/* Construct our request body. */
+	resetPQExpBuffer(work_buffer);
+	build_urlencoded(work_buffer, "device_code", device_code);
+	build_urlencoded(work_buffer, "grant_type", OAUTH_GRANT_TYPE_DEVICE_CODE);
+
+	if (!add_client_identification(actx, work_buffer, conn))
+		return false;
+
+	if (PQExpBufferBroken(work_buffer))
+	{
+		actx_error(actx, "out of memory");
+		return false;
+	}
+
+	/* Make our request. */
+	CHECK_SETOPT(actx, CURLOPT_URL, token_uri, return false);
+	CHECK_SETOPT(actx, CURLOPT_COPYPOSTFIELDS, work_buffer->data, return false);
+
+	return start_request(actx);
+}
+
+static bool
+finish_token_request(struct async_ctx *actx, struct token *tok)
+{
+	long		response_code;
+
+	CHECK_GETINFO(actx, CURLINFO_RESPONSE_CODE, &response_code, return false);
+
+	/*
+	 * Per RFC 6749, Section 5, a successful response uses 200 OK.
+	 */
+	if (response_code == 200)
+	{
+		actx->errctx = "failed to parse access token response";
+		if (!parse_access_token(actx, tok))
+			return false;		/* error message already set */
+
+		return true;
+	}
+
+	/*
+	 * An error response uses either 400 Bad Request or 401 Unauthorized.
+	 * There are references online to implementations using 403 for error
+	 * return which would violate the specification. For now we stick to the
+	 * specification but we might have to revisit this.
+	 */
+	if (response_code == 400 || response_code == 401)
+	{
+		if (!parse_token_error(actx, &tok->err))
+			return false;
+
+		return true;
+	}
+
+	/* Any other response codes are considered invalid */
+	actx_error(actx, "unexpected response code %ld", response_code);
+	return false;
+}
+
+/*
+ * Finishes the token request and examines the response. If the flow has
+ * completed, a valid token will be returned via the parameter list. Otherwise,
+ * the token parameter remains unchanged, and the caller needs to wait for
+ * another interval (which will have been increased in response to a slow_down
+ * message from the server) before starting a new token request.
+ *
+ * False is returned only for permanent error conditions.
+ */
+static bool
+handle_token_response(struct async_ctx *actx, char **token)
+{
+	bool		success = false;
+	struct token tok = {0};
+	const struct token_error *err;
+
+	if (!finish_token_request(actx, &tok))
+		goto token_cleanup;
+
+	/* A successful token request gives either a token or an in-band error. */
+	Assert(tok.access_token || tok.err.error);
+
+	if (tok.access_token)
+	{
+		*token = tok.access_token;
+		tok.access_token = NULL;
+
+		success = true;
+		goto token_cleanup;
+	}
+
+	/*
+	 * authorization_pending and slow_down are the only acceptable errors;
+	 * anything else and we bail. These are defined in RFC 8628, Sec. 3.5.
+	 */
+	err = &tok.err;
+	if (strcmp(err->error, "authorization_pending") != 0 &&
+		strcmp(err->error, "slow_down") != 0)
+	{
+		record_token_error(actx, err);
+		goto token_cleanup;
+	}
+
+	/*
+	 * A slow_down error requires us to permanently increase our retry
+	 * interval by five seconds.
+	 */
+	if (strcmp(err->error, "slow_down") == 0)
+	{
+		int			prev_interval = actx->authz.interval;
+
+		actx->authz.interval += 5;
+		if (actx->authz.interval < prev_interval)
+		{
+			actx_error(actx, "slow_down interval overflow");
+			goto token_cleanup;
+		}
+	}
+
+	success = true;
+
+token_cleanup:
+	free_token(&tok);
+	return success;
+}
+
+/*
+ * Displays a device authorization prompt for action by the end user, either via
+ * the PQauthDataHook, or by a message on standard error if no hook is set.
+ */
+static bool
+prompt_user(struct async_ctx *actx, PGconn *conn)
+{
+	int			res;
+	PGpromptOAuthDevice prompt = {
+		.verification_uri = actx->authz.verification_uri,
+		.user_code = actx->authz.user_code,
+		.verification_uri_complete = actx->authz.verification_uri_complete,
+		.expires_in = actx->authz.expires_in,
+	};
+
+	res = PQauthDataHook(PQAUTHDATA_PROMPT_OAUTH_DEVICE, conn, &prompt);
+
+	if (!res)
+	{
+		/*
+		 * translator: The first %s is a URL for the user to visit in a
+		 * browser, and the second %s is a code to be copy-pasted there.
+		 */
+		fprintf(stderr, libpq_gettext("Visit %s and enter the code: %s\n"),
+				prompt.verification_uri, prompt.user_code);
+	}
+	else if (res < 0)
+	{
+		actx_error(actx, "device prompt failed");
+		return false;
+	}
+
+	return true;
+}
+
+/*
+ * Calls curl_global_init() in a thread-safe way.
+ *
+ * libcurl has stringent requirements for the thread context in which you call
+ * curl_global_init(), because it's going to try initializing a bunch of other
+ * libraries (OpenSSL, Winsock, etc). Recent versions of libcurl have improved
+ * the thread-safety situation, but there's a chicken-and-egg problem at
+ * runtime: you can't check the thread safety until you've initialized libcurl,
+ * which you can't do from within a thread unless you know it's thread-safe...
+ *
+ * Returns true if initialization was successful. Successful or not, this
+ * function will not try to reinitialize Curl on successive calls.
+ */
+static bool
+initialize_curl(PGconn *conn)
+{
+	/*
+	 * Don't let the compiler play tricks with this variable. In the
+	 * HAVE_THREADSAFE_CURL_GLOBAL_INIT case, we don't care if two threads
+	 * enter simultaneously, but we do care if this gets set transiently to
+	 * PG_BOOL_YES/NO in cases where that's not the final answer.
+	 */
+	static volatile PGTernaryBool init_successful = PG_BOOL_UNKNOWN;
+#if HAVE_THREADSAFE_CURL_GLOBAL_INIT
+	curl_version_info_data *info;
+#endif
+
+#if !HAVE_THREADSAFE_CURL_GLOBAL_INIT
+
+	/*
+	 * Lock around the whole function. If a libpq client performs its own work
+	 * with libcurl, it must either ensure that Curl is initialized safely
+	 * before calling us (in which case our call will be a no-op), or else it
+	 * must guard its own calls to curl_global_init() with a registered
+	 * threadlock handler. See PQregisterThreadLock().
+	 */
+	pglock_thread();
+#endif
+
+	/*
+	 * Skip initialization if we've already done it. (Curl tracks the number
+	 * of calls; there's no point in incrementing the counter every time we
+	 * connect.)
+	 */
+	if (init_successful == PG_BOOL_YES)
+		goto done;
+	else if (init_successful == PG_BOOL_NO)
+	{
+		libpq_append_conn_error(conn,
+								"curl_global_init previously failed during OAuth setup");
+		goto done;
+	}
+
+	/*
+	 * We know we've already initialized Winsock by this point (see
+	 * pqMakeEmptyPGconn()), so we should be able to safely skip that bit. But
+	 * we have to tell libcurl to initialize everything else, because other
+	 * pieces of our client executable may already be using libcurl for their
+	 * own purposes. If we initialize libcurl with only a subset of its
+	 * features, we could break those other clients nondeterministically, and
+	 * that would probably be a nightmare to debug.
+	 *
+	 * If some other part of the program has already called this, it's a
+	 * no-op.
+	 */
+	if (curl_global_init(CURL_GLOBAL_ALL & ~CURL_GLOBAL_WIN32) != CURLE_OK)
+	{
+		libpq_append_conn_error(conn,
+								"curl_global_init failed during OAuth setup");
+		init_successful = PG_BOOL_NO;
+		goto done;
+	}
+
+#if HAVE_THREADSAFE_CURL_GLOBAL_INIT
+
+	/*
+	 * If we determined at configure time that the Curl installation is
+	 * thread-safe, our job here is much easier. We simply initialize above
+	 * without any locking (concurrent or duplicated calls are fine in that
+	 * situation), then double-check to make sure the runtime setting agrees,
+	 * to try to catch silent downgrades.
+	 */
+	info = curl_version_info(CURLVERSION_NOW);
+	if (!(info->features & CURL_VERSION_THREADSAFE))
+	{
+		/*
+		 * 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.");
+		init_successful = PG_BOOL_NO;
+		goto done;
+	}
+#endif
+
+	init_successful = PG_BOOL_YES;
+
+done:
+#if !HAVE_THREADSAFE_CURL_GLOBAL_INIT
+	pgunlock_thread();
+#endif
+	return (init_successful == PG_BOOL_YES);
+}
+
+/*
+ * The core nonblocking libcurl implementation. This will be called several
+ * times to pump the async engine.
+ *
+ * The architecture is based on PQconnectPoll(). The first half drives the
+ * connection state forward as necessary, returning if we're not ready to
+ * proceed to the next step yet. The second half performs the actual transition
+ * between states.
+ *
+ * You can trace the overall OAuth flow through the second half. It's linear
+ * until we get to the end, where we flip back and forth between
+ * OAUTH_STEP_TOKEN_REQUEST and OAUTH_STEP_WAIT_INTERVAL to regularly ping the
+ * provider.
+ */
+static PostgresPollingStatusType
+pg_fe_run_oauth_flow_impl(PGconn *conn)
+{
+	fe_oauth_state *state = conn->sasl_state;
+	struct async_ctx *actx;
+
+	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. */
+		conn->altsock = actx->mux;
+
+		switch (actx->step)
+		{
+			case OAUTH_STEP_INIT:
+				break;
+
+			case OAUTH_STEP_DISCOVERY:
+			case OAUTH_STEP_DEVICE_AUTHORIZATION:
+			case OAUTH_STEP_TOKEN_REQUEST:
+				{
+					PostgresPollingStatusType status;
+
+					status = drive_request(actx);
+
+					if (status == PGRES_POLLING_FAILED)
+						goto error_return;
+					else if (status != PGRES_POLLING_OK)
+					{
+						/* not done yet */
+						return status;
+					}
+
+					break;
+				}
+
+			case OAUTH_STEP_WAIT_INTERVAL:
+
+				/*
+				 * The client application is supposed to wait until our timer
+				 * expires before calling PQconnectPoll() again, but that
+				 * might not happen. To avoid sending a token request early,
+				 * check the timer before continuing.
+				 */
+				if (!timer_expired(actx))
+				{
+					conn->altsock = actx->timerfd;
+					return PGRES_POLLING_READING;
+				}
+
+				/* Disable the expired timer. */
+				if (!set_timer(actx, -1))
+					goto error_return;
+
+				break;
+		}
+
+		/*
+		 * Each case here must ensure that actx->running is set while we're
+		 * waiting on some asynchronous work. Most cases rely on
+		 * start_request() to do that for them.
+		 */
+		switch (actx->step)
+		{
+			case OAUTH_STEP_INIT:
+				actx->errctx = "failed to fetch OpenID discovery document";
+				if (!start_discovery(actx, conn->oauth_discovery_uri))
+					goto error_return;
+
+				actx->step = OAUTH_STEP_DISCOVERY;
+				break;
+
+			case OAUTH_STEP_DISCOVERY:
+				if (!finish_discovery(actx))
+					goto error_return;
+
+				if (!check_issuer(actx, conn))
+					goto error_return;
+
+				actx->errctx = "cannot run OAuth device authorization";
+				if (!check_for_device_flow(actx))
+					goto error_return;
+
+				actx->errctx = "failed to obtain device authorization";
+				if (!start_device_authz(actx, conn))
+					goto error_return;
+
+				actx->step = OAUTH_STEP_DEVICE_AUTHORIZATION;
+				break;
+
+			case OAUTH_STEP_DEVICE_AUTHORIZATION:
+				if (!finish_device_authz(actx))
+					goto error_return;
+
+				actx->errctx = "failed to obtain access token";
+				if (!start_token_request(actx, conn))
+					goto error_return;
+
+				actx->step = OAUTH_STEP_TOKEN_REQUEST;
+				break;
+
+			case OAUTH_STEP_TOKEN_REQUEST:
+				if (!handle_token_response(actx, &conn->oauth_token))
+					goto error_return;
+
+				if (!actx->user_prompted)
+				{
+					/*
+					 * Now that we know the token endpoint isn't broken, give
+					 * the user the login instructions.
+					 */
+					if (!prompt_user(actx, conn))
+						goto error_return;
+
+					actx->user_prompted = true;
+				}
+
+				if (conn->oauth_token)
+					break;		/* done! */
+
+				/*
+				 * Wait for the required interval before issuing the next
+				 * request.
+				 */
+				if (!set_timer(actx, actx->authz.interval * 1000))
+					goto error_return;
+
+				/*
+				 * No Curl requests are running, so we can simplify by having
+				 * the client wait directly on the timerfd rather than the
+				 * multiplexer.
+				 */
+				conn->altsock = actx->timerfd;
+
+				actx->step = OAUTH_STEP_WAIT_INTERVAL;
+				actx->running = 1;
+				break;
+
+			case OAUTH_STEP_WAIT_INTERVAL:
+				actx->errctx = "failed to obtain access token";
+				if (!start_token_request(actx, conn))
+					goto error_return;
+
+				actx->step = OAUTH_STEP_TOKEN_REQUEST;
+				break;
+		}
+
+		/*
+		 * The vast majority of the time, if we don't have a token at this
+		 * point, actx->running will be set. But there are some corner cases
+		 * where we can immediately loop back around; see start_request().
+		 */
+	} while (!conn->oauth_token && !actx->running);
+
+	/* If we've stored a token, we're done. Otherwise come back later. */
+	return conn->oauth_token ? PGRES_POLLING_OK : PGRES_POLLING_READING;
+
+error_return:
+
+	/*
+	 * Assemble the three parts of our error: context, body, and detail. See
+	 * also the documentation for struct async_ctx.
+	 */
+	if (actx->errctx)
+	{
+		appendPQExpBufferStr(&conn->errorMessage,
+							 libpq_gettext(actx->errctx));
+		appendPQExpBufferStr(&conn->errorMessage, ": ");
+	}
+
+	if (PQExpBufferDataBroken(actx->errbuf))
+		appendPQExpBufferStr(&conn->errorMessage,
+							 libpq_gettext("out of memory"));
+	else
+		appendPQExpBufferStr(&conn->errorMessage, actx->errbuf.data);
+
+	if (actx->curl_err[0])
+	{
+		size_t		len;
+
+		appendPQExpBuffer(&conn->errorMessage,
+						  " (libcurl: %s)", actx->curl_err);
+
+		/* Sometimes libcurl adds a newline to the error buffer. :( */
+		len = conn->errorMessage.len;
+		if (len >= 2 && conn->errorMessage.data[len - 2] == '\n')
+		{
+			conn->errorMessage.data[len - 2] = ')';
+			conn->errorMessage.data[len - 1] = '\0';
+			conn->errorMessage.len--;
+		}
+	}
+
+	appendPQExpBufferStr(&conn->errorMessage, "\n");
+
+	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.
+ */
+PostgresPollingStatusType
+pg_fe_run_oauth_flow(PGconn *conn)
+{
+	PostgresPollingStatusType result;
+#ifndef WIN32
+	sigset_t	osigset;
+	bool		sigpipe_pending;
+	bool		masked;
+
+	/*---
+	 * Ignore SIGPIPE on this thread during all Curl processing.
+	 *
+	 * Because we support multiple threads, we have to set up libcurl with
+	 * CURLOPT_NOSIGNAL, which disables its default global handling of
+	 * SIGPIPE. From the Curl docs:
+	 *
+	 *     libcurl makes an effort to never cause such SIGPIPE signals to
+	 *     trigger, but some operating systems have no way to avoid them and
+	 *     even on those that have there are some corner cases when they may
+	 *     still happen, contrary to our desire.
+	 *
+	 * Note that libcurl is also at the mercy of its DNS resolution and SSL
+	 * libraries; if any of them forget a MSG_NOSIGNAL then we're in trouble.
+	 * Modern platforms and libraries seem to get it right, so this is a
+	 * difficult corner case to exercise in practice, and unfortunately it's
+	 * not really clear whether it's necessary in all cases.
+	 */
+	masked = (pq_block_sigpipe(&osigset, &sigpipe_pending) == 0);
+#endif
+
+	result = pg_fe_run_oauth_flow_impl(conn);
+
+#ifndef WIN32
+	if (masked)
+	{
+		/*
+		 * Undo the SIGPIPE mask. Assume we may have gotten EPIPE (we have no
+		 * way of knowing at this level).
+		 */
+		pq_reset_sigpipe(&osigset, sigpipe_pending, true /* EPIPE, maybe */ );
+	}
+#endif
+
+	return result;
+}
diff --git a/src/interfaces/libpq/fe-auth-oauth.c b/src/interfaces/libpq/fe-auth-oauth.c
new file mode 100644
index 00000000000..fb1e9a1a8aa
--- /dev/null
+++ b/src/interfaces/libpq/fe-auth-oauth.c
@@ -0,0 +1,1163 @@
+/*-------------------------------------------------------------------------
+ *
+ * fe-auth-oauth.c
+ *	   The front-end (client) implementation of OAuth/OIDC authentication
+ *	   using the SASL OAUTHBEARER mechanism.
+ *
+ * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * IDENTIFICATION
+ *	  src/interfaces/libpq/fe-auth-oauth.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres_fe.h"
+
+#include "common/base64.h"
+#include "common/hmac.h"
+#include "common/jsonapi.h"
+#include "common/oauth-common.h"
+#include "fe-auth.h"
+#include "fe-auth-oauth.h"
+#include "mb/pg_wchar.h"
+
+/* The exported OAuth callback mechanism. */
+static void *oauth_init(PGconn *conn, const char *password,
+						const char *sasl_mechanism);
+static SASLStatus oauth_exchange(void *opaq, bool final,
+								 char *input, int inputlen,
+								 char **output, int *outputlen);
+static bool oauth_channel_bound(void *opaq);
+static void oauth_free(void *opaq);
+
+const pg_fe_sasl_mech pg_oauth_mech = {
+	oauth_init,
+	oauth_exchange,
+	oauth_channel_bound,
+	oauth_free,
+};
+
+/*
+ * Initializes mechanism state for OAUTHBEARER.
+ *
+ * For a full description of the API, see libpq/fe-auth-sasl.h.
+ */
+static void *
+oauth_init(PGconn *conn, const char *password,
+		   const char *sasl_mechanism)
+{
+	fe_oauth_state *state;
+
+	/*
+	 * We only support one SASL mechanism here; anything else is programmer
+	 * error.
+	 */
+	Assert(sasl_mechanism != NULL);
+	Assert(strcmp(sasl_mechanism, OAUTHBEARER_NAME) == 0);
+
+	state = calloc(1, sizeof(*state));
+	if (!state)
+		return NULL;
+
+	state->step = FE_OAUTH_INIT;
+	state->conn = conn;
+
+	return state;
+}
+
+/*
+ * Frees the state allocated by oauth_init().
+ *
+ * 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().
+ */
+static void
+oauth_free(void *opaq)
+{
+	fe_oauth_state *state = opaq;
+
+	/* Any async authentication state should have been cleaned up already. */
+	Assert(!state->async_ctx);
+
+	free(state);
+}
+
+#define kvsep "\x01"
+
+/*
+ * Constructs an OAUTHBEARER client initial response (RFC 7628, Sec. 3.1).
+ *
+ * If discover is true, the initial response will contain a request for the
+ * server's required OAuth parameters (Sec. 4.3). Otherwise, conn->token must
+ * be set; it will be sent as the connection's bearer token.
+ *
+ * Returns the response as a null-terminated string, or NULL on error.
+ */
+static char *
+client_initial_response(PGconn *conn, bool discover)
+{
+	static const char *const resp_format = "n,," kvsep "auth=%s%s" kvsep kvsep;
+
+	PQExpBufferData buf;
+	const char *authn_scheme;
+	char	   *response = NULL;
+	const char *token = conn->oauth_token;
+
+	if (discover)
+	{
+		/* Parameter discovery uses a completely empty auth value. */
+		authn_scheme = token = "";
+	}
+	else
+	{
+		/*
+		 * Use a Bearer authentication scheme (RFC 6750, Sec. 2.1). A trailing
+		 * space is used as a separator.
+		 */
+		authn_scheme = "Bearer ";
+
+		/* conn->token must have been set in this case. */
+		if (!token)
+		{
+			Assert(false);
+			libpq_append_conn_error(conn,
+									"internal error: no OAuth token was set for the connection");
+			return NULL;
+		}
+	}
+
+	initPQExpBuffer(&buf);
+	appendPQExpBuffer(&buf, resp_format, authn_scheme, token);
+
+	if (!PQExpBufferDataBroken(buf))
+		response = strdup(buf.data);
+	termPQExpBuffer(&buf);
+
+	if (!response)
+		libpq_append_conn_error(conn, "out of memory");
+
+	return response;
+}
+
+/*
+ * JSON Parser (for the OAUTHBEARER error result)
+ */
+
+/* Relevant JSON fields in the error result object. */
+#define ERROR_STATUS_FIELD "status"
+#define ERROR_SCOPE_FIELD "scope"
+#define ERROR_OPENID_CONFIGURATION_FIELD "openid-configuration"
+
+struct json_ctx
+{
+	char	   *errmsg;			/* any non-NULL value stops all processing */
+	PQExpBufferData errbuf;		/* backing memory for errmsg */
+	int			nested;			/* nesting level (zero is the top) */
+
+	const char *target_field_name;	/* points to a static allocation */
+	char	  **target_field;	/* see below */
+
+	/* target_field, if set, points to one of the following: */
+	char	   *status;
+	char	   *scope;
+	char	   *discovery_uri;
+};
+
+#define oauth_json_has_error(ctx) \
+	(PQExpBufferDataBroken((ctx)->errbuf) || (ctx)->errmsg)
+
+#define oauth_json_set_error(ctx, ...) \
+	do { \
+		appendPQExpBuffer(&(ctx)->errbuf, __VA_ARGS__); \
+		(ctx)->errmsg = (ctx)->errbuf.data; \
+	} while (0)
+
+static JsonParseErrorType
+oauth_json_object_start(void *state)
+{
+	struct json_ctx *ctx = state;
+
+	if (ctx->target_field)
+	{
+		Assert(ctx->nested == 1);
+
+		oauth_json_set_error(ctx,
+							 libpq_gettext("field \"%s\" must be a string"),
+							 ctx->target_field_name);
+	}
+
+	++ctx->nested;
+	return oauth_json_has_error(ctx) ? JSON_SEM_ACTION_FAILED : JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_object_end(void *state)
+{
+	struct json_ctx *ctx = state;
+
+	--ctx->nested;
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_object_field_start(void *state, char *name, bool isnull)
+{
+	struct json_ctx *ctx = state;
+
+	/* Only top-level keys are considered. */
+	if (ctx->nested == 1)
+	{
+		if (strcmp(name, ERROR_STATUS_FIELD) == 0)
+		{
+			ctx->target_field_name = ERROR_STATUS_FIELD;
+			ctx->target_field = &ctx->status;
+		}
+		else if (strcmp(name, ERROR_SCOPE_FIELD) == 0)
+		{
+			ctx->target_field_name = ERROR_SCOPE_FIELD;
+			ctx->target_field = &ctx->scope;
+		}
+		else if (strcmp(name, ERROR_OPENID_CONFIGURATION_FIELD) == 0)
+		{
+			ctx->target_field_name = ERROR_OPENID_CONFIGURATION_FIELD;
+			ctx->target_field = &ctx->discovery_uri;
+		}
+	}
+
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_array_start(void *state)
+{
+	struct json_ctx *ctx = state;
+
+	if (!ctx->nested)
+	{
+		ctx->errmsg = libpq_gettext("top-level element must be an object");
+	}
+	else if (ctx->target_field)
+	{
+		Assert(ctx->nested == 1);
+
+		oauth_json_set_error(ctx,
+							 libpq_gettext("field \"%s\" must be a string"),
+							 ctx->target_field_name);
+	}
+
+	return oauth_json_has_error(ctx) ? JSON_SEM_ACTION_FAILED : JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_scalar(void *state, char *token, JsonTokenType type)
+{
+	struct json_ctx *ctx = state;
+
+	if (!ctx->nested)
+	{
+		ctx->errmsg = libpq_gettext("top-level element must be an object");
+		return JSON_SEM_ACTION_FAILED;
+	}
+
+	if (ctx->target_field)
+	{
+		if (ctx->nested != 1)
+		{
+			/*
+			 * ctx->target_field should not have been set for nested keys.
+			 * Assert and don't continue any further for production builds.
+			 */
+			Assert(false);
+			oauth_json_set_error(ctx,
+								 "internal error: target scalar found at nesting level %d during OAUTHBEARER parsing",
+								 ctx->nested);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		/*
+		 * We don't allow duplicate field names; error out if the target has
+		 * already been set.
+		 */
+		if (*ctx->target_field)
+		{
+			oauth_json_set_error(ctx,
+								 libpq_gettext("field \"%s\" is duplicated"),
+								 ctx->target_field_name);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		/* The only fields we support are strings. */
+		if (type != JSON_TOKEN_STRING)
+		{
+			oauth_json_set_error(ctx,
+								 libpq_gettext("field \"%s\" must be a string"),
+								 ctx->target_field_name);
+			return JSON_SEM_ACTION_FAILED;
+		}
+
+		*ctx->target_field = strdup(token);
+		if (!*ctx->target_field)
+			return JSON_OUT_OF_MEMORY;
+
+		ctx->target_field = NULL;
+		ctx->target_field_name = NULL;
+	}
+	else
+	{
+		/* otherwise we just ignore it */
+	}
+
+	return JSON_SUCCESS;
+}
+
+#define HTTPS_SCHEME "https://"
+#define HTTP_SCHEME "http://"
+
+/* We support both well-known suffixes defined by RFC 8414. */
+#define WK_PREFIX "/.well-known/"
+#define OPENID_WK_SUFFIX "openid-configuration"
+#define OAUTH_WK_SUFFIX "oauth-authorization-server"
+
+/*
+ * Derives an issuer identifier from one of our recognized .well-known URIs,
+ * using the rules in RFC 8414.
+ */
+static char *
+issuer_from_well_known_uri(PGconn *conn, const char *wkuri)
+{
+	const char *authority_start = NULL;
+	const char *wk_start;
+	const char *wk_end;
+	char	   *issuer;
+	ptrdiff_t	start_offset,
+				end_offset;
+	size_t		end_len;
+
+	/*
+	 * https:// is required for issuer identifiers (RFC 8414, Sec. 2; OIDC
+	 * Discovery 1.0, Sec. 3). This is a case-insensitive comparison at this
+	 * level (but issuer identifier comparison at the level above this is
+	 * case-sensitive, so in practice it's probably moot).
+	 */
+	if (pg_strncasecmp(wkuri, HTTPS_SCHEME, strlen(HTTPS_SCHEME)) == 0)
+		authority_start = wkuri + strlen(HTTPS_SCHEME);
+
+	if (!authority_start
+		&& oauth_unsafe_debugging_enabled()
+		&& pg_strncasecmp(wkuri, HTTP_SCHEME, strlen(HTTP_SCHEME)) == 0)
+	{
+		/* Allow http:// for testing only. */
+		authority_start = wkuri + strlen(HTTP_SCHEME);
+	}
+
+	if (!authority_start)
+	{
+		libpq_append_conn_error(conn,
+								"OAuth discovery URI \"%s\" must use HTTPS",
+								wkuri);
+		return NULL;
+	}
+
+	/*
+	 * Well-known URIs in general may support queries and fragments, but the
+	 * two types we support here do not. (They must be constructed from the
+	 * components of issuer identifiers, which themselves may not contain any
+	 * queries or fragments.)
+	 *
+	 * It's important to check this first, to avoid getting tricked later by a
+	 * prefix buried inside a query or fragment.
+	 */
+	if (strpbrk(authority_start, "?#") != NULL)
+	{
+		libpq_append_conn_error(conn,
+								"OAuth discovery URI \"%s\" must not contain query or fragment components",
+								wkuri);
+		return NULL;
+	}
+
+	/*
+	 * Find the start of the .well-known prefix. IETF rules (RFC 8615) state
+	 * this must be at the beginning of the path component, but OIDC defined
+	 * it at the end instead (OIDC Discovery 1.0, Sec. 4), so we have to
+	 * search for it anywhere.
+	 */
+	wk_start = strstr(authority_start, WK_PREFIX);
+	if (!wk_start)
+	{
+		libpq_append_conn_error(conn,
+								"OAuth discovery URI \"%s\" is not a .well-known URI",
+								wkuri);
+		return NULL;
+	}
+
+	/*
+	 * Now find the suffix type. We only support the two defined in OIDC
+	 * Discovery 1.0 and RFC 8414.
+	 */
+	wk_end = wk_start + strlen(WK_PREFIX);
+
+	if (strncmp(wk_end, OPENID_WK_SUFFIX, strlen(OPENID_WK_SUFFIX)) == 0)
+		wk_end += strlen(OPENID_WK_SUFFIX);
+	else if (strncmp(wk_end, OAUTH_WK_SUFFIX, strlen(OAUTH_WK_SUFFIX)) == 0)
+		wk_end += strlen(OAUTH_WK_SUFFIX);
+	else
+		wk_end = NULL;
+
+	/*
+	 * Even if there's a match, we still need to check to make sure the suffix
+	 * takes up the entire path segment, to weed out constructions like
+	 * "/.well-known/openid-configuration-bad".
+	 */
+	if (!wk_end || (*wk_end != '/' && *wk_end != '\0'))
+	{
+		libpq_append_conn_error(conn,
+								"OAuth discovery URI \"%s\" uses an unsupported .well-known suffix",
+								wkuri);
+		return NULL;
+	}
+
+	/*
+	 * Finally, make sure the .well-known components are provided either as a
+	 * prefix (IETF style) or as a postfix (OIDC style). In other words,
+	 * "https://localhost/a/.well-known/openid-configuration/b" is not allowed
+	 * to claim association with "https://localhost/a/b".
+	 */
+	if (*wk_end != '\0')
+	{
+		/*
+		 * It's not at the end, so it's required to be at the beginning at the
+		 * path. Find the starting slash.
+		 */
+		const char *path_start;
+
+		path_start = strchr(authority_start, '/');
+		Assert(path_start);		/* otherwise we wouldn't have found WK_PREFIX */
+
+		if (wk_start != path_start)
+		{
+			libpq_append_conn_error(conn,
+									"OAuth discovery URI \"%s\" uses an invalid format",
+									wkuri);
+			return NULL;
+		}
+	}
+
+	/* Checks passed! Now build the issuer. */
+	issuer = strdup(wkuri);
+	if (!issuer)
+	{
+		libpq_append_conn_error(conn, "out of memory");
+		return NULL;
+	}
+
+	/*
+	 * The .well-known components are from [wk_start, wk_end). Remove those to
+	 * form the issuer ID, by shifting the path suffix (which may be empty)
+	 * leftwards.
+	 */
+	start_offset = wk_start - wkuri;
+	end_offset = wk_end - wkuri;
+	end_len = strlen(wk_end) + 1;	/* move the NULL terminator too */
+
+	memmove(issuer + start_offset, issuer + end_offset, end_len);
+
+	return issuer;
+}
+
+/*
+ * Parses the server error result (RFC 7628, Sec. 3.2.2) contained in msg and
+ * stores any discovered openid_configuration and scope settings for the
+ * connection.
+ */
+static bool
+handle_oauth_sasl_error(PGconn *conn, const char *msg, int msglen)
+{
+	JsonLexContext lex = {0};
+	JsonSemAction sem = {0};
+	JsonParseErrorType err;
+	struct json_ctx ctx = {0};
+	char	   *errmsg = NULL;
+	bool		success = false;
+
+	Assert(conn->oauth_issuer_id);	/* ensured by setup_oauth_parameters() */
+
+	/* Sanity check. */
+	if (strlen(msg) != msglen)
+	{
+		libpq_append_conn_error(conn,
+								"server's error message contained an embedded NULL, and was discarded");
+		return false;
+	}
+
+	/*
+	 * pg_parse_json doesn't validate the incoming UTF-8, so we have to check
+	 * that up front.
+	 */
+	if (pg_encoding_verifymbstr(PG_UTF8, msg, msglen) != msglen)
+	{
+		libpq_append_conn_error(conn,
+								"server's error response is not valid UTF-8");
+		return false;
+	}
+
+	makeJsonLexContextCstringLen(&lex, msg, msglen, PG_UTF8, true);
+	setJsonLexContextOwnsTokens(&lex, true);	/* must not leak on error */
+
+	initPQExpBuffer(&ctx.errbuf);
+	sem.semstate = &ctx;
+
+	sem.object_start = oauth_json_object_start;
+	sem.object_end = oauth_json_object_end;
+	sem.object_field_start = oauth_json_object_field_start;
+	sem.array_start = oauth_json_array_start;
+	sem.scalar = oauth_json_scalar;
+
+	err = pg_parse_json(&lex, &sem);
+
+	if (err == JSON_SEM_ACTION_FAILED)
+	{
+		if (PQExpBufferDataBroken(ctx.errbuf))
+			errmsg = libpq_gettext("out of memory");
+		else if (ctx.errmsg)
+			errmsg = ctx.errmsg;
+		else
+		{
+			/*
+			 * Developer error: one of the action callbacks didn't call
+			 * oauth_json_set_error() before erroring out.
+			 */
+			Assert(oauth_json_has_error(&ctx));
+			errmsg = "<unexpected empty error>";
+		}
+	}
+	else if (err != JSON_SUCCESS)
+		errmsg = json_errdetail(err, &lex);
+
+	if (errmsg)
+		libpq_append_conn_error(conn,
+								"failed to parse server's error response: %s",
+								errmsg);
+
+	/* Don't need the error buffer or the JSON lexer anymore. */
+	termPQExpBuffer(&ctx.errbuf);
+	freeJsonLexContext(&lex);
+
+	if (errmsg)
+		goto cleanup;
+
+	if (ctx.discovery_uri)
+	{
+		char	   *discovery_issuer;
+
+		/*
+		 * The URI MUST correspond to our existing issuer, to avoid mix-ups.
+		 *
+		 * Issuer comparison is done byte-wise, rather than performing any URL
+		 * normalization; this follows the suggestions for issuer comparison
+		 * in RFC 9207 Sec. 2.4 (which requires simple string comparison) and
+		 * vastly simplifies things. Since this is the key protection against
+		 * a rogue server sending the client to an untrustworthy location,
+		 * simpler is better.
+		 */
+		discovery_issuer = issuer_from_well_known_uri(conn, ctx.discovery_uri);
+		if (!discovery_issuer)
+			goto cleanup;		/* error message already set */
+
+		if (strcmp(conn->oauth_issuer_id, discovery_issuer) != 0)
+		{
+			libpq_append_conn_error(conn,
+									"server's discovery document at %s (issuer \"%s\") is incompatible with oauth_issuer (%s)",
+									ctx.discovery_uri, discovery_issuer,
+									conn->oauth_issuer_id);
+
+			free(discovery_issuer);
+			goto cleanup;
+		}
+
+		free(discovery_issuer);
+
+		if (!conn->oauth_discovery_uri)
+		{
+			conn->oauth_discovery_uri = ctx.discovery_uri;
+			ctx.discovery_uri = NULL;
+		}
+		else
+		{
+			/* This must match the URI we'd previously determined. */
+			if (strcmp(conn->oauth_discovery_uri, ctx.discovery_uri) != 0)
+			{
+				libpq_append_conn_error(conn,
+										"server's discovery document has moved to %s (previous location was %s)",
+										ctx.discovery_uri,
+										conn->oauth_discovery_uri);
+				goto cleanup;
+			}
+		}
+	}
+
+	if (ctx.scope)
+	{
+		/* Servers may not override a previously set oauth_scope. */
+		if (!conn->oauth_scope)
+		{
+			conn->oauth_scope = ctx.scope;
+			ctx.scope = NULL;
+		}
+	}
+
+	if (!ctx.status)
+	{
+		libpq_append_conn_error(conn,
+								"server sent error response without a status");
+		goto cleanup;
+	}
+
+	if (strcmp(ctx.status, "invalid_token") != 0)
+	{
+		/*
+		 * invalid_token is the only error code we'll automatically retry for;
+		 * otherwise, just bail out now.
+		 */
+		libpq_append_conn_error(conn,
+								"server rejected OAuth bearer token: %s",
+								ctx.status);
+		goto cleanup;
+	}
+
+	success = true;
+
+cleanup:
+	free(ctx.status);
+	free(ctx.scope);
+	free(ctx.discovery_uri);
+
+	return success;
+}
+
+/*
+ * Callback implementation of conn->async_auth() for a user-defined OAuth flow.
+ * Delegates the retrieval of the token to the application's 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_*
+ * statuses for use by PQconnectPoll().
+ */
+static PostgresPollingStatusType
+run_user_oauth_flow(PGconn *conn)
+{
+	fe_oauth_state *state = conn->sasl_state;
+	PGoauthBearerRequest *request = state->async_ctx;
+	PostgresPollingStatusType status;
+
+	if (!request->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);
+	if (status == PGRES_POLLING_FAILED)
+	{
+		libpq_append_conn_error(conn, "user-defined OAuth flow failed");
+		return status;
+	}
+	else if (status == PGRES_POLLING_OK)
+	{
+		/*
+		 * 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.)
+		 */
+		if (!request->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);
+		if (!conn->oauth_token)
+		{
+			libpq_append_conn_error(conn, "out of memory");
+			return PGRES_POLLING_FAILED;
+		}
+
+		return PGRES_POLLING_OK;
+	}
+
+	/* The hook wants the client to poll the altsock. Make sure it set one. */
+	if (conn->altsock == PGINVALID_SOCKET)
+	{
+		libpq_append_conn_error(conn,
+								"user-defined OAuth flow did not provide a socket for polling");
+		return PGRES_POLLING_FAILED;
+	}
+
+	return status;
+}
+
+/*
+ * Cleanup callback for the async user flow. Delegates most of its job to the
+ * user-provided cleanup implementation, then disconnects the altsock.
+ */
+static void
+cleanup_user_oauth_flow(PGconn *conn)
+{
+	fe_oauth_state *state = conn->sasl_state;
+	PGoauthBearerRequest *request = state->async_ctx;
+
+	Assert(request);
+
+	if (request->cleanup)
+		request->cleanup(conn, request);
+	conn->altsock = PGINVALID_SOCKET;
+
+	free(request);
+	state->async_ctx = NULL;
+}
+
+/*
+ * Chooses an OAuth client flow for the connection, which will retrieve a Bearer
+ * 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
+ * asynchronous callbacks which will be managed by run_user_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.)
+ *
+ * If neither a custom handler nor the builtin flow is available, the connection
+ * fails here.
+ */
+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,
+	};
+
+	Assert(request.openid_configuration);
+
+	/* The client may have overridden the OAuth flow. */
+	res = PQauthDataHook(PQAUTHDATA_OAUTH_BEARER_TOKEN, conn, &request);
+	if (res > 0)
+	{
+		PGoauthBearerRequest *request_copy;
+
+		if (request.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);
+			if (!conn->oauth_token)
+			{
+				libpq_append_conn_error(conn, "out of memory");
+				goto fail;
+			}
+
+			/* short-circuit */
+			if (request.cleanup)
+				request.cleanup(conn, &request);
+			return true;
+		}
+
+		request_copy = malloc(sizeof(*request_copy));
+		if (!request_copy)
+		{
+			libpq_append_conn_error(conn, "out of memory");
+			goto fail;
+		}
+
+		memcpy(request_copy, &request, sizeof(request));
+
+		conn->async_auth = run_user_oauth_flow;
+		conn->cleanup_async_auth = cleanup_user_oauth_flow;
+		state->async_ctx = request_copy;
+	}
+	else if (res < 0)
+	{
+		libpq_append_conn_error(conn, "user-defined OAuth flow failed");
+		goto fail;
+	}
+	else
+	{
+#if USE_LIBCURL
+		/* Hand off to our built-in OAuth flow. */
+		conn->async_auth = pg_fe_run_oauth_flow;
+		conn->cleanup_async_auth = pg_fe_cleanup_oauth_flow;
+
+#else
+		libpq_append_conn_error(conn, "no custom OAuth flows are available, and libpq was not built with libcurl support");
+		goto fail;
+
+#endif
+	}
+
+	return true;
+
+fail:
+	if (request.cleanup)
+		request.cleanup(conn, &request);
+	return false;
+}
+
+/*
+ * Fill in our issuer identifier (and discovery URI, if possible) using the
+ * connection parameters. If conn->oauth_discovery_uri can't be populated in
+ * this function, it will be requested from the server.
+ */
+static bool
+setup_oauth_parameters(PGconn *conn)
+{
+	/*
+	 * This is the only function that sets conn->oauth_issuer_id. If a
+	 * previous connection attempt has already computed it, don't overwrite it
+	 * or the discovery URI. (There's no reason for them to change once
+	 * they're set, and handle_oauth_sasl_error() will fail the connection if
+	 * the server attempts to switch them on us later.)
+	 */
+	if (conn->oauth_issuer_id)
+		return true;
+
+	/*---
+	 * To talk to a server, we require the user to provide issuer and client
+	 * identifiers.
+	 *
+	 * While it's possible for an OAuth client to support multiple issuers, it
+	 * requires additional effort to make sure the flows in use are safe -- to
+	 * quote RFC 9207,
+	 *
+	 *     OAuth clients that interact with only one authorization server are
+	 *     not vulnerable to mix-up attacks. However, when such clients decide
+	 *     to add support for a second authorization server in the future, they
+	 *     become vulnerable and need to apply countermeasures to mix-up
+	 *     attacks.
+	 *
+	 * For now, we allow only one.
+	 */
+	if (!conn->oauth_issuer || !conn->oauth_client_id)
+	{
+		libpq_append_conn_error(conn,
+								"server requires OAuth authentication, but oauth_issuer and oauth_client_id are not both set");
+		return false;
+	}
+
+	/*
+	 * oauth_issuer is interpreted differently if it's a well-known discovery
+	 * URI rather than just an issuer identifier.
+	 */
+	if (strstr(conn->oauth_issuer, WK_PREFIX) != NULL)
+	{
+		/*
+		 * Convert the URI back to an issuer identifier. (This also performs
+		 * validation of the URI format.)
+		 */
+		conn->oauth_issuer_id = issuer_from_well_known_uri(conn,
+														   conn->oauth_issuer);
+		if (!conn->oauth_issuer_id)
+			return false;		/* error message already set */
+
+		conn->oauth_discovery_uri = strdup(conn->oauth_issuer);
+		if (!conn->oauth_discovery_uri)
+		{
+			libpq_append_conn_error(conn, "out of memory");
+			return false;
+		}
+	}
+	else
+	{
+		/*
+		 * Treat oauth_issuer as an issuer identifier. We'll ask the server
+		 * for the discovery URI.
+		 */
+		conn->oauth_issuer_id = strdup(conn->oauth_issuer);
+		if (!conn->oauth_issuer_id)
+		{
+			libpq_append_conn_error(conn, "out of memory");
+			return false;
+		}
+	}
+
+	return true;
+}
+
+/*
+ * Implements the OAUTHBEARER SASL exchange (RFC 7628, Sec. 3.2).
+ *
+ * If the necessary OAuth parameters are set up on the connection, this will run
+ * the client flow asynchronously and present the resulting token to the server.
+ * Otherwise, an empty discovery response will be sent and any parameters sent
+ * back by the server will be stored for a second attempt.
+ *
+ * For a full description of the API, see libpq/sasl.h.
+ */
+static SASLStatus
+oauth_exchange(void *opaq, bool final,
+			   char *input, int inputlen,
+			   char **output, int *outputlen)
+{
+	fe_oauth_state *state = opaq;
+	PGconn	   *conn = state->conn;
+	bool		discover = false;
+
+	*output = NULL;
+	*outputlen = 0;
+
+	switch (state->step)
+	{
+		case FE_OAUTH_INIT:
+			/* We begin in the initial response phase. */
+			Assert(inputlen == -1);
+
+			if (!setup_oauth_parameters(conn))
+				return SASL_FAILED;
+
+			if (conn->oauth_token)
+			{
+				/*
+				 * A previous connection already fetched the token; we'll use
+				 * it below.
+				 */
+			}
+			else if (conn->oauth_discovery_uri)
+			{
+				/*
+				 * We don't have a token, but we have a discovery URI already
+				 * stored. Decide whether we're using a user-provided OAuth
+				 * flow or the one we have built in.
+				 */
+				if (!setup_token_request(conn, state))
+					return SASL_FAILED;
+
+				if (conn->oauth_token)
+				{
+					/*
+					 * A really smart user implementation may have already
+					 * given us the token (e.g. if there was an unexpired copy
+					 * already cached), and we can use it immediately.
+					 */
+				}
+				else
+				{
+					/*
+					 * Otherwise, we'll have to hand the connection over to
+					 * our OAuth implementation.
+					 *
+					 * This could take a while, since it generally involves a
+					 * user in the loop. To avoid consuming the server's
+					 * authentication timeout, we'll continue this handshake
+					 * to the end, so that the server can close its side of
+					 * the connection. We'll open a second connection later
+					 * once we've retrieved a token.
+					 */
+					discover = true;
+				}
+			}
+			else
+			{
+				/*
+				 * If we don't have a token, and we don't have a discovery URI
+				 * to be able to request a token, we ask the server for one
+				 * explicitly.
+				 */
+				discover = true;
+			}
+
+			/*
+			 * Generate an initial response. This either contains a token, if
+			 * we have one, or an empty discovery response which is doomed to
+			 * fail.
+			 */
+			*output = client_initial_response(conn, discover);
+			if (!*output)
+				return SASL_FAILED;
+
+			*outputlen = strlen(*output);
+			state->step = FE_OAUTH_BEARER_SENT;
+
+			if (conn->oauth_token)
+			{
+				/*
+				 * For the purposes of require_auth, our side of
+				 * authentication is done at this point; the server will
+				 * either accept the connection or send an error. Unlike
+				 * SCRAM, there is no additional server data to check upon
+				 * success.
+				 */
+				conn->client_finished_auth = true;
+			}
+
+			return SASL_CONTINUE;
+
+		case FE_OAUTH_BEARER_SENT:
+			if (final)
+			{
+				/*
+				 * OAUTHBEARER does not make use of additional data with a
+				 * successful SASL exchange, so we shouldn't get an
+				 * AuthenticationSASLFinal message.
+				 */
+				libpq_append_conn_error(conn,
+										"server sent unexpected additional OAuth data");
+				return SASL_FAILED;
+			}
+
+			/*
+			 * An error message was sent by the server. Respond with the
+			 * required dummy message (RFC 7628, sec. 3.2.3).
+			 */
+			*output = strdup(kvsep);
+			if (unlikely(!*output))
+			{
+				libpq_append_conn_error(conn, "out of memory");
+				return SASL_FAILED;
+			}
+			*outputlen = strlen(*output);	/* == 1 */
+
+			/* Grab the settings from discovery. */
+			if (!handle_oauth_sasl_error(conn, input, inputlen))
+				return SASL_FAILED;
+
+			if (conn->oauth_token)
+			{
+				/*
+				 * The server rejected our token. Continue onwards towards the
+				 * expected FATAL message, but mark our state to catch any
+				 * unexpected "success" from the server.
+				 */
+				state->step = FE_OAUTH_SERVER_ERROR;
+				return SASL_CONTINUE;
+			}
+
+			if (!conn->async_auth)
+			{
+				/*
+				 * No OAuth flow is set up yet. Did we get enough information
+				 * from the server to create one?
+				 */
+				if (!conn->oauth_discovery_uri)
+				{
+					libpq_append_conn_error(conn,
+											"server requires OAuth authentication, but no discovery metadata was provided");
+					return SASL_FAILED;
+				}
+
+				/* Yes. Set up the flow now. */
+				if (!setup_token_request(conn, state))
+					return SASL_FAILED;
+
+				if (conn->oauth_token)
+				{
+					/*
+					 * A token was available in a custom flow's cache. Skip
+					 * the asynchronous processing.
+					 */
+					goto reconnect;
+				}
+			}
+
+			/*
+			 * Time to retrieve a token. This involves a number of HTTP
+			 * connections and timed waits, so we escape the synchronous auth
+			 * processing and tell PQconnectPoll to transfer control to our
+			 * async implementation.
+			 */
+			Assert(conn->async_auth);	/* should have been set already */
+			state->step = FE_OAUTH_REQUESTING_TOKEN;
+			return SASL_ASYNC;
+
+		case FE_OAUTH_REQUESTING_TOKEN:
+
+			/*
+			 * We've returned successfully from token retrieval. Double-check
+			 * that we have what we need for the next connection.
+			 */
+			if (!conn->oauth_token)
+			{
+				Assert(false);	/* should have failed before this point! */
+				libpq_append_conn_error(conn,
+										"internal error: OAuth flow did not set a token");
+				return SASL_FAILED;
+			}
+
+			goto reconnect;
+
+		case FE_OAUTH_SERVER_ERROR:
+
+			/*
+			 * After an error, the server should send an error response to
+			 * fail the SASL handshake, which is handled in higher layers.
+			 *
+			 * If we get here, the server either sent *another* challenge
+			 * which isn't defined in the RFC, or completed the handshake
+			 * successfully after telling us it was going to fail. Neither is
+			 * acceptable.
+			 */
+			libpq_append_conn_error(conn,
+									"server sent additional OAuth data after error");
+			return SASL_FAILED;
+
+		default:
+			libpq_append_conn_error(conn, "invalid OAuth exchange state");
+			break;
+	}
+
+	Assert(false);				/* should never get here */
+	return SASL_FAILED;
+
+reconnect:
+
+	/*
+	 * Despite being a failure from the point of view of SASL, we have enough
+	 * information to restart with a new connection.
+	 */
+	libpq_append_conn_error(conn, "retrying connection with new bearer token");
+	conn->oauth_want_retry = true;
+	return SASL_FAILED;
+}
+
+static bool
+oauth_channel_bound(void *opaq)
+{
+	/* This mechanism does not support channel binding. */
+	return false;
+}
+
+/*
+ * Fully clears out any stored OAuth token. This is done proactively upon
+ * successful connection as well as during pqClosePGconn().
+ */
+void
+pqClearOAuthToken(PGconn *conn)
+{
+	if (!conn->oauth_token)
+		return;
+
+	explicit_bzero(conn->oauth_token, strlen(conn->oauth_token));
+	free(conn->oauth_token);
+	conn->oauth_token = NULL;
+}
+
+/*
+ * Returns true if the PGOAUTHDEBUG=UNSAFE flag is set in the environment.
+ */
+bool
+oauth_unsafe_debugging_enabled(void)
+{
+	const char *env = getenv("PGOAUTHDEBUG");
+
+	return (env && strcmp(env, "UNSAFE") == 0);
+}
diff --git a/src/interfaces/libpq/fe-auth-oauth.h b/src/interfaces/libpq/fe-auth-oauth.h
new file mode 100644
index 00000000000..3f1a7503a01
--- /dev/null
+++ b/src/interfaces/libpq/fe-auth-oauth.h
@@ -0,0 +1,46 @@
+/*-------------------------------------------------------------------------
+ *
+ * fe-auth-oauth.h
+ *
+ *	  Definitions for OAuth authentication implementations
+ *
+ * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/interfaces/libpq/fe-auth-oauth.h
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#ifndef FE_AUTH_OAUTH_H
+#define FE_AUTH_OAUTH_H
+
+#include "libpq-fe.h"
+#include "libpq-int.h"
+
+
+enum fe_oauth_step
+{
+	FE_OAUTH_INIT,
+	FE_OAUTH_BEARER_SENT,
+	FE_OAUTH_REQUESTING_TOKEN,
+	FE_OAUTH_SERVER_ERROR,
+};
+
+typedef struct
+{
+	enum fe_oauth_step step;
+
+	PGconn	   *conn;
+	void	   *async_ctx;
+} fe_oauth_state;
+
+extern PostgresPollingStatusType pg_fe_run_oauth_flow(PGconn *conn);
+extern void pg_fe_cleanup_oauth_flow(PGconn *conn);
+extern void pqClearOAuthToken(PGconn *conn);
+extern bool oauth_unsafe_debugging_enabled(void);
+
+/* Mechanisms in fe-auth-oauth.c */
+extern const pg_fe_sasl_mech pg_oauth_mech;
+
+#endif							/* FE_AUTH_OAUTH_H */
diff --git a/src/interfaces/libpq/fe-auth.c b/src/interfaces/libpq/fe-auth.c
index 761ee8f88f7..ec7a9236044 100644
--- a/src/interfaces/libpq/fe-auth.c
+++ b/src/interfaces/libpq/fe-auth.c
@@ -40,9 +40,11 @@
 #endif
 
 #include "common/md5.h"
+#include "common/oauth-common.h"
 #include "common/scram-common.h"
 #include "fe-auth.h"
 #include "fe-auth-sasl.h"
+#include "fe-auth-oauth.h"
 #include "libpq-fe.h"
 
 #ifdef ENABLE_GSS
@@ -535,6 +537,13 @@ pg_SASL_init(PGconn *conn, int payloadlen, bool *async)
 			conn->sasl = &pg_scram_mech;
 			conn->password_needed = true;
 		}
+		else if (strcmp(mechanism_buf.data, OAUTHBEARER_NAME) == 0 &&
+				 !selected_mechanism)
+		{
+			selected_mechanism = OAUTHBEARER_NAME;
+			conn->sasl = &pg_oauth_mech;
+			conn->password_needed = false;
+		}
 	}
 
 	if (!selected_mechanism)
@@ -559,13 +568,6 @@ pg_SASL_init(PGconn *conn, int payloadlen, bool *async)
 
 		if (!allowed)
 		{
-			/*
-			 * TODO: this is dead code until a second SASL mechanism is added;
-			 * the connection can't have proceeded past check_expected_areq()
-			 * if no SASL methods are allowed.
-			 */
-			Assert(false);
-
 			libpq_append_conn_error(conn, "authentication method requirement \"%s\" failed: server requested %s authentication",
 									conn->require_auth, selected_mechanism);
 			goto error;
@@ -1580,3 +1582,23 @@ PQchangePassword(PGconn *conn, const char *user, const char *passwd)
 		}
 	}
 }
+
+PQauthDataHook_type PQauthDataHook = PQdefaultAuthDataHook;
+
+PQauthDataHook_type
+PQgetAuthDataHook(void)
+{
+	return PQauthDataHook;
+}
+
+void
+PQsetAuthDataHook(PQauthDataHook_type hook)
+{
+	PQauthDataHook = hook ? hook : PQdefaultAuthDataHook;
+}
+
+int
+PQdefaultAuthDataHook(PGauthData type, PGconn *conn, void *data)
+{
+	return 0;					/* handle nothing */
+}
diff --git a/src/interfaces/libpq/fe-auth.h b/src/interfaces/libpq/fe-auth.h
index 1d4991f8996..de98e0d20c4 100644
--- a/src/interfaces/libpq/fe-auth.h
+++ b/src/interfaces/libpq/fe-auth.h
@@ -18,6 +18,9 @@
 #include "libpq-int.h"
 
 
+extern PQauthDataHook_type PQauthDataHook;
+
+
 /* Prototypes for functions in fe-auth.c */
 extern int	pg_fe_sendauth(AuthRequest areq, int payloadlen, PGconn *conn,
 						   bool *async);
diff --git a/src/interfaces/libpq/fe-connect.c b/src/interfaces/libpq/fe-connect.c
index 85d1ca2864f..d5051f5e820 100644
--- a/src/interfaces/libpq/fe-connect.c
+++ b/src/interfaces/libpq/fe-connect.c
@@ -28,6 +28,7 @@
 #include "common/scram-common.h"
 #include "common/string.h"
 #include "fe-auth.h"
+#include "fe-auth-oauth.h"
 #include "libpq-fe.h"
 #include "libpq-int.h"
 #include "mb/pg_wchar.h"
@@ -373,6 +374,23 @@ static const internalPQconninfoOption PQconninfoOptions[] = {
 	{"scram_server_key", NULL, NULL, NULL, "SCRAM-Server-Key", "D", SCRAM_MAX_KEY_LEN * 2,
 	offsetof(struct pg_conn, scram_server_key)},
 
+	/* OAuth v2 */
+	{"oauth_issuer", NULL, NULL, NULL,
+		"OAuth-Issuer", "", 40,
+	offsetof(struct pg_conn, oauth_issuer)},
+
+	{"oauth_client_id", NULL, NULL, NULL,
+		"OAuth-Client-ID", "", 40,
+	offsetof(struct pg_conn, oauth_client_id)},
+
+	{"oauth_client_secret", NULL, NULL, NULL,
+		"OAuth-Client-Secret", "", 40,
+	offsetof(struct pg_conn, oauth_client_secret)},
+
+	{"oauth_scope", NULL, NULL, NULL,
+		"OAuth-Scope", "", 15,
+	offsetof(struct pg_conn, oauth_scope)},
+
 	/* Terminating entry --- MUST BE LAST */
 	{NULL, NULL, NULL, NULL,
 	NULL, NULL, 0}
@@ -399,6 +417,7 @@ static const PQEnvironmentOption EnvironmentOptions[] =
 static const pg_fe_sasl_mech *supported_sasl_mechs[] =
 {
 	&pg_scram_mech,
+	&pg_oauth_mech,
 };
 #define SASL_MECHANISM_COUNT lengthof(supported_sasl_mechs)
 
@@ -655,6 +674,7 @@ pqDropServerData(PGconn *conn)
 	conn->write_failed = false;
 	free(conn->write_err_msg);
 	conn->write_err_msg = NULL;
+	conn->oauth_want_retry = false;
 
 	/*
 	 * Cancel connections need to retain their be_pid and be_key across
@@ -1144,7 +1164,7 @@ static inline void
 fill_allowed_sasl_mechs(PGconn *conn)
 {
 	/*---
-	 * We only support one mechanism at the moment, so rather than deal with a
+	 * We only support two mechanisms at the moment, so rather than deal with a
 	 * linked list, conn->allowed_sasl_mechs is an array of static length. We
 	 * rely on the compile-time assertion here to keep us honest.
 	 *
@@ -1519,6 +1539,10 @@ pqConnectOptions2(PGconn *conn)
 			{
 				mech = &pg_scram_mech;
 			}
+			else if (strcmp(method, "oauth") == 0)
+			{
+				mech = &pg_oauth_mech;
+			}
 
 			/*
 			 * Final group: meta-options.
@@ -4111,7 +4135,19 @@ keep_going:						/* We will come back to here until there is
 				conn->inStart = conn->inCursor;
 
 				if (res != STATUS_OK)
+				{
+					/*
+					 * OAuth connections may perform two-step discovery, where
+					 * the first connection is a dummy.
+					 */
+					if (conn->sasl == &pg_oauth_mech && conn->oauth_want_retry)
+					{
+						need_new_connection = true;
+						goto keep_going;
+					}
+
 					goto error_return;
+				}
 
 				/*
 				 * Just make sure that any data sent by pg_fe_sendauth is
@@ -4390,6 +4426,9 @@ keep_going:						/* We will come back to here until there is
 					}
 				}
 
+				/* Don't hold onto any OAuth tokens longer than necessary. */
+				pqClearOAuthToken(conn);
+
 				/*
 				 * For non cancel requests we can release the address list
 				 * now. For cancel requests we never actually resolve
@@ -5002,6 +5041,12 @@ freePGconn(PGconn *conn)
 	free(conn->load_balance_hosts);
 	free(conn->scram_client_key);
 	free(conn->scram_server_key);
+	free(conn->oauth_issuer);
+	free(conn->oauth_issuer_id);
+	free(conn->oauth_discovery_uri);
+	free(conn->oauth_client_id);
+	free(conn->oauth_client_secret);
+	free(conn->oauth_scope);
 	termPQExpBuffer(&conn->errorMessage);
 	termPQExpBuffer(&conn->workBuffer);
 
@@ -5155,6 +5200,7 @@ pqClosePGconn(PGconn *conn)
 	conn->asyncStatus = PGASYNC_IDLE;
 	conn->xactStatus = PQTRANS_IDLE;
 	conn->pipelineStatus = PQ_PIPELINE_OFF;
+	pqClearOAuthToken(conn);
 	pqClearAsyncResult(conn);	/* deallocate result */
 	pqClearConnErrorState(conn);
 
diff --git a/src/interfaces/libpq/libpq-fe.h b/src/interfaces/libpq/libpq-fe.h
index a3491faf0c3..b7399dee58e 100644
--- a/src/interfaces/libpq/libpq-fe.h
+++ b/src/interfaces/libpq/libpq-fe.h
@@ -59,6 +59,8 @@ extern "C"
 /* Features added in PostgreSQL v18: */
 /* Indicates presence of PQfullProtocolVersion */
 #define LIBPQ_HAS_FULL_PROTOCOL_VERSION 1
+/* Indicates presence of the PQAUTHDATA_PROMPT_OAUTH_DEVICE authdata hook */
+#define LIBPQ_HAS_PROMPT_OAUTH_DEVICE 1
 
 /*
  * Option flags for PQcopyResult
@@ -186,6 +188,13 @@ typedef enum
 	PQ_PIPELINE_ABORTED
 } PGpipelineStatus;
 
+typedef enum
+{
+	PQAUTHDATA_PROMPT_OAUTH_DEVICE, /* user must visit a device-authorization
+									 * URL */
+	PQAUTHDATA_OAUTH_BEARER_TOKEN,	/* server requests an OAuth Bearer token */
+} PGauthData;
+
 /* PGconn encapsulates a connection to the backend.
  * The contents of this struct are not supposed to be known to applications.
  */
@@ -720,10 +729,86 @@ extern int	PQenv2encoding(void);
 
 /* === in fe-auth.c === */
 
+typedef struct _PGpromptOAuthDevice
+{
+	const char *verification_uri;	/* verification URI to visit */
+	const char *user_code;		/* user code to enter */
+	const char *verification_uri_complete;	/* optional combination of URI and
+											 * code, or NULL */
+	int			expires_in;		/* seconds until user code expires */
+} PGpromptOAuthDevice;
+
+/* for PGoauthBearerRequest.async() */
+#ifdef _WIN32
+#define SOCKTYPE uintptr_t		/* avoids depending on winsock2.h for SOCKET */
+#else
+#define SOCKTYPE int
+#endif
+
+typedef struct _PGoauthBearerRequest
+{
+	/* Hook inputs (constant across all calls) */
+	const char *const openid_configuration; /* OIDC discovery URI */
+	const char *const scope;	/* required scope(s), or NULL */
+
+	/* Hook outputs */
+
+	/*---------
+	 * Callback implementing a custom asynchronous OAuth flow.
+	 *
+	 * The callback may return
+	 * - PGRES_POLLING_READING/WRITING, to indicate that a socket descriptor
+	 *   has been stored in *altsock and libpq should wait until it is
+	 *   readable or writable before calling back;
+	 * - PGRES_POLLING_OK, to indicate that the flow is complete and
+	 *   request->token has been set; or
+	 * - PGRES_POLLING_FAILED, to indicate that token retrieval has failed.
+	 *
+	 * This callback is optional. If the token can be obtained without
+	 * 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.
+	 */
+	PostgresPollingStatusType (*async) (PGconn *conn,
+										struct _PGoauthBearerRequest *request,
+										SOCKTYPE * altsock);
+
+	/*
+	 * Callback to clean up custom allocations. A hook implementation may use
+	 * this to free request->token and any resources in request->user.
+	 *
+	 * This is technically optional, but highly recommended, because there is
+	 * no other indication as to when it is safe to free the token.
+	 */
+	void		(*cleanup) (PGconn *conn, struct _PGoauthBearerRequest *request);
+
+	/*
+	 * The hook should set this to the Bearer token contents for the
+	 * connection, once the flow is completed.  The token contents must remain
+	 * available to libpq until the hook's cleanup callback is called.
+	 */
+	char	   *token;
+
+	/*
+	 * Hook-defined data. libpq will not modify this pointer across calls to
+	 * the async callback, so it can be used to keep track of
+	 * application-specific state. Resources allocated here should be freed by
+	 * the cleanup callback.
+	 */
+	void	   *user;
+} PGoauthBearerRequest;
+
+#undef SOCKTYPE
+
 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);
 
+typedef int (*PQauthDataHook_type) (PGauthData type, PGconn *conn, void *data);
+extern void PQsetAuthDataHook(PQauthDataHook_type hook);
+extern PQauthDataHook_type PQgetAuthDataHook(void);
+extern int	PQdefaultAuthDataHook(PGauthData type, PGconn *conn, void *data);
+
 /* === in encnames.c === */
 
 extern int	pg_char_to_encoding(const char *name);
diff --git a/src/interfaces/libpq/libpq-int.h b/src/interfaces/libpq/libpq-int.h
index 2546f9f8a50..f36f7f19d58 100644
--- a/src/interfaces/libpq/libpq-int.h
+++ b/src/interfaces/libpq/libpq-int.h
@@ -437,6 +437,17 @@ struct pg_conn
 								 * cancel request, instead of being a normal
 								 * connection that's used for queries */
 
+	/* OAuth v2 */
+	char	   *oauth_issuer;	/* token issuer/URL */
+	char	   *oauth_issuer_id;	/* token issuer identifier */
+	char	   *oauth_discovery_uri;	/* URI of the issuer's discovery
+										 * document */
+	char	   *oauth_client_id;	/* client identifier */
+	char	   *oauth_client_secret;	/* client secret */
+	char	   *oauth_scope;	/* access token scope */
+	char	   *oauth_token;	/* access token */
+	bool		oauth_want_retry;	/* should we retry on failure? */
+
 	/* Optional file to write trace info to */
 	FILE	   *Pfdebug;
 	int			traceFlags;
@@ -505,7 +516,7 @@ struct pg_conn
 								 * the server? */
 	uint32		allowed_auth_methods;	/* bitmask of acceptable AuthRequest
 										 * codes */
-	const pg_fe_sasl_mech *allowed_sasl_mechs[1];	/* and acceptable SASL
+	const pg_fe_sasl_mech *allowed_sasl_mechs[2];	/* and acceptable SASL
 													 * mechanisms */
 	bool		client_finished_auth;	/* have we finished our half of the
 										 * authentication exchange? */
diff --git a/src/interfaces/libpq/meson.build b/src/interfaces/libpq/meson.build
index dd64d291b3e..19f4a52a97a 100644
--- a/src/interfaces/libpq/meson.build
+++ b/src/interfaces/libpq/meson.build
@@ -1,6 +1,7 @@
 # Copyright (c) 2022-2025, PostgreSQL Global Development Group
 
 libpq_sources = files(
+  'fe-auth-oauth.c',
   'fe-auth-scram.c',
   'fe-auth.c',
   'fe-cancel.c',
@@ -37,6 +38,10 @@ if gssapi.found()
   )
 endif
 
+if libcurl.found()
+  libpq_sources += files('fe-auth-oauth-curl.c')
+endif
+
 export_file = custom_target('libpq.exports',
   kwargs: gen_export_kwargs,
 )
diff --git a/src/makefiles/meson.build b/src/makefiles/meson.build
index d49b2079a44..60e13d50235 100644
--- a/src/makefiles/meson.build
+++ b/src/makefiles/meson.build
@@ -229,6 +229,7 @@ pgxs_deps = {
   'gssapi': gssapi,
   'icu': icu,
   'ldap': ldap,
+  'libcurl': libcurl,
   'libxml': libxml,
   'libxslt': libxslt,
   'llvm': llvm,
diff --git a/src/test/authentication/t/001_password.pl b/src/test/authentication/t/001_password.pl
index 1357f806b6f..4ce22ccbdf2 100644
--- a/src/test/authentication/t/001_password.pl
+++ b/src/test/authentication/t/001_password.pl
@@ -404,11 +404,11 @@ $node->connect_fails(
 $node->connect_fails(
 	"user=scram_role require_auth=!scram-sha-256",
 	"SCRAM authentication forbidden, fails with SCRAM auth",
-	expected_stderr => qr/server requested SASL authentication/);
+	expected_stderr => qr/server requested SCRAM-SHA-256 authentication/);
 $node->connect_fails(
 	"user=scram_role require_auth=!password,!md5,!scram-sha-256",
 	"multiple authentication types forbidden, fails with SCRAM auth",
-	expected_stderr => qr/server requested SASL authentication/);
+	expected_stderr => qr/server requested SCRAM-SHA-256 authentication/);
 
 # Test that bad passwords are rejected.
 $ENV{"PGPASSWORD"} = 'badpass';
@@ -465,13 +465,13 @@ $node->connect_fails(
 	"user=scram_role require_auth=!scram-sha-256",
 	"password authentication forbidden, fails with SCRAM auth",
 	expected_stderr =>
-	  qr/authentication method requirement "!scram-sha-256" failed: server requested SASL authentication/
+	  qr/authentication method requirement "!scram-sha-256" failed: server requested SCRAM-SHA-256 authentication/
 );
 $node->connect_fails(
 	"user=scram_role require_auth=!password,!md5,!scram-sha-256",
 	"multiple authentication types forbidden, fails with SCRAM auth",
 	expected_stderr =>
-	  qr/authentication method requirement "!password,!md5,!scram-sha-256" failed: server requested SASL authentication/
+	  qr/authentication method requirement "!password,!md5,!scram-sha-256" failed: server requested SCRAM-SHA-256 authentication/
 );
 
 # Test SYSTEM_USER <> NULL with parallel workers.
diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile
index 89e78b7d114..4e4be3fa511 100644
--- a/src/test/modules/Makefile
+++ b/src/test/modules/Makefile
@@ -11,6 +11,7 @@ SUBDIRS = \
 		  dummy_index_am \
 		  dummy_seclabel \
 		  libpq_pipeline \
+		  oauth_validator \
 		  plsample \
 		  spgist_name_ops \
 		  test_bloomfilter \
diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build
index a57077b682e..2b057451473 100644
--- a/src/test/modules/meson.build
+++ b/src/test/modules/meson.build
@@ -9,6 +9,7 @@ subdir('gin')
 subdir('injection_points')
 subdir('ldap_password_func')
 subdir('libpq_pipeline')
+subdir('oauth_validator')
 subdir('plsample')
 subdir('spgist_name_ops')
 subdir('ssl_passphrase_callback')
diff --git a/src/test/modules/oauth_validator/.gitignore b/src/test/modules/oauth_validator/.gitignore
new file mode 100644
index 00000000000..5dcb3ff9723
--- /dev/null
+++ b/src/test/modules/oauth_validator/.gitignore
@@ -0,0 +1,4 @@
+# Generated subdirectories
+/log/
+/results/
+/tmp_check/
diff --git a/src/test/modules/oauth_validator/Makefile b/src/test/modules/oauth_validator/Makefile
new file mode 100644
index 00000000000..05b9f06ed73
--- /dev/null
+++ b/src/test/modules/oauth_validator/Makefile
@@ -0,0 +1,40 @@
+#-------------------------------------------------------------------------
+#
+# Makefile for src/test/modules/oauth_validator
+#
+# Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/test/modules/oauth_validator/Makefile
+#
+#-------------------------------------------------------------------------
+
+MODULES = validator fail_validator magic_validator
+PGFILEDESC = "validator - test OAuth validator module"
+
+PROGRAM = oauth_hook_client
+PGAPPICON = win32
+OBJS = $(WIN32RES) oauth_hook_client.o
+
+PG_CPPFLAGS = -I$(libpq_srcdir)
+PG_LIBS_INTERNAL += $(libpq_pgport)
+
+NO_INSTALLCHECK = 1
+
+TAP_TESTS = 1
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = src/test/modules/oauth_validator
+top_builddir = ../../../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+
+export PYTHON
+export with_libcurl
+export with_python
+
+endif
diff --git a/src/test/modules/oauth_validator/README b/src/test/modules/oauth_validator/README
new file mode 100644
index 00000000000..54eac5b117e
--- /dev/null
+++ b/src/test/modules/oauth_validator/README
@@ -0,0 +1,13 @@
+Test programs and libraries for OAuth
+-------------------------------------
+
+This folder contains tests for the client- and server-side OAuth
+implementations. Most tests are run end-to-end to test both simultaneously. The
+tests in t/001_server use a mock OAuth authorization server, implemented jointly
+by t/OAuth/Server.pm and t/oauth_server.py, to run the libpq Device
+Authorization flow. The tests in t/002_client exercise custom OAuth flows and
+don't need an authorization server.
+
+Tests in this folder require 'oauth' to be present in PG_TEST_EXTRA, since
+HTTPS servers listening on localhost with TCP/IP sockets will be started. A
+Python installation is required to run the mock authorization server.
diff --git a/src/test/modules/oauth_validator/fail_validator.c b/src/test/modules/oauth_validator/fail_validator.c
new file mode 100644
index 00000000000..a4c7a4451d3
--- /dev/null
+++ b/src/test/modules/oauth_validator/fail_validator.c
@@ -0,0 +1,47 @@
+/*-------------------------------------------------------------------------
+ *
+ * fail_validator.c
+ *	  Test module for serverside OAuth token validation callbacks, which is
+ *	  guaranteed to always fail in the validation callback
+ *
+ * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/test/modules/oauth_validator/fail_validator.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "fmgr.h"
+#include "libpq/oauth.h"
+
+PG_MODULE_MAGIC;
+
+static bool fail_token(const ValidatorModuleState *state,
+					   const char *token,
+					   const char *role,
+					   ValidatorModuleResult *result);
+
+/* Callback implementations (we only need the main one) */
+static const OAuthValidatorCallbacks validator_callbacks = {
+	PG_OAUTH_VALIDATOR_MAGIC,
+
+	.validate_cb = fail_token,
+};
+
+const OAuthValidatorCallbacks *
+_PG_oauth_validator_module_init(void)
+{
+	return &validator_callbacks;
+}
+
+static bool
+fail_token(const ValidatorModuleState *state,
+		   const char *token, const char *role,
+		   ValidatorModuleResult *res)
+{
+	elog(FATAL, "fail_validator: sentinel error");
+	pg_unreachable();
+}
diff --git a/src/test/modules/oauth_validator/magic_validator.c b/src/test/modules/oauth_validator/magic_validator.c
new file mode 100644
index 00000000000..9dc55b602e3
--- /dev/null
+++ b/src/test/modules/oauth_validator/magic_validator.c
@@ -0,0 +1,48 @@
+/*-------------------------------------------------------------------------
+ *
+ * magic_validator.c
+ *	  Test module for serverside OAuth token validation callbacks, which
+ *	  should fail due to using the wrong PG_OAUTH_VALIDATOR_MAGIC marker
+ *	  and thus the wrong ABI version
+ *
+ * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/test/modules/oauth_validator/magic_validator.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "fmgr.h"
+#include "libpq/oauth.h"
+
+PG_MODULE_MAGIC;
+
+static bool validate_token(const ValidatorModuleState *state,
+						   const char *token,
+						   const char *role,
+						   ValidatorModuleResult *result);
+
+/* Callback implementations (we only need the main one) */
+static const OAuthValidatorCallbacks validator_callbacks = {
+	0xdeadbeef,
+
+	.validate_cb = validate_token,
+};
+
+const OAuthValidatorCallbacks *
+_PG_oauth_validator_module_init(void)
+{
+	return &validator_callbacks;
+}
+
+static bool
+validate_token(const ValidatorModuleState *state,
+			   const char *token, const char *role,
+			   ValidatorModuleResult *res)
+{
+	elog(FATAL, "magic_validator: this should be unreachable");
+	pg_unreachable();
+}
diff --git a/src/test/modules/oauth_validator/meson.build b/src/test/modules/oauth_validator/meson.build
new file mode 100644
index 00000000000..36d1b26369f
--- /dev/null
+++ b/src/test/modules/oauth_validator/meson.build
@@ -0,0 +1,85 @@
+# Copyright (c) 2025, PostgreSQL Global Development Group
+
+validator_sources = files(
+  'validator.c',
+)
+
+if host_system == 'windows'
+  validator_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'validator',
+    '--FILEDESC', 'validator - test OAuth validator module',])
+endif
+
+validator = shared_module('validator',
+  validator_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += validator
+
+fail_validator_sources = files(
+  'fail_validator.c',
+)
+
+if host_system == 'windows'
+  fail_validator_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'fail_validator',
+    '--FILEDESC', 'fail_validator - failing OAuth validator module',])
+endif
+
+fail_validator = shared_module('fail_validator',
+  fail_validator_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += fail_validator
+
+magic_validator_sources = files(
+  'magic_validator.c',
+)
+
+if host_system == 'windows'
+  magic_validator_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'magic_validator',
+    '--FILEDESC', 'magic_validator - ABI incompatible OAuth validator module',])
+endif
+
+magic_validator = shared_module('magic_validator',
+  magic_validator_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += magic_validator
+
+oauth_hook_client_sources = files(
+  'oauth_hook_client.c',
+)
+
+if host_system == 'windows'
+  oauth_hook_client_sources += rc_bin_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'oauth_hook_client',
+    '--FILEDESC', 'oauth_hook_client - test program for libpq OAuth hooks',])
+endif
+
+oauth_hook_client = executable('oauth_hook_client',
+  oauth_hook_client_sources,
+  dependencies: [frontend_code, libpq],
+  kwargs: default_bin_args + {
+    'install': false,
+  },
+)
+testprep_targets += oauth_hook_client
+
+tests += {
+  'name': 'oauth_validator',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'tap': {
+    'tests': [
+      't/001_server.pl',
+      't/002_client.pl',
+    ],
+    'env': {
+      'PYTHON': python.path(),
+      'with_libcurl': libcurl.found() ? 'yes' : 'no',
+      'with_python': 'yes',
+    },
+  },
+}
diff --git a/src/test/modules/oauth_validator/oauth_hook_client.c b/src/test/modules/oauth_validator/oauth_hook_client.c
new file mode 100644
index 00000000000..9f553792c05
--- /dev/null
+++ b/src/test/modules/oauth_validator/oauth_hook_client.c
@@ -0,0 +1,293 @@
+/*-------------------------------------------------------------------------
+ *
+ * oauth_hook_client.c
+ *		Test driver for t/002_client.pl, which verifies OAuth hook
+ *		functionality in libpq.
+ *
+ * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ *
+ * IDENTIFICATION
+ *		src/test/modules/oauth_validator/oauth_hook_client.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres_fe.h"
+
+#include <sys/socket.h>
+
+#include "getopt_long.h"
+#include "libpq-fe.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(" --expected-scope SCOPE	fail if received scopes do not match SCOPE\n");
+	printf(" --expected-uri URI		fail if received configuration link does not match URI\n");
+	printf(" --misbehave=MODE		have the hook fail required postconditions\n"
+		   "						(MODEs: no-hook, fail-async, no-token, no-socket)\n");
+	printf(" --no-hook				don't install OAuth hooks\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(" --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_scope = NULL;
+static const char *misbehave_mode = NULL;
+static char *token = NULL;
+
+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},
+		{0}
+	};
+
+	const char *conninfo;
+	PGconn	   *conn;
+	int			c;
+
+	while ((c = getopt_long(argc, argv, "h", long_options, NULL)) != -1)
+	{
+		switch (c)
+		{
+			case 'h':
+				usage(argv);
+				return 0;
+
+			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;
+
+			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);
+
+	/* Connect. (All the actual work is in the hook.) */
+	if (stress_async)
+	{
+		/*
+		 * Perform an asynchronous connection, busy-looping on PQconnectPoll()
+		 * without actually waiting on socket events. This stresses code paths
+		 * that rely on asynchronous work to be done before continuing with
+		 * the next step in the flow.
+		 */
+		PostgresPollingStatusType res;
+
+		conn = PQconnectStart(conninfo);
+
+		do
+		{
+			res = PQconnectPoll(conn);
+		} while (res != PGRES_POLLING_FAILED && res != PGRES_POLLING_OK);
+	}
+	else
+	{
+		/* Perform a standard synchronous connection. */
+		conn = PQconnectdb(conninfo);
+	}
+
+	if (PQstatus(conn) != CONNECTION_OK)
+	{
+		fprintf(stderr, "connection to database failed: %s\n",
+				PQerrorMessage(conn));
+		PQfinish(conn);
+		return 1;
+	}
+
+	printf("connection succeeded\n");
+	PQfinish(conn);
+	return 0;
+}
+
+/*
+ * PQauthDataHook implementation. Replaces the default client flow by handling
+ * PQAUTHDATA_OAUTH_BEARER_TOKEN.
+ */
+static int
+handle_auth_data(PGauthData type, PGconn *conn, void *data)
+{
+	PGoauthBearerRequest *req = data;
+
+	if (no_hook || (type != PQAUTHDATA_OAUTH_BEARER_TOKEN))
+		return 0;
+
+	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;
+		}
+	}
+
+	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". */
+		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/001_server.pl b/src/test/modules/oauth_validator/t/001_server.pl
new file mode 100644
index 00000000000..6fa59fbeb25
--- /dev/null
+++ b/src/test/modules/oauth_validator/t/001_server.pl
@@ -0,0 +1,594 @@
+
+#
+# Tests the libpq builtin OAuth flow, as well as server-side HBA and validator
+# setup.
+#
+# Copyright (c) 2021-2025, PostgreSQL Global Development Group
+#
+
+use strict;
+use warnings FATAL => 'all';
+
+use JSON::PP     qw(encode_json);
+use MIME::Base64 qw(encode_base64);
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+use FindBin;
+use lib $FindBin::RealBin;
+
+use OAuth::Server;
+
+if (!$ENV{PG_TEST_EXTRA} || $ENV{PG_TEST_EXTRA} !~ /\boauth\b/)
+{
+	plan skip_all =>
+	  'Potentially unsafe test oauth not enabled in PG_TEST_EXTRA';
+}
+
+if ($windows_os)
+{
+	plan skip_all => 'OAuth server-side tests are not supported on Windows';
+}
+
+if ($ENV{with_libcurl} ne 'yes')
+{
+	plan skip_all => 'client-side OAuth not supported by this build';
+}
+
+if ($ENV{with_python} ne 'yes')
+{
+	plan skip_all => 'OAuth tests require --with-python to run';
+}
+
+my $node = PostgreSQL::Test::Cluster->new('primary');
+$node->init;
+$node->append_conf('postgresql.conf', "log_connections = on\n");
+$node->append_conf('postgresql.conf',
+	"oauth_validator_libraries = 'validator'\n");
+$node->start;
+
+$node->safe_psql('postgres', 'CREATE USER test;');
+$node->safe_psql('postgres', 'CREATE USER testalt;');
+$node->safe_psql('postgres', 'CREATE USER testparam;');
+
+# Save a background connection for later configuration changes.
+my $bgconn = $node->background_psql('postgres');
+
+my $webserver = OAuth::Server->new();
+$webserver->run();
+
+END
+{
+	my $exit_code = $?;
+
+	$webserver->stop() if defined $webserver;    # might have been SKIP'd
+
+	$? = $exit_code;
+}
+
+my $port = $webserver->port();
+my $issuer = "http://localhost:$port";
+
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test      oauth issuer="$issuer"       scope="openid postgres"
+local all testalt   oauth issuer="$issuer/.well-known/oauth-authorization-server/alternate" scope="openid postgres alt"
+local all testparam oauth issuer="$issuer/param" scope="openid postgres"
+});
+$node->reload;
+
+my $log_start = $node->wait_for_log(qr/reloading configuration files/);
+
+# Check pg_hba_file_rules() support.
+my $contents = $bgconn->query_safe(
+	qq(SELECT rule_number, auth_method, options
+		 FROM pg_hba_file_rules
+		 ORDER BY rule_number;));
+is( $contents,
+	qq{1|oauth|\{issuer=$issuer,"scope=openid postgres",validator=validator\}
+2|oauth|\{issuer=$issuer/.well-known/oauth-authorization-server/alternate,"scope=openid postgres alt",validator=validator\}
+3|oauth|\{issuer=$issuer/param,"scope=openid postgres",validator=validator\}},
+	"pg_hba_file_rules recreates OAuth HBA settings");
+
+# To test against HTTP rather than HTTPS, we need to enable PGOAUTHDEBUG. But
+# first, check to make sure the client refuses such connections by default.
+$node->connect_fails(
+	"user=test dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635",
+	"HTTPS is required without debug mode",
+	expected_stderr =>
+	  qr@OAuth discovery URI "\Q$issuer\E/.well-known/openid-configuration" must use HTTPS@
+);
+
+$ENV{PGOAUTHDEBUG} = "UNSAFE";
+
+my $user = "test";
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635",
+	"connect as test",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@,
+	log_like => [
+		qr/oauth_validator: token="9243959234", role="$user"/,
+		qr/oauth_validator: issuer="\Q$issuer\E", scope="openid postgres"/,
+		qr/connection authenticated: identity="test" method=oauth/,
+		qr/connection authorized/,
+	]);
+
+# The /alternate issuer uses slightly different parameters, along with an
+# OAuth-style discovery document.
+$user = "testalt";
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer/alternate oauth_client_id=f02c6361-0636",
+	"connect as testalt",
+	expected_stderr =>
+	  qr@Visit https://example\.org/ and enter the code: postgresuser@,
+	log_like => [
+		qr/oauth_validator: token="9243959234-alt", role="$user"/,
+		qr|oauth_validator: issuer="\Q$issuer/.well-known/oauth-authorization-server/alternate\E", scope="openid postgres alt"|,
+		qr/connection authenticated: identity="testalt" method=oauth/,
+		qr/connection authorized/,
+	]);
+
+# The issuer linked by the server must match the client's oauth_issuer setting.
+$node->connect_fails(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0636",
+	"oauth_issuer must match discovery",
+	expected_stderr =>
+	  qr@server's discovery document at \Q$issuer/.well-known/oauth-authorization-server/alternate\E \(issuer "\Q$issuer/alternate\E"\) is incompatible with oauth_issuer \(\Q$issuer\E\)@
+);
+
+# Test require_auth settings against OAUTHBEARER.
+my @cases = (
+	{ require_auth => "oauth" },
+	{ require_auth => "oauth,scram-sha-256" },
+	{ require_auth => "password,oauth" },
+	{ require_auth => "none,oauth" },
+	{ require_auth => "!scram-sha-256" },
+	{ require_auth => "!none" },
+
+	{
+		require_auth => "!oauth",
+		failure => qr/server requested OAUTHBEARER authentication/
+	},
+	{
+		require_auth => "scram-sha-256",
+		failure => qr/server requested OAUTHBEARER authentication/
+	},
+	{
+		require_auth => "!password,!oauth",
+		failure => qr/server requested OAUTHBEARER authentication/
+	},
+	{
+		require_auth => "none",
+		failure => qr/server requested SASL authentication/
+	},
+	{
+		require_auth => "!oauth,!scram-sha-256",
+		failure => qr/server requested SASL authentication/
+	});
+
+$user = "test";
+foreach my $c (@cases)
+{
+	my $connstr =
+	  "user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635 require_auth=$c->{'require_auth'}";
+
+	if (defined $c->{'failure'})
+	{
+		$node->connect_fails(
+			$connstr,
+			"require_auth=$c->{'require_auth'} fails",
+			expected_stderr => $c->{'failure'});
+	}
+	else
+	{
+		$node->connect_ok(
+			$connstr,
+			"require_auth=$c->{'require_auth'} succeeds",
+			expected_stderr =>
+			  qr@Visit https://example\.com/ and enter the code: postgresuser@
+		);
+	}
+}
+
+# Make sure the client_id and secret are correctly encoded. $vschars contains
+# every allowed character for a client_id/_secret (the "VSCHAR" class).
+# $vschars_esc is additionally backslash-escaped for inclusion in a
+# single-quoted connection string.
+my $vschars =
+  " !\"#\$%&'()*+,-./0123456789:;<=>?\@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~";
+my $vschars_esc =
+  " !\"#\$%&\\'()*+,-./0123456789:;<=>?\@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\\\]^_`abcdefghijklmnopqrstuvwxyz{|}~";
+
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id='$vschars_esc'",
+	"escapable characters: client_id",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id='$vschars_esc' oauth_client_secret='$vschars_esc'",
+	"escapable characters: client_id and secret",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+#
+# Further tests rely on support for specific behaviors in oauth_server.py. To
+# trigger these behaviors, we ask for the special issuer .../param (which is set
+# up in HBA for the testparam user) and encode magic instructions into the
+# oauth_client_id.
+#
+
+my $common_connstr =
+  "user=testparam dbname=postgres oauth_issuer=$issuer/param ";
+my $base_connstr = $common_connstr;
+
+sub connstr
+{
+	my (%params) = @_;
+
+	my $json = encode_json(\%params);
+	my $encoded = encode_base64($json, "");
+
+	return "$base_connstr oauth_client_id=$encoded";
+}
+
+# Make sure the param system works end-to-end first.
+$node->connect_ok(
+	connstr(),
+	"connect to /param",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$node->connect_ok(
+	connstr(stage => 'token', retries => 1),
+	"token retry",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(stage => 'token', retries => 2),
+	"token retry (twice)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(stage => 'all', retries => 1, interval => 2),
+	"token retry (two second interval)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(stage => 'all', retries => 1, interval => JSON::PP::null),
+	"token retry (default interval)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$node->connect_ok(
+	connstr(stage => 'all', content_type => 'application/json;charset=utf-8'),
+	"content type with charset",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(
+		stage => 'all',
+		content_type => "application/json \t;\t charset=utf-8"),
+	"content type with charset (whitespace)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(stage => 'device', uri_spelling => "verification_url"),
+	"alternative spelling of verification_uri",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$node->connect_fails(
+	connstr(stage => 'device', huge_response => JSON::PP::true),
+	"bad device authz response: overlarge JSON",
+	expected_stderr =>
+	  qr/failed to obtain device authorization: response is too large/);
+$node->connect_fails(
+	connstr(stage => 'token', huge_response => JSON::PP::true),
+	"bad token response: overlarge JSON",
+	expected_stderr =>
+	  qr/failed to obtain access token: response is too large/);
+
+$node->connect_fails(
+	connstr(stage => 'device', content_type => 'text/plain'),
+	"bad device authz response: wrong content type",
+	expected_stderr =>
+	  qr/failed to parse device authorization: unexpected content type/);
+$node->connect_fails(
+	connstr(stage => 'token', content_type => 'text/plain'),
+	"bad token response: wrong content type",
+	expected_stderr =>
+	  qr/failed to parse access token response: unexpected content type/);
+$node->connect_fails(
+	connstr(stage => 'token', content_type => 'application/jsonx'),
+	"bad token response: wrong content type (correct prefix)",
+	expected_stderr =>
+	  qr/failed to parse access token response: unexpected content type/);
+
+$node->connect_fails(
+	connstr(
+		stage => 'all',
+		interval => ~0,
+		retries => 1,
+		retry_code => "slow_down"),
+	"bad token response: server overflows the device authz interval",
+	expected_stderr =>
+	  qr/failed to obtain access token: slow_down interval overflow/);
+
+$node->connect_fails(
+	connstr(stage => 'token', error_code => "invalid_grant"),
+	"bad token response: invalid_grant, no description",
+	expected_stderr => qr/failed to obtain access token: \(invalid_grant\)/);
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_grant",
+		error_desc => "grant expired"),
+	"bad token response: expired grant",
+	expected_stderr =>
+	  qr/failed to obtain access token: grant expired \(invalid_grant\)/);
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_client",
+		error_status => 401),
+	"bad token response: client authentication failure, default description",
+	expected_stderr =>
+	  qr/failed to obtain access token: provider requires client authentication, and no oauth_client_secret is set \(invalid_client\)/
+);
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_client",
+		error_status => 401,
+		error_desc => "authn failure"),
+	"bad token response: client authentication failure, provided description",
+	expected_stderr =>
+	  qr/failed to obtain access token: authn failure \(invalid_client\)/);
+
+$node->connect_fails(
+	connstr(stage => 'token', token => ""),
+	"server rejects access: empty token",
+	expected_stderr => qr/bearer authentication failed/);
+$node->connect_fails(
+	connstr(stage => 'token', token => "****"),
+	"server rejects access: invalid token contents",
+	expected_stderr => qr/bearer authentication failed/);
+
+# Test behavior of the oauth_client_secret.
+$base_connstr = "$common_connstr oauth_client_secret=''";
+
+$node->connect_ok(
+	connstr(stage => 'all', expected_secret => ''),
+	"empty oauth_client_secret",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$base_connstr = "$common_connstr oauth_client_secret='$vschars_esc'";
+
+$node->connect_ok(
+	connstr(stage => 'all', expected_secret => $vschars),
+	"nonempty oauth_client_secret",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_client",
+		error_status => 401),
+	"bad token response: client authentication failure, default description with oauth_client_secret",
+	expected_stderr =>
+	  qr/failed to obtain access token: provider rejected the oauth_client_secret \(invalid_client\)/
+);
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_client",
+		error_status => 401,
+		error_desc => "mutual TLS required for client"),
+	"bad token response: client authentication failure, provided description with oauth_client_secret",
+	expected_stderr =>
+	  qr/failed to obtain access token: mutual TLS required for client \(invalid_client\)/
+);
+
+# Stress test: make sure our builtin flow operates correctly even if the client
+# application isn't respecting PGRES_POLLING_READING/WRITING signals returned
+# from PQconnectPoll().
+$base_connstr =
+  "$common_connstr port=" . $node->port . " host=" . $node->host;
+my @cmd = (
+	"oauth_hook_client", "--no-hook", "--stress-async",
+	connstr(stage => 'all', retries => 1, interval => 1));
+
+note "running '" . join("' '", @cmd) . "'";
+my ($stdout, $stderr) = run_command(\@cmd);
+
+like($stdout, qr/connection succeeded/, "stress-async: stdout matches");
+unlike(
+	$stderr,
+	qr/connection to database failed/,
+	"stress-async: stderr matches");
+
+#
+# This section of tests reconfigures the validator module itself, rather than
+# the OAuth server.
+#
+
+# Searching the logs is easier if OAuth parameter discovery isn't cluttering
+# things up; hardcode the discovery URI. (Scope is hardcoded to empty to cover
+# that case as well.)
+$common_connstr =
+  "dbname=postgres oauth_issuer=$issuer/.well-known/openid-configuration oauth_scope='' oauth_client_id=f02c6361-0635";
+
+# Misbehaving validators must fail shut.
+$bgconn->query_safe("ALTER SYSTEM SET oauth_validator.authn_id TO ''");
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+$node->connect_fails(
+	"$common_connstr user=test",
+	"validator must set authn_id",
+	expected_stderr => qr/OAuth bearer authentication failed/,
+	log_like => [
+		qr/connection authenticated: identity=""/,
+		qr/DETAIL:\s+Validator provided no identity/,
+		qr/FATAL:\s+OAuth bearer authentication failed/,
+	]);
+
+# Even if a validator authenticates the user, if the token isn't considered
+# valid, the connection fails.
+$bgconn->query_safe(
+	"ALTER SYSTEM SET oauth_validator.authn_id TO 'test\@example.org'");
+$bgconn->query_safe(
+	"ALTER SYSTEM SET oauth_validator.authorize_tokens TO false");
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+$node->connect_fails(
+	"$common_connstr user=test",
+	"validator must authorize token explicitly",
+	expected_stderr => qr/OAuth bearer authentication failed/,
+	log_like => [
+		qr/connection authenticated: identity="test\@example\.org"/,
+		qr/DETAIL:\s+Validator failed to authorize the provided token/,
+		qr/FATAL:\s+OAuth bearer authentication failed/,
+	]);
+
+#
+# Test user mapping.
+#
+
+# Allow "[email protected]" to log in under the test role.
+unlink($node->data_dir . '/pg_ident.conf');
+$node->append_conf(
+	'pg_ident.conf', qq{
+oauthmap	user\@example.com	test
+});
+
+# test and testalt use the map; testparam uses ident delegation.
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test      oauth issuer="$issuer" scope="" map=oauthmap
+local all testalt   oauth issuer="$issuer" scope="" map=oauthmap
+local all testparam oauth issuer="$issuer" scope="" delegate_ident_mapping=1
+});
+
+# To start, have the validator use the role names as authn IDs.
+$bgconn->query_safe("ALTER SYSTEM RESET oauth_validator.authn_id");
+$bgconn->query_safe("ALTER SYSTEM RESET oauth_validator.authorize_tokens");
+
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+# The test and testalt roles should no longer map correctly.
+$node->connect_fails(
+	"$common_connstr user=test",
+	"mismatched username map (test)",
+	expected_stderr => qr/OAuth bearer authentication failed/);
+$node->connect_fails(
+	"$common_connstr user=testalt",
+	"mismatched username map (testalt)",
+	expected_stderr => qr/OAuth bearer authentication failed/);
+
+# Have the validator identify the end user as [email protected].
+$bgconn->query_safe(
+	"ALTER SYSTEM SET oauth_validator.authn_id TO 'user\@example.com'");
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+# Now the test role can be logged into. (testalt still can't be mapped.)
+$node->connect_ok(
+	"$common_connstr user=test",
+	"matched username map (test)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_fails(
+	"$common_connstr user=testalt",
+	"mismatched username map (testalt)",
+	expected_stderr => qr/OAuth bearer authentication failed/);
+
+# testparam ignores the map entirely.
+$node->connect_ok(
+	"$common_connstr user=testparam",
+	"delegated ident (testparam)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$bgconn->query_safe("ALTER SYSTEM RESET oauth_validator.authn_id");
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+#
+# Test multiple validators.
+#
+
+$node->append_conf('postgresql.conf',
+	"oauth_validator_libraries = 'validator, fail_validator'\n");
+
+# With multiple validators, every HBA line must explicitly declare one.
+my $result = $node->restart(fail_ok => 1);
+is($result, 0,
+	'restart fails without explicit validators in oauth HBA entries');
+
+$log_start = $node->wait_for_log(
+	qr/authentication method "oauth" requires argument "validator" to be set/,
+	$log_start);
+
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test    oauth validator=validator      issuer="$issuer"           scope="openid postgres"
+local all testalt oauth validator=fail_validator issuer="$issuer/.well-known/oauth-authorization-server/alternate" scope="openid postgres alt"
+});
+$node->restart;
+
+$log_start = $node->wait_for_log(qr/ready to accept connections/, $log_start);
+
+# The test user should work as before.
+$user = "test";
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635",
+	"validator is used for $user",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@,
+	log_like => [qr/connection authorized/]);
+
+# testalt should be routed through the fail_validator.
+$user = "testalt";
+$node->connect_fails(
+	"user=$user dbname=postgres oauth_issuer=$issuer/.well-known/oauth-authorization-server/alternate oauth_client_id=f02c6361-0636",
+	"fail_validator is used for $user",
+	expected_stderr => qr/FATAL:\s+fail_validator: sentinel error/);
+
+#
+# Test ABI compatibility magic marker
+#
+$node->append_conf('postgresql.conf',
+	"oauth_validator_libraries = 'magic_validator'\n");
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test    oauth validator=magic_validator      issuer="$issuer"           scope="openid postgres"
+});
+$node->restart;
+
+$log_start = $node->wait_for_log(qr/ready to accept connections/, $log_start);
+
+$node->connect_fails(
+	"user=test dbname=postgres oauth_issuer=$issuer/.well-known/oauth-authorization-server/alternate oauth_client_id=f02c6361-0636",
+	"magic_validator is used for $user",
+	expected_stderr =>
+	  qr/FATAL:\s+OAuth validator module "magic_validator": magic number mismatch/
+);
+$node->stop;
+
+done_testing();
diff --git a/src/test/modules/oauth_validator/t/002_client.pl b/src/test/modules/oauth_validator/t/002_client.pl
new file mode 100644
index 00000000000..ab83258d736
--- /dev/null
+++ b/src/test/modules/oauth_validator/t/002_client.pl
@@ -0,0 +1,154 @@
+#
+# Exercises the API for custom OAuth client flows, using the oauth_hook_client
+# test driver.
+#
+# Copyright (c) 2021-2025, PostgreSQL Global Development Group
+#
+
+use strict;
+use warnings FATAL => 'all';
+
+use JSON::PP     qw(encode_json);
+use MIME::Base64 qw(encode_base64);
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+if (!$ENV{PG_TEST_EXTRA} || $ENV{PG_TEST_EXTRA} !~ /\boauth\b/)
+{
+	plan skip_all =>
+	  'Potentially unsafe test oauth not enabled in PG_TEST_EXTRA';
+}
+
+#
+# Cluster Setup
+#
+
+my $node = PostgreSQL::Test::Cluster->new('primary');
+$node->init;
+$node->append_conf('postgresql.conf', "log_connections = on\n");
+$node->append_conf('postgresql.conf',
+	"oauth_validator_libraries = 'validator'\n");
+$node->start;
+
+$node->safe_psql('postgres', 'CREATE USER test;');
+
+# These tests don't use the builtin flow, and we don't have an authorization
+# server running, so the address used here shouldn't matter. Use an invalid IP
+# address, so if there's some cascade of errors that causes the client to
+# attempt a connection, we'll fail noisily.
+my $issuer = "https://256.256.256.256";
+my $scope = "openid postgres";
+
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test oauth issuer="$issuer" scope="$scope"
+});
+$node->reload;
+
+my ($log_start, $log_end);
+$log_start = $node->wait_for_log(qr/reloading configuration files/);
+
+$ENV{PGOAUTHDEBUG} = "UNSAFE";
+
+#
+# Tests
+#
+
+my $user = "test";
+my $base_connstr = $node->connstr() . " user=$user";
+my $common_connstr =
+  "$base_connstr oauth_issuer=$issuer oauth_client_id=myID";
+
+sub test
+{
+	my ($test_name, %params) = @_;
+
+	my $flags = [];
+	if (defined($params{flags}))
+	{
+		$flags = $params{flags};
+	}
+
+	my @cmd = ("oauth_hook_client", @{$flags}, $common_connstr);
+	note "running '" . join("' '", @cmd) . "'";
+
+	my ($stdout, $stderr) = run_command(\@cmd);
+
+	if (defined($params{expected_stdout}))
+	{
+		like($stdout, $params{expected_stdout}, "$test_name: stdout matches");
+	}
+
+	if (defined($params{expected_stderr}))
+	{
+		like($stderr, $params{expected_stderr}, "$test_name: stderr matches");
+	}
+	else
+	{
+		is($stderr, "", "$test_name: no stderr");
+	}
+}
+
+test(
+	"basic synchronous hook can provide a token",
+	flags => [
+		"--token", "my-token",
+		"--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"/, ]);
+
+if ($ENV{with_libcurl} ne 'yes')
+{
+	# libpq should help users out if no OAuth support is built in.
+	test(
+		"fails without custom hook installed",
+		flags => ["--no-hook"],
+		expected_stderr =>
+		  qr/no custom OAuth flows are available, and libpq was not built with libcurl support/
+	);
+}
+
+# connect_timeout should work if the flow doesn't respond.
+$common_connstr = "$common_connstr connect_timeout=1";
+test(
+	"connect_timeout interrupts hung client flow",
+	flags => ["--hang-forever"],
+	expected_stderr => qr/failed: timeout expired/);
+
+# Test various misbehaviors of the client hook.
+my @cases = (
+	{
+		flag => "--misbehave=no-hook",
+		expected_error =>
+		  qr/user-defined OAuth flow provided neither a token nor an async callback/,
+	},
+	{
+		flag => "--misbehave=fail-async",
+		expected_error => qr/user-defined OAuth flow failed/,
+	},
+	{
+		flag => "--misbehave=no-token",
+		expected_error => qr/user-defined OAuth flow did not provide a token/,
+	},
+	{
+		flag => "--misbehave=no-socket",
+		expected_error =>
+		  qr/user-defined OAuth flow did not provide a socket for polling/,
+	});
+
+foreach my $c (@cases)
+{
+	test(
+		"hook misbehavior: $c->{'flag'}",
+		flags => [ $c->{'flag'} ],
+		expected_stderr => $c->{'expected_error'});
+}
+
+done_testing();
diff --git a/src/test/modules/oauth_validator/t/OAuth/Server.pm b/src/test/modules/oauth_validator/t/OAuth/Server.pm
new file mode 100644
index 00000000000..655b2870b0b
--- /dev/null
+++ b/src/test/modules/oauth_validator/t/OAuth/Server.pm
@@ -0,0 +1,140 @@
+
+# Copyright (c) 2025, PostgreSQL Global Development Group
+
+=pod
+
+=head1 NAME
+
+OAuth::Server - runs a mock OAuth authorization server for testing
+
+=head1 SYNOPSIS
+
+  use OAuth::Server;
+
+  my $server = OAuth::Server->new();
+  $server->run;
+
+  my $port = $server->port;
+  my $issuer = "http://localhost:$port";
+
+  # test against $issuer...
+
+  $server->stop;
+
+=head1 DESCRIPTION
+
+This is glue API between the Perl tests and the Python authorization server
+daemon implemented in t/oauth_server.py. (Python has a fairly usable HTTP server
+in its standard library, so the implementation was ported from Perl.)
+
+This authorization server does not use TLS (it implements a nonstandard, unsafe
+issuer at "http://localhost:<port>"), so libpq in particular will need to set
+PGOAUTHDEBUG=UNSAFE to be able to talk to it.
+
+=cut
+
+package OAuth::Server;
+
+use warnings;
+use strict;
+use Scalar::Util;
+use Test::More;
+
+=pod
+
+=head1 METHODS
+
+=over
+
+=item OAuth::Server->new()
+
+Create a new OAuth Server object.
+
+=cut
+
+sub new
+{
+	my $class = shift;
+
+	my $self = {};
+	bless($self, $class);
+
+	return $self;
+}
+
+=pod
+
+=item $server->port()
+
+Returns the port in use by the server.
+
+=cut
+
+sub port
+{
+	my $self = shift;
+
+	return $self->{'port'};
+}
+
+=pod
+
+=item $server->run()
+
+Runs the authorization server daemon in t/oauth_server.py.
+
+=cut
+
+sub run
+{
+	my $self = shift;
+	my $port;
+
+	my $pid = open(my $read_fh, "-|", $ENV{PYTHON}, "t/oauth_server.py")
+	  or die "failed to start OAuth server: $!";
+
+	# Get the port number from the daemon. It closes stdout afterwards; that way
+	# we can slurp in the entire contents here rather than worrying about the
+	# number of bytes to read.
+	$port = do { local $/ = undef; <$read_fh> }
+	  // die "failed to read port number: $!";
+	chomp $port;
+	die "server did not advertise a valid port"
+	  unless Scalar::Util::looks_like_number($port);
+
+	$self->{'pid'} = $pid;
+	$self->{'port'} = $port;
+	$self->{'child'} = $read_fh;
+
+	note("OAuth provider (PID $pid) is listening on port $port\n");
+}
+
+=pod
+
+=item $server->stop()
+
+Sends SIGTERM to the authorization server and waits for it to exit.
+
+=cut
+
+sub stop
+{
+	my $self = shift;
+
+	note("Sending SIGTERM to OAuth provider PID: $self->{'pid'}\n");
+
+	kill(15, $self->{'pid'});
+	$self->{'pid'} = undef;
+
+	# Closing the popen() handle waits for the process to exit.
+	close($self->{'child'});
+	$self->{'child'} = undef;
+}
+
+=pod
+
+=back
+
+=cut
+
+1;
diff --git a/src/test/modules/oauth_validator/t/oauth_server.py b/src/test/modules/oauth_validator/t/oauth_server.py
new file mode 100755
index 00000000000..4faf3323d38
--- /dev/null
+++ b/src/test/modules/oauth_validator/t/oauth_server.py
@@ -0,0 +1,391 @@
+#! /usr/bin/env python3
+#
+# A mock OAuth authorization server, designed to be invoked from
+# OAuth/Server.pm. This listens on an ephemeral port number (printed to stdout
+# so that the Perl tests can contact it) and runs as a daemon until it is
+# signaled.
+#
+
+import base64
+import http.server
+import json
+import os
+import sys
+import time
+import urllib.parse
+from collections import defaultdict
+
+
+class OAuthHandler(http.server.BaseHTTPRequestHandler):
+    """
+    Core implementation of the authorization server. The API is
+    inheritance-based, with entry points at do_GET() and do_POST(). See the
+    documentation for BaseHTTPRequestHandler.
+    """
+
+    JsonObject = dict[str, object]  # TypeAlias is not available until 3.10
+
+    def _check_issuer(self):
+        """
+        Switches the behavior of the provider depending on the issuer URI.
+        """
+        self._alt_issuer = (
+            self.path.startswith("/alternate/")
+            or self.path == "/.well-known/oauth-authorization-server/alternate"
+        )
+        self._parameterized = self.path.startswith("/param/")
+
+        if self._alt_issuer:
+            # The /alternate issuer uses IETF-style .well-known URIs.
+            if self.path.startswith("/.well-known/"):
+                self.path = self.path.removesuffix("/alternate")
+            else:
+                self.path = self.path.removeprefix("/alternate")
+        elif self._parameterized:
+            self.path = self.path.removeprefix("/param")
+
+    def _check_authn(self):
+        """
+        Checks the expected value of the Authorization header, if any.
+        """
+        secret = self._get_param("expected_secret", None)
+        if secret is None:
+            return
+
+        assert "Authorization" in self.headers
+        method, creds = self.headers["Authorization"].split()
+
+        if method != "Basic":
+            raise RuntimeError(f"client used {method} auth; expected Basic")
+
+        username = urllib.parse.quote_plus(self.client_id)
+        password = urllib.parse.quote_plus(secret)
+        expected_creds = f"{username}:{password}"
+
+        if creds.encode() != base64.b64encode(expected_creds.encode()):
+            raise RuntimeError(
+                f"client sent '{creds}'; expected b64encode('{expected_creds}')"
+            )
+
+    def do_GET(self):
+        self._response_code = 200
+        self._check_issuer()
+
+        config_path = "/.well-known/openid-configuration"
+        if self._alt_issuer:
+            config_path = "/.well-known/oauth-authorization-server"
+
+        if self.path == config_path:
+            resp = self.config()
+        else:
+            self.send_error(404, "Not Found")
+            return
+
+        self._send_json(resp)
+
+    def _parse_params(self) -> dict[str, str]:
+        """
+        Parses apart the form-urlencoded request body and returns the resulting
+        dict. For use by do_POST().
+        """
+        size = int(self.headers["Content-Length"])
+        form = self.rfile.read(size)
+
+        assert self.headers["Content-Type"] == "application/x-www-form-urlencoded"
+        return urllib.parse.parse_qs(
+            form.decode("utf-8"),
+            strict_parsing=True,
+            keep_blank_values=True,
+            encoding="utf-8",
+            errors="strict",
+        )
+
+    @property
+    def client_id(self) -> str:
+        """
+        Returns the client_id sent in the POST body or the Authorization header.
+        self._parse_params() must have been called first.
+        """
+        if "client_id" in self._params:
+            return self._params["client_id"][0]
+
+        if "Authorization" not in self.headers:
+            raise RuntimeError("client did not send any client_id")
+
+        _, creds = self.headers["Authorization"].split()
+
+        decoded = base64.b64decode(creds).decode("utf-8")
+        username, _ = decoded.split(":", 1)
+
+        return urllib.parse.unquote_plus(username)
+
+    def do_POST(self):
+        self._response_code = 200
+        self._check_issuer()
+
+        self._params = self._parse_params()
+        if self._parameterized:
+            # Pull encoded test parameters out of the peer's client_id field.
+            # This is expected to be Base64-encoded JSON.
+            js = base64.b64decode(self.client_id)
+            self._test_params = json.loads(js)
+
+        self._check_authn()
+
+        if self.path == "/authorize":
+            resp = self.authorization()
+        elif self.path == "/token":
+            resp = self.token()
+        else:
+            self.send_error(404)
+            return
+
+        self._send_json(resp)
+
+    def _should_modify(self) -> bool:
+        """
+        Returns True if the client has requested a modification to this stage of
+        the exchange.
+        """
+        if not hasattr(self, "_test_params"):
+            return False
+
+        stage = self._test_params.get("stage")
+
+        return (
+            stage == "all"
+            or (
+                stage == "discovery"
+                and self.path == "/.well-known/openid-configuration"
+            )
+            or (stage == "device" and self.path == "/authorize")
+            or (stage == "token" and self.path == "/token")
+        )
+
+    def _get_param(self, name, default):
+        """
+        If the client has requested a modification to this stage (see
+        _should_modify()), this method searches the provided test parameters for
+        a key of the given name, and returns it if found. Otherwise the provided
+        default is returned.
+        """
+        if self._should_modify() and name in self._test_params:
+            return self._test_params[name]
+
+        return default
+
+    @property
+    def _content_type(self) -> str:
+        """
+        Returns "application/json" unless the test has requested something
+        different.
+        """
+        return self._get_param("content_type", "application/json")
+
+    @property
+    def _interval(self) -> int:
+        """
+        Returns 0 unless the test has requested something different.
+        """
+        return self._get_param("interval", 0)
+
+    @property
+    def _retry_code(self) -> str:
+        """
+        Returns "authorization_pending" unless the test has requested something
+        different.
+        """
+        return self._get_param("retry_code", "authorization_pending")
+
+    @property
+    def _uri_spelling(self) -> str:
+        """
+        Returns "verification_uri" unless the test has requested something
+        different.
+        """
+        return self._get_param("uri_spelling", "verification_uri")
+
+    @property
+    def _response_padding(self):
+        """
+        If the huge_response test parameter is set to True, returns a dict
+        containing a gigantic string value, which can then be folded into a JSON
+        response.
+        """
+        if not self._get_param("huge_response", False):
+            return dict()
+
+        return {"_pad_": "x" * 1024 * 1024}
+
+    @property
+    def _access_token(self):
+        """
+        The actual Bearer token sent back to the client on success. Tests may
+        override this with the "token" test parameter.
+        """
+        token = self._get_param("token", None)
+        if token is not None:
+            return token
+
+        token = "9243959234"
+        if self._alt_issuer:
+            token += "-alt"
+
+        return token
+
+    def _send_json(self, js: JsonObject) -> None:
+        """
+        Sends the provided JSON dict as an application/json response.
+        self._response_code can be modified to send JSON error responses.
+        """
+        resp = json.dumps(js).encode("ascii")
+        self.log_message("sending JSON response: %s", resp)
+
+        self.send_response(self._response_code)
+        self.send_header("Content-Type", self._content_type)
+        self.send_header("Content-Length", str(len(resp)))
+        self.end_headers()
+
+        self.wfile.write(resp)
+
+    def config(self) -> JsonObject:
+        port = self.server.socket.getsockname()[1]
+
+        issuer = f"http://localhost:{port}"
+        if self._alt_issuer:
+            issuer += "/alternate"
+        elif self._parameterized:
+            issuer += "/param"
+
+        return {
+            "issuer": issuer,
+            "token_endpoint": issuer + "/token",
+            "device_authorization_endpoint": issuer + "/authorize",
+            "response_types_supported": ["token"],
+            "subject_types_supported": ["public"],
+            "id_token_signing_alg_values_supported": ["RS256"],
+            "grant_types_supported": [
+                "authorization_code",
+                "urn:ietf:params:oauth:grant-type:device_code",
+            ],
+        }
+
+    @property
+    def _token_state(self):
+        """
+        A cached _TokenState object for the connected client (as determined by
+        the request's client_id), or a new one if it doesn't already exist.
+
+        This relies on the existence of a defaultdict attached to the server;
+        see main() below.
+        """
+        return self.server.token_state[self.client_id]
+
+    def _remove_token_state(self):
+        """
+        Removes any cached _TokenState for the current client_id. Call this
+        after the token exchange ends to get rid of unnecessary state.
+        """
+        if self.client_id in self.server.token_state:
+            del self.server.token_state[self.client_id]
+
+    def authorization(self) -> JsonObject:
+        uri = "https://example.com/"
+        if self._alt_issuer:
+            uri = "https://example.org/"
+
+        resp = {
+            "device_code": "postgres",
+            "user_code": "postgresuser",
+            self._uri_spelling: uri,
+            "expires_in": 5,
+            **self._response_padding,
+        }
+
+        interval = self._interval
+        if interval is not None:
+            resp["interval"] = interval
+            self._token_state.min_delay = interval
+        else:
+            self._token_state.min_delay = 5  # default
+
+        # Check the scope.
+        if "scope" in self._params:
+            assert self._params["scope"][0], "empty scopes should be omitted"
+
+        return resp
+
+    def token(self) -> JsonObject:
+        if err := self._get_param("error_code", None):
+            self._response_code = self._get_param("error_status", 400)
+
+            resp = {"error": err}
+            if desc := self._get_param("error_desc", ""):
+                resp["error_description"] = desc
+
+            return resp
+
+        if self._should_modify() and "retries" in self._test_params:
+            retries = self._test_params["retries"]
+
+            # Check to make sure the token interval is being respected.
+            now = time.monotonic()
+            if self._token_state.last_try is not None:
+                delay = now - self._token_state.last_try
+                assert (
+                    delay > self._token_state.min_delay
+                ), f"client waited only {delay} seconds between token requests (expected {self._token_state.min_delay})"
+
+            self._token_state.last_try = now
+
+            # If we haven't reached the required number of retries yet, return a
+            # "pending" response.
+            if self._token_state.retries < retries:
+                self._token_state.retries += 1
+
+                self._response_code = 400
+                return {"error": self._retry_code}
+
+        # Clean up any retry tracking state now that the exchange is ending.
+        self._remove_token_state()
+
+        return {
+            "access_token": self._access_token,
+            "token_type": "bearer",
+            **self._response_padding,
+        }
+
+
+def main():
+    """
+    Starts the authorization server on localhost. The ephemeral port in use will
+    be printed to stdout.
+    """
+
+    s = http.server.HTTPServer(("127.0.0.1", 0), OAuthHandler)
+
+    # Attach a "cache" dictionary to the server to allow the OAuthHandlers to
+    # track state across token requests. The use of defaultdict ensures that new
+    # entries will be created automatically.
+    class _TokenState:
+        retries = 0
+        min_delay = None
+        last_try = None
+
+    s.token_state = defaultdict(_TokenState)
+
+    # Give the parent the port number to contact (this is also the signal that
+    # we're ready to receive requests).
+    port = s.socket.getsockname()[1]
+    print(port)
+
+    # stdout is closed to allow the parent to just "read to the end".
+    stdout = sys.stdout.fileno()
+    sys.stdout.close()
+    os.close(stdout)
+
+    s.serve_forever()  # we expect our parent to send a termination signal
+
+
+if __name__ == "__main__":
+    main()
diff --git a/src/test/modules/oauth_validator/validator.c b/src/test/modules/oauth_validator/validator.c
new file mode 100644
index 00000000000..b2e5d182e1b
--- /dev/null
+++ b/src/test/modules/oauth_validator/validator.c
@@ -0,0 +1,143 @@
+/*-------------------------------------------------------------------------
+ *
+ * validator.c
+ *	  Test module for serverside OAuth token validation callbacks
+ *
+ * Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/test/modules/oauth_validator/validator.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "fmgr.h"
+#include "libpq/oauth.h"
+#include "miscadmin.h"
+#include "utils/guc.h"
+#include "utils/memutils.h"
+
+PG_MODULE_MAGIC;
+
+static void validator_startup(ValidatorModuleState *state);
+static void validator_shutdown(ValidatorModuleState *state);
+static bool validate_token(const ValidatorModuleState *state,
+						   const char *token,
+						   const char *role,
+						   ValidatorModuleResult *result);
+
+/* Callback implementations (exercise all three) */
+static const OAuthValidatorCallbacks validator_callbacks = {
+	PG_OAUTH_VALIDATOR_MAGIC,
+
+	.startup_cb = validator_startup,
+	.shutdown_cb = validator_shutdown,
+	.validate_cb = validate_token
+};
+
+/* GUCs */
+static char *authn_id = NULL;
+static bool authorize_tokens = true;
+
+/*---
+ * Extension entry point. Sets up GUCs for use by tests:
+ *
+ * - oauth_validator.authn_id	Sets the user identifier to return during token
+ *								validation. Defaults to the username in the
+ *								startup packet.
+ *
+ * - oauth_validator.authorize_tokens
+ *								Sets whether to successfully validate incoming
+ *								tokens. Defaults to true.
+ */
+void
+_PG_init(void)
+{
+	DefineCustomStringVariable("oauth_validator.authn_id",
+							   "Authenticated identity to use for future connections",
+							   NULL,
+							   &authn_id,
+							   NULL,
+							   PGC_SIGHUP,
+							   0,
+							   NULL, NULL, NULL);
+	DefineCustomBoolVariable("oauth_validator.authorize_tokens",
+							 "Should tokens be marked valid?",
+							 NULL,
+							 &authorize_tokens,
+							 true,
+							 PGC_SIGHUP,
+							 0,
+							 NULL, NULL, NULL);
+
+	MarkGUCPrefixReserved("oauth_validator");
+}
+
+/*
+ * Validator module entry point.
+ */
+const OAuthValidatorCallbacks *
+_PG_oauth_validator_module_init(void)
+{
+	return &validator_callbacks;
+}
+
+#define PRIVATE_COOKIE ((void *) 13579)
+
+/*
+ * Startup callback, to set up private data for the validator.
+ */
+static void
+validator_startup(ValidatorModuleState *state)
+{
+	/*
+	 * Make sure the server is correctly setting sversion. (Real modules
+	 * should not do this; it would defeat upgrade compatibility.)
+	 */
+	if (state->sversion != PG_VERSION_NUM)
+		elog(ERROR, "oauth_validator: sversion set to %d", state->sversion);
+
+	state->private_data = PRIVATE_COOKIE;
+}
+
+/*
+ * Shutdown callback, to tear down the validator.
+ */
+static void
+validator_shutdown(ValidatorModuleState *state)
+{
+	/* Check to make sure our private state still exists. */
+	if (state->private_data != PRIVATE_COOKIE)
+		elog(PANIC, "oauth_validator: private state cookie changed to %p in shutdown",
+			 state->private_data);
+}
+
+/*
+ * Validator implementation. Logs the incoming data and authorizes the token by
+ * default; the behavior can be modified via the module's GUC settings.
+ */
+static bool
+validate_token(const ValidatorModuleState *state,
+			   const char *token, const char *role,
+			   ValidatorModuleResult *res)
+{
+	/* Check to make sure our private state still exists. */
+	if (state->private_data != PRIVATE_COOKIE)
+		elog(ERROR, "oauth_validator: private state cookie changed to %p in validate",
+			 state->private_data);
+
+	elog(LOG, "oauth_validator: token=\"%s\", role=\"%s\"", token, role);
+	elog(LOG, "oauth_validator: issuer=\"%s\", scope=\"%s\"",
+		 MyProcPort->hba->oauth_issuer,
+		 MyProcPort->hba->oauth_scope);
+
+	res->authorized = authorize_tokens;
+	if (authn_id)
+		res->authn_id = pstrdup(authn_id);
+	else
+		res->authn_id = pstrdup(role);
+
+	return true;
+}
diff --git a/src/test/perl/PostgreSQL/Test/Cluster.pm b/src/test/perl/PostgreSQL/Test/Cluster.pm
index f521ad0b12f..f31af70edb6 100644
--- a/src/test/perl/PostgreSQL/Test/Cluster.pm
+++ b/src/test/perl/PostgreSQL/Test/Cluster.pm
@@ -2515,6 +2515,11 @@ instead of the default.
 
 If this regular expression is set, matches it with the output generated.
 
+=item expected_stderr => B<value>
+
+If this regular expression is set, matches it against the standard error
+stream; otherwise stderr must be empty.
+
 =item log_like => [ qr/required message/ ]
 
 =item log_unlike => [ qr/prohibited message/ ]
@@ -2558,7 +2563,22 @@ sub connect_ok
 		like($stdout, $params{expected_stdout}, "$test_name: stdout matches");
 	}
 
-	is($stderr, "", "$test_name: no stderr");
+	if (defined($params{expected_stderr}))
+	{
+		if (like(
+				$stderr, $params{expected_stderr},
+				"$test_name: stderr matches")
+			&& ($ret != 0))
+		{
+			# In this case (failing test but matching stderr) we'll have
+			# swallowed the output needed to debug. Put it back into the logs.
+			diag("$test_name: full stderr:\n" . $stderr);
+		}
+	}
+	else
+	{
+		is($stderr, "", "$test_name: no stderr");
+	}
 
 	$self->log_check($test_name, $log_location, %params);
 }
diff --git a/src/tools/pgindent/pgindent b/src/tools/pgindent/pgindent
index d8acce7e929..7dccf4614aa 100755
--- a/src/tools/pgindent/pgindent
+++ b/src/tools/pgindent/pgindent
@@ -242,6 +242,14 @@ sub pre_indent
 	# Protect wrapping in CATALOG()
 	$source =~ s!^(CATALOG\(.*)$!/*$1*/!gm;
 
+	# Treat a CURL_IGNORE_DEPRECATION() as braces for the purposes of
+	# indentation. (The recursive regex comes from the perlre documentation; it
+	# matches balanced parentheses as group $1 and the contents as group $2.)
+	my $curlopen = '{ /* CURL_IGNORE_DEPRECATION */';
+	my $curlclose = '} /* CURL_IGNORE_DEPRECATION */';
+	$source =~
+	  s!^[ \t]+CURL_IGNORE_DEPRECATION(\(((?:(?>[^()]+)|(?1))*)\))!$curlopen$2$curlclose!gms;
+
 	return $source;
 }
 
@@ -256,6 +264,12 @@ sub post_indent
 	$source =~ s!^/\* Open extern "C" \*/$!{!gm;
 	$source =~ s!^/\* Close extern "C" \*/$!}!gm;
 
+	# Restore the CURL_IGNORE_DEPRECATION() macro, keeping in mind that our
+	# markers may have been re-indented.
+	$source =~
+	  s!{[ \t]+/\* CURL_IGNORE_DEPRECATION \*/!CURL_IGNORE_DEPRECATION(!gm;
+	$source =~ s!}[ \t]+/\* CURL_IGNORE_DEPRECATION \*/!)!gm;
+
 	## Comments
 
 	# Undo change of dash-protected block comments
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 64c6bf7a891..befdcaf2425 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -371,6 +371,9 @@ CState
 CTECycleClause
 CTEMaterialize
 CTESearchClause
+CURL
+CURLM
+CURLoption
 CV
 CachedExpression
 CachedPlan
@@ -1724,6 +1727,7 @@ NumericDigit
 NumericSortSupport
 NumericSumAccum
 NumericVar
+OAuthValidatorCallbacks
 OM_uint32
 OP
 OSAPerGroupState
@@ -1833,6 +1837,7 @@ PGVerbosity
 PG_Locale_Strategy
 PG_Lock_Status
 PG_init_t
+PGauthData
 PGcancel
 PGcancelConn
 PGcmdQueueEntry
@@ -1840,7 +1845,9 @@ PGconn
 PGdataValue
 PGlobjfuncs
 PGnotify
+PGoauthBearerRequest
 PGpipelineStatus
+PGpromptOAuthDevice
 PGresAttDesc
 PGresAttValue
 PGresParamDesc
@@ -1953,6 +1960,7 @@ PQArgBlock
 PQEnvironmentOption
 PQExpBuffer
 PQExpBufferData
+PQauthDataHook_type
 PQcommMethods
 PQconninfoOption
 PQnoticeProcessor
@@ -3094,6 +3102,8 @@ VacuumRelation
 VacuumStmt
 ValidIOData
 ValidateIndexState
+ValidatorModuleState
+ValidatorModuleResult
 ValuesScan
 ValuesScanState
 Var
@@ -3491,6 +3501,7 @@ explain_get_index_name_hook_type
 f_smgr
 fasthash_state
 fd_set
+fe_oauth_state
 fe_scram_state
 fe_scram_state_enum
 fetch_range_request
-- 
2.39.3 (Apple Git-146)



^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-02-19 19:54  Jacob Champion <[email protected]>
  parent: Daniel Gustafsson <[email protected]>
  1 sibling, 0 replies; 101+ messages in thread

From: Jacob Champion @ 2025-02-19 19:54 UTC (permalink / raw)
  To: Daniel Gustafsson <[email protected]>; +Cc: Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

On Wed, Feb 19, 2025 at 6:13 AM Daniel Gustafsson <[email protected]> wrote:
> The attached rebased has your 0002 fix as well as some minor tweaks like a few
> small whitespace changes from a pgperltidy run and a copyright date fix which
> still said 2024.

LGTM.

Thanks!
--Jacob






^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-02-20 16:29  Daniel Gustafsson <[email protected]>
  parent: Daniel Gustafsson <[email protected]>
  1 sibling, 4 replies; 101+ messages in thread

From: Daniel Gustafsson @ 2025-02-20 16:29 UTC (permalink / raw)
  To: Jacob Champion <[email protected]>; +Cc: Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

> On 19 Feb 2025, at 15:13, Daniel Gustafsson <[email protected]> wrote:

> Unless something shows up I plan to commit this sometime tomorrow to allow it
> ample time in the tree before the freeze.

I spent a few more hours staring at this, and ran it through a number of CI and
local builds, without anything showing up.  Pushed to master with the first set
of buildfarm animals showing green builds.

--
Daniel Gustafsson







^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-02-20 17:28  Jacob Champion <[email protected]>
  parent: Daniel Gustafsson <[email protected]>
  3 siblings, 1 reply; 101+ messages in thread

From: Jacob Champion @ 2025-02-20 17:28 UTC (permalink / raw)
  To: Daniel Gustafsson <[email protected]>; +Cc: Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

On Thu, Feb 20, 2025 at 8:30 AM Daniel Gustafsson <[email protected]> wrote:
> I spent a few more hours staring at this, and ran it through a number of CI and
> local builds, without anything showing up.  Pushed to master with the first set
> of buildfarm animals showing green builds.

Thank you!! And _huge thanks_ to everyone who's reviewed and provided
feedback. I'm going to start working with Andrew on getting the new
tests going in the buildfarm.

If you've been reading along and would like to get started with OAuth
validators and flows, but don't know where to start, please reach out.
The proof of the new APIs will be in the using, and the best time to
tell me if you hate those APIs is now :D

--Jacob






^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-02-20 20:07  Tom Lane <[email protected]>
  parent: Daniel Gustafsson <[email protected]>
  3 siblings, 1 reply; 101+ messages in thread

From: Tom Lane @ 2025-02-20 20:07 UTC (permalink / raw)
  To: Daniel Gustafsson <[email protected]>; +Cc: Jacob Champion <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

Daniel Gustafsson <[email protected]> writes:
> I spent a few more hours staring at this, and ran it through a number of CI and
> local builds, without anything showing up.  Pushed to master with the first set
> of buildfarm animals showing green builds.

After doing an in-tree "make check", I see

$ git status
On branch master
Your branch is up to date with 'origin/master'.

Untracked files:
  (use "git add <file>..." to include in what will be committed)
        src/test/modules/oauth_validator/oauth_hook_client

Looks like we're short a .gitignore entry.  (It does appear that
"make clean" cleans it up, at least.)

			regards, tom lane






^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-02-20 20:21  Jacob Champion <[email protected]>
  parent: Tom Lane <[email protected]>
  0 siblings, 1 reply; 101+ messages in thread

From: Jacob Champion @ 2025-02-20 20:21 UTC (permalink / raw)
  To: Tom Lane <[email protected]>; +Cc: Daniel Gustafsson <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

On Thu, Feb 20, 2025 at 12:07 PM Tom Lane <[email protected]> wrote:
> Looks like we're short a .gitignore entry.  (It does appear that
> "make clean" cleans it up, at least.)

So we are! Sorry about that. The attached patch gets in-tree builds
clean for me again.

Thanks,
--Jacob


Attachments:

  [application/octet-stream] fix-gitignore.patch (308B, ../../CAOYmi+khcG43fCHgQ4eOnHq8qehjX8_NRjfPPnOzQ4xxCRkBEw@mail.gmail.com/2-fix-gitignore.patch)
  download | inline diff:
diff --git a/src/test/modules/oauth_validator/.gitignore b/src/test/modules/oauth_validator/.gitignore
index 5dcb3ff9723..8f18bcd2e1c 100644
--- a/src/test/modules/oauth_validator/.gitignore
+++ b/src/test/modules/oauth_validator/.gitignore
@@ -2,3 +2,4 @@
 /log/
 /results/
 /tmp_check/
+/oauth_hook_client


^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-02-20 20:37  Daniel Gustafsson <[email protected]>
  parent: Jacob Champion <[email protected]>
  0 siblings, 0 replies; 101+ messages in thread

From: Daniel Gustafsson @ 2025-02-20 20:37 UTC (permalink / raw)
  To: Jacob Champion <[email protected]>; Tom Lane <[email protected]>; +Cc: Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

> On 20 Feb 2025, at 21:21, Jacob Champion <[email protected]> wrote:
> 
> On Thu, Feb 20, 2025 at 12:07 PM Tom Lane <[email protected]> wrote:
>> Looks like we're short a .gitignore entry.  (It does appear that
>> "make clean" cleans it up, at least.)
> 
> So we are! Sorry about that. The attached patch gets in-tree builds
> clean for me again.

Fixed, thanks for the report!

--
Daniel Gustafsson







^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-02-21 17:18  Andres Freund <[email protected]>
  parent: Jacob Champion <[email protected]>
  0 siblings, 1 reply; 101+ messages in thread

From: Andres Freund @ 2025-02-21 17:18 UTC (permalink / raw)
  To: Jacob Champion <[email protected]>; +Cc: Daniel Gustafsson <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

Hi,

On 2025-02-20 09:28:36 -0800, Jacob Champion wrote:
> On Thu, Feb 20, 2025 at 8:30 AM Daniel Gustafsson <[email protected]> wrote:
> > I spent a few more hours staring at this, and ran it through a number of CI and
> > local builds, without anything showing up.  Pushed to master with the first set
> > of buildfarm animals showing green builds.
>
> Thank you!! And _huge thanks_ to everyone who's reviewed and provided
> feedback. I'm going to start working with Andrew on getting the new
> tests going in the buildfarm.

+1


One question about interruptability. The docs say:

+    <varlistentry>
+     <term>Interruptibility</term>
+     <listitem>
+      <para>
+       Modules must remain interruptible by signals so that the server can
+       correctly handle authentication timeouts and shutdown signals from
+       <application>pg_ctl</application>. For example, a module receiving
+       <symbol>EINTR</symbol>/<symbol>EAGAIN</symbol> from a blocking call
+       should call <function>CHECK_FOR_INTERRUPTS()</function> before retrying.
+       The same should be done during any long-running loops. Failure to follow
+       this guidance may result in unresponsive backend sessions.
+      </para>
+     </listitem>
+    </varlistentry>

Is EINTR checking really sufficient?

I don't think we can generally rely on all blocking system calls to be
interruptible by signals on all platforms?

And, probably worse, isn't relying on getting EINTR rather racy, due to the
chance of the signal arriving between CHECK_FOR_INTERRUPTS() and the blocking
system call?

Afaict the only real way to do safely across platforms is to never call
blocking functions, e.g. by using non-blocking sockets and waiting for
readiness using latches.



And a second one about supporting !CURL_VERSION_ASYNCHDNS:

Is it a good idea to support that? We e.g. rely on libpq connections made by
the backend to be interruptible. Admittedly that's, I think, already not
bulletproof, due to libpq's DNS lookups going through libc if connection
string contains a name that needs to be looked up, but this seems to make that
a bit worse? With the connection string the DNS lookups can at least be
avoided, not that I think we properly document that...

Greetings,

Andres






^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-02-23 16:49  Tom Lane <[email protected]>
  parent: Daniel Gustafsson <[email protected]>
  3 siblings, 2 replies; 101+ messages in thread

From: Tom Lane @ 2025-02-23 16:49 UTC (permalink / raw)
  To: Daniel Gustafsson <[email protected]>; +Cc: Jacob Champion <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

Daniel Gustafsson <[email protected]> writes:
> I spent a few more hours staring at this, and ran it through a number of CI and
> local builds, without anything showing up.  Pushed to master with the first set
> of buildfarm animals showing green builds.

Coverity has a nit-pick about this:

/srv/coverity/git/pgsql-git/postgresql/src/interfaces/libpq/fe-auth-oauth.c: 784 in setup_token_request()
778     		if (!request_copy)
779     		{
780     			libpq_append_conn_error(conn, "out of memory");
781     			goto fail;
782     		}
783     
>>>     CID 1643156:  High impact quality  (WRITE_CONST_FIELD)
>>>     A write to an aggregate overwrites a const-qualified field within the aggregate.
784     		memcpy(request_copy, &request, sizeof(request));
785     
786     		conn->async_auth = run_user_oauth_flow;
787     		conn->cleanup_async_auth = cleanup_user_oauth_flow;
788     		state->async_ctx = request_copy;
789     	}

This is evidently because of the fields declared const:

	/* Hook inputs (constant across all calls) */
	const char *const openid_configuration; /* OIDC discovery URI */
	const char *const scope;	/* required scope(s), or NULL */

IMO, the set of cases where it's legitimate to mark individual struct
fields as const is negligibly small, and this doesn't seem to be one
of them.  It's not obvious to me where/how PGoauthBearerRequest
structs are supposed to be constructed, but I find it hard to believe
that they will all spring full-grown from the forehead of Zeus.
Nonetheless, this declaration requires exactly that.

(I'm kind of surprised that we're not getting similar bleats from
any buildfarm animals, but so far I don't see any.)

BTW, as another nitpicky style matter: why do PGoauthBearerRequest
etc. spell their struct tag names differently from their typedef names
(that is, with/without an underscore)?  That is not our project style
anywhere else, and I'm failing to detect a good reason to do it here.

			regards, tom lane






^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-02-23 17:08  Daniel Gustafsson <[email protected]>
  parent: Tom Lane <[email protected]>
  1 sibling, 1 reply; 101+ messages in thread

From: Daniel Gustafsson @ 2025-02-23 17:08 UTC (permalink / raw)
  To: Tom Lane <[email protected]>; +Cc: Jacob Champion <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

> On 23 Feb 2025, at 17:49, Tom Lane <[email protected]> wrote:
> 
> Daniel Gustafsson <[email protected]> writes:
>> I spent a few more hours staring at this, and ran it through a number of CI and
>> local builds, without anything showing up.  Pushed to master with the first set
>> of buildfarm animals showing green builds.
> 
> Coverity has a nit-pick about this:
> 
> /srv/coverity/git/pgsql-git/postgresql/src/interfaces/libpq/fe-auth-oauth.c: 784 in setup_token_request()
> 778      if (!request_copy)
> 779      {
> 780      libpq_append_conn_error(conn, "out of memory");
> 781      goto fail;
> 782      }
> 783     
>>>>    CID 1643156:  High impact quality  (WRITE_CONST_FIELD)
>>>>    A write to an aggregate overwrites a const-qualified field within the aggregate.
> 784      memcpy(request_copy, &request, sizeof(request));
> 785     
> 786      conn->async_auth = run_user_oauth_flow;
> 787      conn->cleanup_async_auth = cleanup_user_oauth_flow;
> 788      state->async_ctx = request_copy;
> 789      }
> 
> This is evidently because of the fields declared const:
> 
> /* Hook inputs (constant across all calls) */
> const char *const openid_configuration; /* OIDC discovery URI */
> const char *const scope; /* required scope(s), or NULL */
> 
> IMO, the set of cases where it's legitimate to mark individual struct
> fields as const is negligibly small, and this doesn't seem to be one
> of them.

Thanks for the report, will fix.

> BTW, as another nitpicky style matter: why do PGoauthBearerRequest
> etc. spell their struct tag names differently from their typedef names
> (that is, with/without an underscore)?  That is not our project style
> anywhere else, and I'm failing to detect a good reason to do it here.

Indeed it isn't, the only explanation is that I missed it. Will fix.

--
Daniel Gustafsson







^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-02-23 23:45  Daniel Gustafsson <[email protected]>
  parent: Daniel Gustafsson <[email protected]>
  0 siblings, 1 reply; 101+ messages in thread

From: Daniel Gustafsson @ 2025-02-23 23:45 UTC (permalink / raw)
  To: Tom Lane <[email protected]>; +Cc: Jacob Champion <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

>> IMO, the set of cases where it's legitimate to mark individual struct
>> fields as const is negligibly small, and this doesn't seem to be one
>> of them.
> 
> Thanks for the report, will fix.
> 
>> BTW, as another nitpicky style matter: why do PGoauthBearerRequest
>> etc. spell their struct tag names differently from their typedef names
>> (that is, with/without an underscore)?  That is not our project style
>> anywhere else, and I'm failing to detect a good reason to do it here.
> 
> Indeed it isn't, the only explanation is that I missed it. Will fix.

The attached diff passes CI and works for me, will revisit in the morning.

--
Daniel Gustafsson



Attachments:

  [application/octet-stream] structfixups.diff (2.8K, ../../[email protected]/2-structfixups.diff)
  download | inline diff:
diff --git a/doc/src/sgml/libpq.sgml b/doc/src/sgml/libpq.sgml
index ddb3596df83..8fa0515c6a0 100644
--- a/doc/src/sgml/libpq.sgml
+++ b/doc/src/sgml/libpq.sgml
@@ -10318,21 +10318,21 @@ typedef struct _PGpromptOAuthDevice
          of <symbol>PGoauthBearerRequest</symbol>, which should be filled in
          by the implementation:
 <synopsis>
-typedef struct _PGoauthBearerRequest
+typedef struct PGoauthBearerRequest
 {
     /* Hook inputs (constant across all calls) */
-    const char *const openid_configuration; /* OIDC discovery URL */
-    const char *const scope;                /* required scope(s), or NULL */
+    const char *openid_configuration; /* OIDC discovery URL */
+    const char *scope;                /* required scope(s), or NULL */
 
     /* Hook outputs */
 
     /* Callback implementing a custom asynchronous OAuth flow. */
     PostgresPollingStatusType (*async) (PGconn *conn,
-                                        struct _PGoauthBearerRequest *request,
+                                        struct PGoauthBearerRequest *request,
                                         SOCKTYPE *altsock);
 
     /* Callback to clean up custom allocations. */
-    void        (*cleanup) (PGconn *conn, struct _PGoauthBearerRequest *request);
+    void        (*cleanup) (PGconn *conn, struct PGoauthBearerRequest *request);
 
     char       *token;   /* acquired Bearer token */
     void       *user;    /* hook-defined allocated data */
diff --git a/src/interfaces/libpq/libpq-fe.h b/src/interfaces/libpq/libpq-fe.h
index b7399dee58e..34ddfdb1831 100644
--- a/src/interfaces/libpq/libpq-fe.h
+++ b/src/interfaces/libpq/libpq-fe.h
@@ -745,11 +745,11 @@ typedef struct _PGpromptOAuthDevice
 #define SOCKTYPE int
 #endif
 
-typedef struct _PGoauthBearerRequest
+typedef struct PGoauthBearerRequest
 {
 	/* Hook inputs (constant across all calls) */
-	const char *const openid_configuration; /* OIDC discovery URI */
-	const char *const scope;	/* required scope(s), or NULL */
+	const char *openid_configuration;	/* OIDC discovery URI */
+	const char *scope;			/* required scope(s), or NULL */
 
 	/* Hook outputs */
 
@@ -770,7 +770,7 @@ typedef struct _PGoauthBearerRequest
 	 * request->token must be set by the hook.
 	 */
 	PostgresPollingStatusType (*async) (PGconn *conn,
-										struct _PGoauthBearerRequest *request,
+										struct PGoauthBearerRequest *request,
 										SOCKTYPE * altsock);
 
 	/*
@@ -780,7 +780,7 @@ typedef struct _PGoauthBearerRequest
 	 * This is technically optional, but highly recommended, because there is
 	 * no other indication as to when it is safe to free the token.
 	 */
-	void		(*cleanup) (PGconn *conn, struct _PGoauthBearerRequest *request);
+	void		(*cleanup) (PGconn *conn, struct PGoauthBearerRequest *request);
 
 	/*
 	 * The hook should set this to the Bearer token contents for the


^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-02-24 09:00  Daniel Gustafsson <[email protected]>
  parent: Daniel Gustafsson <[email protected]>
  0 siblings, 1 reply; 101+ messages in thread

From: Daniel Gustafsson @ 2025-02-24 09:00 UTC (permalink / raw)
  To: Tom Lane <[email protected]>; Dave Cramer <[email protected]>; +Cc: Jacob Champion <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

> On 24 Feb 2025, at 00:45, Daniel Gustafsson <[email protected]> wrote:

> The attached diff passes CI and works for me, will revisit in the morning.

Dave reported (on Discord) that the OPTIONAL macro collided with windef.h, so
attached is a small fix for that as well (even though we don't support Windows
here right now there is little point in clashing since we don't need that
particular macro name so rename anyways).

--
Daniel Gustafsson



Attachments:

  [application/octet-stream] 0002-oauth-Rename-macro-to-avoid-collisions-on-Windows.patch (5.5K, ../../[email protected]/2-0002-oauth-Rename-macro-to-avoid-collisions-on-Windows.patch)
  download | inline diff:
From 9f13bd3e416263d7cc444245dc5f858478cc0562 Mon Sep 17 00:00:00 2001
From: Daniel Gustafsson <[email protected]>
Date: Mon, 24 Feb 2025 09:49:18 +0100
Subject: [PATCH 2/2] oauth: Rename macro to avoid collisions on Windows

Our json parsing defined the macros OPTIONAL and REQUIRED to decorate the
structs with for increased readability. This however collides with macros
in the <windef.h> header on Windows.

../src/interfaces/libpq/fe-auth-oauth-curl.c:398:9: warning: "OPTIONAL" redefined
  398 | #define OPTIONAL false
      |         ^~~~~~~~
In file included from D:/a/_temp/msys64/ucrt64/include/windef.h:9,
                 from D:/a/_temp/msys64/ucrt64/include/windows.h:69,
                 from D:/a/_temp/msys64/ucrt64/include/winsock2.h:23,
                 from ../src/include/port/win32_port.h:60,
                 from ../src/include/port.h:24,
                 from ../src/include/c.h:1331,
                 from ../src/include/postgres_fe.h:28,
                 from ../src/interfaces/libpq/fe-auth-oauth-curl.c:16:
include/minwindef.h:65:9: note: this is the location of the previous definition
   65 | #define OPTIONAL
      |         ^~~~~~~~

Rename to avoid compilation errors in anticipation of implementing
support for Windows.

Reported-by: Dave Cramer (on PostgreSQL Hacking Discord)
---
 src/interfaces/libpq/fe-auth-oauth-curl.c | 34 +++++++++++------------
 1 file changed, 17 insertions(+), 17 deletions(-)

diff --git a/src/interfaces/libpq/fe-auth-oauth-curl.c b/src/interfaces/libpq/fe-auth-oauth-curl.c
index a80e2047bb7..ae339579f88 100644
--- a/src/interfaces/libpq/fe-auth-oauth-curl.c
+++ b/src/interfaces/libpq/fe-auth-oauth-curl.c
@@ -394,8 +394,8 @@ struct json_field
 };
 
 /* Documentation macros for json_field.required. */
-#define REQUIRED true
-#define OPTIONAL false
+#define PG_OAUTH_REQUIRED true
+#define PG_OAUTH_OPTIONAL false
 
 /* Parse state for parse_oauth_json(). */
 struct oauth_parse
@@ -844,8 +844,8 @@ static bool
 parse_provider(struct async_ctx *actx, struct provider *provider)
 {
 	struct json_field fields[] = {
-		{"issuer", JSON_TOKEN_STRING, {&provider->issuer}, REQUIRED},
-		{"token_endpoint", JSON_TOKEN_STRING, {&provider->token_endpoint}, REQUIRED},
+		{"issuer", JSON_TOKEN_STRING, {&provider->issuer}, PG_OAUTH_REQUIRED},
+		{"token_endpoint", JSON_TOKEN_STRING, {&provider->token_endpoint}, PG_OAUTH_REQUIRED},
 
 		/*----
 		 * The following fields are technically REQUIRED, but we don't use
@@ -857,8 +857,8 @@ parse_provider(struct async_ctx *actx, struct provider *provider)
 		 * - id_token_signing_alg_values_supported
 		 */
 
-		{"device_authorization_endpoint", JSON_TOKEN_STRING, {&provider->device_authorization_endpoint}, OPTIONAL},
-		{"grant_types_supported", JSON_TOKEN_ARRAY_START, {.array = &provider->grant_types_supported}, OPTIONAL},
+		{"device_authorization_endpoint", JSON_TOKEN_STRING, {&provider->device_authorization_endpoint}, PG_OAUTH_OPTIONAL},
+		{"grant_types_supported", JSON_TOKEN_ARRAY_START, {.array = &provider->grant_types_supported}, PG_OAUTH_OPTIONAL},
 
 		{0},
 	};
@@ -955,24 +955,24 @@ static bool
 parse_device_authz(struct async_ctx *actx, struct device_authz *authz)
 {
 	struct json_field fields[] = {
-		{"device_code", JSON_TOKEN_STRING, {&authz->device_code}, REQUIRED},
-		{"user_code", JSON_TOKEN_STRING, {&authz->user_code}, REQUIRED},
-		{"verification_uri", JSON_TOKEN_STRING, {&authz->verification_uri}, REQUIRED},
-		{"expires_in", JSON_TOKEN_NUMBER, {&authz->expires_in_str}, REQUIRED},
+		{"device_code", JSON_TOKEN_STRING, {&authz->device_code}, PG_OAUTH_REQUIRED},
+		{"user_code", JSON_TOKEN_STRING, {&authz->user_code}, PG_OAUTH_REQUIRED},
+		{"verification_uri", JSON_TOKEN_STRING, {&authz->verification_uri}, PG_OAUTH_REQUIRED},
+		{"expires_in", JSON_TOKEN_NUMBER, {&authz->expires_in_str}, PG_OAUTH_REQUIRED},
 
 		/*
 		 * Some services (Google, Azure) spell verification_uri differently.
 		 * We accept either.
 		 */
-		{"verification_url", JSON_TOKEN_STRING, {&authz->verification_uri}, REQUIRED},
+		{"verification_url", JSON_TOKEN_STRING, {&authz->verification_uri}, PG_OAUTH_REQUIRED},
 
 		/*
 		 * There is no evidence of verification_uri_complete being spelled
 		 * with "url" instead with any service provider, so only support
 		 * "uri".
 		 */
-		{"verification_uri_complete", JSON_TOKEN_STRING, {&authz->verification_uri_complete}, OPTIONAL},
-		{"interval", JSON_TOKEN_NUMBER, {&authz->interval_str}, OPTIONAL},
+		{"verification_uri_complete", JSON_TOKEN_STRING, {&authz->verification_uri_complete}, PG_OAUTH_OPTIONAL},
+		{"interval", JSON_TOKEN_NUMBER, {&authz->interval_str}, PG_OAUTH_OPTIONAL},
 
 		{0},
 	};
@@ -1010,9 +1010,9 @@ parse_token_error(struct async_ctx *actx, struct token_error *err)
 {
 	bool		result;
 	struct json_field fields[] = {
-		{"error", JSON_TOKEN_STRING, {&err->error}, REQUIRED},
+		{"error", JSON_TOKEN_STRING, {&err->error}, PG_OAUTH_REQUIRED},
 
-		{"error_description", JSON_TOKEN_STRING, {&err->error_description}, OPTIONAL},
+		{"error_description", JSON_TOKEN_STRING, {&err->error_description}, PG_OAUTH_OPTIONAL},
 
 		{0},
 	};
@@ -1069,8 +1069,8 @@ static bool
 parse_access_token(struct async_ctx *actx, struct token *tok)
 {
 	struct json_field fields[] = {
-		{"access_token", JSON_TOKEN_STRING, {&tok->access_token}, REQUIRED},
-		{"token_type", JSON_TOKEN_STRING, {&tok->token_type}, REQUIRED},
+		{"access_token", JSON_TOKEN_STRING, {&tok->access_token}, PG_OAUTH_REQUIRED},
+		{"token_type", JSON_TOKEN_STRING, {&tok->token_type}, PG_OAUTH_REQUIRED},
 
 		/*---
 		 * We currently have no use for the following OPTIONAL fields:
-- 
2.39.3 (Apple Git-146)



  [application/octet-stream] 0001-oauth-Fix-incorrect-const-markers-in-struct.patch (3.5K, ../../[email protected]/3-0001-oauth-Fix-incorrect-const-markers-in-struct.patch)
  download | inline diff:
From 577e8918c67dc66c0ec1686f92c85eb63c045bcc Mon Sep 17 00:00:00 2001
From: Daniel Gustafsson <[email protected]>
Date: Mon, 24 Feb 2025 09:46:53 +0100
Subject: [PATCH 1/2] oauth: Fix incorrect const markers in struct

Two members in PGoauthBearerRequest were incorrectly marked as const.
While in there, align the name of the struct with the typedef as per
project style.

Reported-by: Tom Lane <[email protected]>
Discussion: https://postgr.es/m/[email protected]
---
 doc/src/sgml/libpq.sgml         | 10 +++++-----
 src/interfaces/libpq/libpq-fe.h | 10 +++++-----
 2 files changed, 10 insertions(+), 10 deletions(-)

diff --git a/doc/src/sgml/libpq.sgml b/doc/src/sgml/libpq.sgml
index ddb3596df83..8fa0515c6a0 100644
--- a/doc/src/sgml/libpq.sgml
+++ b/doc/src/sgml/libpq.sgml
@@ -10318,21 +10318,21 @@ typedef struct _PGpromptOAuthDevice
          of <symbol>PGoauthBearerRequest</symbol>, which should be filled in
          by the implementation:
 <synopsis>
-typedef struct _PGoauthBearerRequest
+typedef struct PGoauthBearerRequest
 {
     /* Hook inputs (constant across all calls) */
-    const char *const openid_configuration; /* OIDC discovery URL */
-    const char *const scope;                /* required scope(s), or NULL */
+    const char *openid_configuration; /* OIDC discovery URL */
+    const char *scope;                /* required scope(s), or NULL */
 
     /* Hook outputs */
 
     /* Callback implementing a custom asynchronous OAuth flow. */
     PostgresPollingStatusType (*async) (PGconn *conn,
-                                        struct _PGoauthBearerRequest *request,
+                                        struct PGoauthBearerRequest *request,
                                         SOCKTYPE *altsock);
 
     /* Callback to clean up custom allocations. */
-    void        (*cleanup) (PGconn *conn, struct _PGoauthBearerRequest *request);
+    void        (*cleanup) (PGconn *conn, struct PGoauthBearerRequest *request);
 
     char       *token;   /* acquired Bearer token */
     void       *user;    /* hook-defined allocated data */
diff --git a/src/interfaces/libpq/libpq-fe.h b/src/interfaces/libpq/libpq-fe.h
index b7399dee58e..34ddfdb1831 100644
--- a/src/interfaces/libpq/libpq-fe.h
+++ b/src/interfaces/libpq/libpq-fe.h
@@ -745,11 +745,11 @@ typedef struct _PGpromptOAuthDevice
 #define SOCKTYPE int
 #endif
 
-typedef struct _PGoauthBearerRequest
+typedef struct PGoauthBearerRequest
 {
 	/* Hook inputs (constant across all calls) */
-	const char *const openid_configuration; /* OIDC discovery URI */
-	const char *const scope;	/* required scope(s), or NULL */
+	const char *openid_configuration;	/* OIDC discovery URI */
+	const char *scope;			/* required scope(s), or NULL */
 
 	/* Hook outputs */
 
@@ -770,7 +770,7 @@ typedef struct _PGoauthBearerRequest
 	 * request->token must be set by the hook.
 	 */
 	PostgresPollingStatusType (*async) (PGconn *conn,
-										struct _PGoauthBearerRequest *request,
+										struct PGoauthBearerRequest *request,
 										SOCKTYPE * altsock);
 
 	/*
@@ -780,7 +780,7 @@ typedef struct _PGoauthBearerRequest
 	 * This is technically optional, but highly recommended, because there is
 	 * no other indication as to when it is safe to free the token.
 	 */
-	void		(*cleanup) (PGconn *conn, struct _PGoauthBearerRequest *request);
+	void		(*cleanup) (PGconn *conn, struct PGoauthBearerRequest *request);
 
 	/*
 	 * The hook should set this to the Bearer token contents for the
-- 
2.39.3 (Apple Git-146)



^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-02-24 14:41  Andrew Dunstan <[email protected]>
  parent: Daniel Gustafsson <[email protected]>
  3 siblings, 1 reply; 101+ messages in thread

From: Andrew Dunstan @ 2025-02-24 14:41 UTC (permalink / raw)
  To: Daniel Gustafsson <[email protected]>; Jacob Champion <[email protected]>; +Cc: Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers


On 2025-02-20 Th 11:29 AM, Daniel Gustafsson wrote:
>> On 19 Feb 2025, at 15:13, Daniel Gustafsson<[email protected]> wrote:
>> Unless something shows up I plan to commit this sometime tomorrow to allow it
>> ample time in the tree before the freeze.
> I spent a few more hours staring at this, and ran it through a number of CI and
> local builds, without anything showing up.  Pushed to master with the first set
> of buildfarm animals showing green builds.


I notice that 001_server.pl contains this:


if ($ENV{with_python} ne 'yes')
{
     plan skip_all => 'OAuth tests require --with-python to run';
}


and various other things that insist on this. But I think all we should 
need is for Python to be present, whether or not we are building plpython.



cheers

andrew

--
Andrew Dunstan
EDB:https://www.enterprisedb.com


^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-02-24 14:48  Daniel Gustafsson <[email protected]>
  parent: Andrew Dunstan <[email protected]>
  0 siblings, 1 reply; 101+ messages in thread

From: Daniel Gustafsson @ 2025-02-24 14:48 UTC (permalink / raw)
  To: Andrew Dunstan <[email protected]>; +Cc: Jacob Champion <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

> On 24 Feb 2025, at 15:41, Andrew Dunstan <[email protected]> wrote:

> I notice that 001_server.pl contains this:
> 
> if ($ENV{with_python} ne 'yes')
> {
>     plan skip_all => 'OAuth tests require --with-python to run';
> }
> 
> and various other things that insist on this. But I think all we should need is for Python to be present, whether or not we are building plpython.

Agreed.  Right now the --with-python check is what runs PGAC_PATH_PYTHON but
maybe there is value in separating this going forward into a) have python; b)
have python and build plpython?

--
Daniel Gustafsson







^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-02-24 15:10  Andres Freund <[email protected]>
  parent: Daniel Gustafsson <[email protected]>
  0 siblings, 0 replies; 101+ messages in thread

From: Andres Freund @ 2025-02-24 15:10 UTC (permalink / raw)
  To: Daniel Gustafsson <[email protected]>; +Cc: Andrew Dunstan <[email protected]>; Jacob Champion <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

Hi,

On 2025-02-24 15:48:13 +0100, Daniel Gustafsson wrote:
> > On 24 Feb 2025, at 15:41, Andrew Dunstan <[email protected]> wrote:
> 
> > I notice that 001_server.pl contains this:
> > 
> > if ($ENV{with_python} ne 'yes')
> > {
> >     plan skip_all => 'OAuth tests require --with-python to run';
> > }
> > 
> > and various other things that insist on this. But I think all we should need is for Python to be present, whether or not we are building plpython.
> 
> Agreed.  Right now the --with-python check is what runs PGAC_PATH_PYTHON but
> maybe there is value in separating this going forward into a) have python; b)
> have python and build plpython?

FWIW, For other things we need in tests, e.g. gzip, we don't actually use a
configure test.  I think we could do the same for the python commandline
binary.

Greetings,

Andres Freund






^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-02-24 17:39  Jacob Champion <[email protected]>
  parent: Andres Freund <[email protected]>
  0 siblings, 1 reply; 101+ messages in thread

From: Jacob Champion @ 2025-02-24 17:39 UTC (permalink / raw)
  To: Andres Freund <[email protected]>; +Cc: Daniel Gustafsson <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

On Fri, Feb 21, 2025 at 9:19 AM Andres Freund <[email protected]> wrote:
> I don't think we can generally rely on all blocking system calls to be
> interruptible by signals on all platforms?

Probably not; I wasn't sure how much detail to put in here after "must
remain interruptible."

> And, probably worse, isn't relying on getting EINTR rather racy, due to the
> chance of the signal arriving between CHECK_FOR_INTERRUPTS() and the blocking
> system call?

That is worse, and to be honest I hadn't ever thought about that race
condition before your email. So wait... how do people actually rely on
EINTR in production-grade client software? pselect/ppoll exist,
clearly, but they're used so rarely IME. (Have we all just been
subconsciously trained to mash Ctrl-C until the program finally stops?
I'm honestly kind of horrified by this revelation.)

Anyway, yes, this documentation clearly needs to be strengthened.

> Is it a good idea to support that? We e.g. rely on libpq connections made by
> the backend to be interruptible. Admittedly that's, I think, already not
> bulletproof, due to libpq's DNS lookups going through libc if connection
> string contains a name that needs to be looked up, but this seems to make that
> a bit worse?

A bit. The same for Kerberos, IIRC. Is the current configure warning
not strong enough to imply that the packager is on shaky ground? (I
patterned that off of the LDAP crash warning, which seemed much worse
to me. :D)

We can always declare non-support, I suppose, but anyone who doesn't
care about that problem (say, because they just want to use
single-connection psql) is then stuck hacking around it.

--Jacob






^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-02-24 17:39  Jacob Champion <[email protected]>
  parent: Tom Lane <[email protected]>
  1 sibling, 0 replies; 101+ messages in thread

From: Jacob Champion @ 2025-02-24 17:39 UTC (permalink / raw)
  To: Tom Lane <[email protected]>; +Cc: Daniel Gustafsson <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

On Sun, Feb 23, 2025 at 8:49 AM Tom Lane <[email protected]> wrote:
> IMO, the set of cases where it's legitimate to mark individual struct
> fields as const is negligibly small, and this doesn't seem to be one
> of them.  It's not obvious to me where/how PGoauthBearerRequest
> structs are supposed to be constructed, but I find it hard to believe
> that they will all spring full-grown from the forehead of Zeus.
> Nonetheless, this declaration requires exactly that.

As read-only inputs to the client API, they're not meant to be changed
for the lifetime of the struct (and the lifetime of the client flow).
The only place to initialize such a struct is directly above this
code.

> (I'm kind of surprised that we're not getting similar bleats from
> any buildfarm animals, but so far I don't see any.)

Is there a reason for compilers to complain? memcpy's the way I know
of to put a const-member struct on the heap, but maybe there are other
ways that don't annoy Coverity?

If the cost of this warning is too high, removing the const
declarations isn't the end of the world. But we use unconstify and
other type-punning copies in so many other places that this didn't
seem all that bad for the goal of helping out the client writer.

> BTW, as another nitpicky style matter: why do PGoauthBearerRequest
> etc. spell their struct tag names differently from their typedef names
> (that is, with/without an underscore)?  That is not our project style
> anywhere else, and I'm failing to detect a good reason to do it here.

This underscore pattern was copied directly from PQconninfoOption and
PQprintOpt.

Thanks,
--Jacob






^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-02-24 17:43  Jacob Champion <[email protected]>
  parent: Daniel Gustafsson <[email protected]>
  0 siblings, 0 replies; 101+ messages in thread

From: Jacob Champion @ 2025-02-24 17:43 UTC (permalink / raw)
  To: Daniel Gustafsson <[email protected]>; +Cc: Tom Lane <[email protected]>; Dave Cramer <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

On Mon, Feb 24, 2025 at 1:00 AM Daniel Gustafsson <[email protected]> wrote:
> Dave reported (on Discord) that the OPTIONAL macro collided with windef.h,

Ugh.

> so
> attached is a small fix for that as well (even though we don't support Windows
> here right now there is little point in clashing since we don't need that
> particular macro name so rename anyways).

No objections to those patches, from inspection. (See note to Tom,
above, but I won't die on the const hill.)

Thanks!
--Jacob






^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-02-24 20:30  Andres Freund <[email protected]>
  parent: Jacob Champion <[email protected]>
  0 siblings, 1 reply; 101+ messages in thread

From: Andres Freund @ 2025-02-24 20:30 UTC (permalink / raw)
  To: Jacob Champion <[email protected]>; +Cc: Daniel Gustafsson <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

Hi,

On 2025-02-24 09:39:52 -0800, Jacob Champion wrote:
> On Fri, Feb 21, 2025 at 9:19 AM Andres Freund <[email protected]> wrote:
> > And, probably worse, isn't relying on getting EINTR rather racy, due to the
> > chance of the signal arriving between CHECK_FOR_INTERRUPTS() and the blocking
> > system call?
> 
> That is worse, and to be honest I hadn't ever thought about that race
> condition before your email. So wait... how do people actually rely on
> EINTR in production-grade client software? pselect/ppoll exist,
> clearly, but they're used so rarely IME. (Have we all just been
> subconsciously trained to mash Ctrl-C until the program finally stops?
> I'm honestly kind of horrified by this revelation.)

If you need to handle the race it you need to combine it with something
additional, e.g. the so called "self pipe trick".  Which e.g. the latch / wait
event set code does.


> > Is it a good idea to support that? We e.g. rely on libpq connections made by
> > the backend to be interruptible. Admittedly that's, I think, already not
> > bulletproof, due to libpq's DNS lookups going through libc if connection
> > string contains a name that needs to be looked up, but this seems to make that
> > a bit worse?
> 
> A bit. The same for Kerberos, IIRC. Is the current configure warning
> not strong enough to imply that the packager is on shaky ground?

I don't think it's strong enough.


> (I patterned that off of the LDAP crash warning, which seemed much worse to
> me. :D)

I don't think that's a comparable case, because there were in-production uses
of PG+ldap that (kind of) worked. Whereas we start on a green field here.

Greetings,

Andres Freund






^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-02-24 22:02  Jacob Champion <[email protected]>
  parent: Andres Freund <[email protected]>
  0 siblings, 1 reply; 101+ messages in thread

From: Jacob Champion @ 2025-02-24 22:02 UTC (permalink / raw)
  To: Andres Freund <[email protected]>; +Cc: Daniel Gustafsson <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

On Mon, Feb 24, 2025 at 12:30 PM Andres Freund <[email protected]> wrote:
> If you need to handle the race it you need to combine it with something
> additional, e.g. the so called "self pipe trick".  Which e.g. the latch / wait
> event set code does.

Right; I'm just used to that trick being deployed in massively
parallel async event engines rather than linear synchronous code
waiting on a single descriptor. I'm still a bit in disbelief, to be
honest. I'll get over it. Thank you for the note!

> > A bit. The same for Kerberos, IIRC. Is the current configure warning
> > not strong enough to imply that the packager is on shaky ground?
>
> I don't think it's strong enough.
>
> > (I patterned that off of the LDAP crash warning, which seemed much worse to
> > me. :D)
>
> I don't think that's a comparable case, because there were in-production uses
> of PG+ldap that (kind of) worked. Whereas we start on a green field here.

Fair enough. I'll work on a patch to disallow it; best case, no one
ever complains, and we've pruned an entire configuration from the list
of things to worry about.

Thanks!
--Jacob






^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-02-25 17:22  Jacob Champion <[email protected]>
  parent: Jacob Champion <[email protected]>
  0 siblings, 1 reply; 101+ messages in thread

From: Jacob Champion @ 2025-02-25 17:22 UTC (permalink / raw)
  To: Andres Freund <[email protected]>; +Cc: Daniel Gustafsson <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

On Mon, Feb 24, 2025 at 2:02 PM Jacob Champion
<[email protected]> wrote:
> Fair enough. I'll work on a patch to disallow it; best case, no one
> ever complains, and we've pruned an entire configuration from the list
> of things to worry about.

Here goes:

- 0001 fails configuration if the AsynchDNS feature is not built into libcurl.
- 0002 removes EINTR references from the validator documentation and
instead points authors towards our internal Wait APIs.
- 0003 is an optional followup to the const changes from upthread:
there's no need to memcpy() now, and anyone reading the code without
the history might wonder why I chose such a convoluted way to copy a
struct. :D

WDYT?

--Jacob


Attachments:

  [application/octet-stream] 0002-oauth-Improve-validator-docs-on-interruptibility.patch (1.9K, ../../CAOYmi+mhqahb65y1zXtv60T9=mDYTaepV9b-wq-GHey00zuGOg@mail.gmail.com/2-0002-oauth-Improve-validator-docs-on-interruptibility.patch)
  download | inline diff:
From aca624e05b9b0c46f1f6a17c66af02e71c201dab Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Tue, 25 Feb 2025 07:42:43 -0800
Subject: [PATCH 2/3] oauth: Improve validator docs on interruptibility

Andres pointed out that EINTR handling is inadequate for real-world use
cases. Direct module writers to our wait APIs instead.

Discussion: https://postgr.es/m/p4bd7mn6dxr2zdak74abocyltpfdxif4pxqzixqpxpetjwt34h%40qc6jgfmoddvq
---
 doc/src/sgml/oauth-validators.sgml | 12 +++++++-----
 1 file changed, 7 insertions(+), 5 deletions(-)

diff --git a/doc/src/sgml/oauth-validators.sgml b/doc/src/sgml/oauth-validators.sgml
index 356f11d3bd8..704089dd7b3 100644
--- a/doc/src/sgml/oauth-validators.sgml
+++ b/doc/src/sgml/oauth-validators.sgml
@@ -209,11 +209,13 @@
       <para>
        Modules must remain interruptible by signals so that the server can
        correctly handle authentication timeouts and shutdown signals from
-       <application>pg_ctl</application>. For example, a module receiving
-       <symbol>EINTR</symbol>/<symbol>EAGAIN</symbol> from a blocking call
-       should call <function>CHECK_FOR_INTERRUPTS()</function> before retrying.
-       The same should be done during any long-running loops. Failure to follow
-       this guidance may result in unresponsive backend sessions.
+       <application>pg_ctl</application>. For example, blocking calls on sockets
+       should generally be replaced with code that handles both socket events
+       and interrupts without races (see <function>WaitLatchOrSocket()</function>,
+       <function>WaitEventSetWait()</function>, et al), and long-running loops
+       should periodically call <function>CHECK_FOR_INTERRUPTS()</function>.
+       Failure to follow this guidance may result in unresponsive backend
+       sessions.
       </para>
      </listitem>
     </varlistentry>
-- 
2.34.1



  [application/octet-stream] 0001-oauth-Disallow-synchronous-DNS-in-libcurl.patch (4.5K, ../../CAOYmi+mhqahb65y1zXtv60T9=mDYTaepV9b-wq-GHey00zuGOg@mail.gmail.com/3-0001-oauth-Disallow-synchronous-DNS-in-libcurl.patch)
  download | inline diff:
From f6fb7ca5cc6a9b8e749a22516d282181a4ec5d8f Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Mon, 24 Feb 2025 15:02:01 -0800
Subject: [PATCH 1/3] oauth: Disallow synchronous DNS in libcurl

There is concern that a blocking DNS lookup in libpq could stall a
backend process (say, via FDW). Since there's currently no strong
evidence that synchronous DNS is a popular option, disallow it entirely
rather than warning at configure time. We can revisit if anyone
complains.

Per query from Andres Freund.

Discussion: https://postgr.es/m/p4bd7mn6dxr2zdak74abocyltpfdxif4pxqzixqpxpetjwt34h%40qc6jgfmoddvq
---
 config/programs.m4 | 10 +++++-----
 configure          | 14 +++++---------
 meson.build        | 18 ++++++------------
 3 files changed, 16 insertions(+), 26 deletions(-)

diff --git a/config/programs.m4 b/config/programs.m4
index 061b13376ac..0a07feb37cc 100644
--- a/config/programs.m4
+++ b/config/programs.m4
@@ -316,7 +316,7 @@ AC_DEFUN([PGAC_CHECK_LIBCURL],
               [Define to 1 if curl_global_init() is guaranteed to be thread-safe.])
   fi
 
-  # Warn if a thread-friendly DNS resolver isn't built.
+  # Fail if a thread-friendly DNS resolver isn't built.
   AC_CACHE_CHECK([for curl support for asynchronous DNS], [pgac_cv__libcurl_async_dns],
   [AC_RUN_IFELSE([AC_LANG_PROGRAM([
 #include <curl/curl.h>
@@ -332,10 +332,10 @@ AC_DEFUN([PGAC_CHECK_LIBCURL],
   [pgac_cv__libcurl_async_dns=yes],
   [pgac_cv__libcurl_async_dns=no],
   [pgac_cv__libcurl_async_dns=unknown])])
-  if test x"$pgac_cv__libcurl_async_dns" != xyes ; then
-    AC_MSG_WARN([
+  if test x"$pgac_cv__libcurl_async_dns" = xno ; then
+    AC_MSG_ERROR([
 *** The installed version of libcurl does not support asynchronous DNS
-*** lookups. Connection timeouts will not be honored during DNS resolution,
-*** which may lead to hangs in client programs.])
+*** lookups. Rebuild libcurl with the AsynchDNS feature enabled in order
+*** to use it with libpq.])
   fi
 ])# PGAC_CHECK_LIBCURL
diff --git a/configure b/configure
index 93fddd69981..559f535f5cd 100755
--- a/configure
+++ b/configure
@@ -12493,7 +12493,7 @@ $as_echo "#define HAVE_THREADSAFE_CURL_GLOBAL_INIT 1" >>confdefs.h
 
   fi
 
-  # Warn if a thread-friendly DNS resolver isn't built.
+  # Fail if a thread-friendly DNS resolver isn't built.
   { $as_echo "$as_me:${as_lineno-$LINENO}: checking for curl support for asynchronous DNS" >&5
 $as_echo_n "checking for curl support for asynchronous DNS... " >&6; }
 if ${pgac_cv__libcurl_async_dns+:} false; then :
@@ -12535,15 +12535,11 @@ fi
 fi
 { $as_echo "$as_me:${as_lineno-$LINENO}: result: $pgac_cv__libcurl_async_dns" >&5
 $as_echo "$pgac_cv__libcurl_async_dns" >&6; }
-  if test x"$pgac_cv__libcurl_async_dns" != xyes ; then
-    { $as_echo "$as_me:${as_lineno-$LINENO}: WARNING:
-*** The installed version of libcurl does not support asynchronous DNS
-*** lookups. Connection timeouts will not be honored during DNS resolution,
-*** which may lead to hangs in client programs." >&5
-$as_echo "$as_me: WARNING:
+  if test x"$pgac_cv__libcurl_async_dns" = xno ; then
+    as_fn_error $? "
 *** The installed version of libcurl does not support asynchronous DNS
-*** lookups. Connection timeouts will not be honored during DNS resolution,
-*** which may lead to hangs in client programs." >&2;}
+*** lookups. Rebuild libcurl with the AsynchDNS feature enabled in order
+*** to use it with libpq." "$LINENO" 5
   fi
 
 fi
diff --git a/meson.build b/meson.build
index 13c13748e5d..b6daa5b7040 100644
--- a/meson.build
+++ b/meson.build
@@ -909,9 +909,7 @@ if not libcurlopt.disabled()
       cdata.set('HAVE_THREADSAFE_CURL_GLOBAL_INIT', 1)
     endif
 
-    # Warn if a thread-friendly DNS resolver isn't built.
-    libcurl_async_dns = false
-
+    # Fail if a thread-friendly DNS resolver isn't built.
     if not meson.is_cross_build()
       r = cc.run('''
         #include <curl/curl.h>
@@ -931,16 +929,12 @@ if not libcurlopt.disabled()
       )
 
       assert(r.compiled())
-      if r.returncode() == 0
-        libcurl_async_dns = true
-      endif
-    endif
-
-    if not libcurl_async_dns
-      warning('''
+      if r.returncode() != 0
+        error('''
 *** The installed version of libcurl does not support asynchronous DNS
-*** lookups. Connection timeouts will not be honored during DNS resolution,
-*** which may lead to hangs in client programs.''')
+*** lookups. Rebuild libcurl with the AsynchDNS feature enabled in order
+*** to use it with libpq.''')
+      endif
     endif
   endif
 
-- 
2.34.1



  [application/octet-stream] 0003-oauth-Simplify-copy-of-PGoauthBearerRequest.patch (992B, ../../CAOYmi+mhqahb65y1zXtv60T9=mDYTaepV9b-wq-GHey00zuGOg@mail.gmail.com/4-0003-oauth-Simplify-copy-of-PGoauthBearerRequest.patch)
  download | inline diff:
From dea3b63b7bd2196b504a940e515f6940b1794a60 Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Mon, 24 Feb 2025 15:43:09 -0800
Subject: [PATCH 3/3] oauth: Simplify copy of PGoauthBearerRequest

Follow-up to 03366b61d. Since there are no more const members in the
PGoauthBearerRequest struct, the previous memcpy() can be replaced with
simple assignment.
---
 src/interfaces/libpq/fe-auth-oauth.c | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/interfaces/libpq/fe-auth-oauth.c b/src/interfaces/libpq/fe-auth-oauth.c
index fb1e9a1a8aa..cf1a25e2ccc 100644
--- a/src/interfaces/libpq/fe-auth-oauth.c
+++ b/src/interfaces/libpq/fe-auth-oauth.c
@@ -781,7 +781,7 @@ setup_token_request(PGconn *conn, fe_oauth_state *state)
 			goto fail;
 		}
 
-		memcpy(request_copy, &request, sizeof(request));
+		*request_copy = request;
 
 		conn->async_auth = run_user_oauth_flow;
 		conn->cleanup_async_auth = cleanup_user_oauth_flow;
-- 
2.34.1



^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-02-28 13:43  Thomas Munro <[email protected]>
  parent: Jacob Champion <[email protected]>
  0 siblings, 1 reply; 101+ messages in thread

From: Thomas Munro @ 2025-02-28 13:43 UTC (permalink / raw)
  To: Jacob Champion <[email protected]>; +Cc: Andres Freund <[email protected]>; Daniel Gustafsson <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

Hi,

If you trigger the new optional NetBSD CI task, the oauthvalidator
tests implode[1].  Apparently that OS's kevent() doesn't like zero
relative timeouts for EVFILT_TIMER[2].  I see that you worked around
the same problem for Linux timerfd already by rounding 0 up to 1, so
we could just do the same here, and it passes with the attached.  A
cute alternative, not tested, might be to put NOTE_ABSTIME into fflag
if timeout == 0 (then it's an absolute time in the past, which should
fire immediately).

But I'm curious, how hard would it be to do this ↓ instead and not
have that problem on any OS?

     * There might be an optimization opportunity here: if timeout == 0, we
     * could signal drive_request to immediately call
     * curl_multi_socket_action, rather than returning all the way up the
     * stack only to come right back. But it's not clear that the additional
     * code complexity is worth it.

[1] https://cirrus-ci.com/task/6354435774873600
[2] https://github.com/NetBSD/src/blob/67c7c4658e77aa4534b6aac8c041d77097c5e722/sys/kern/kern_event.c#L1...


Attachments:

  [text/x-patch] 0001-Fix-OAUTH-on-NetBSD.patch (1.3K, ../../CA+hUKGJ+WyJ26QGvO_nkgvbxgw+03U4EQ4Hxw+QBft6Np+XW7w@mail.gmail.com/2-0001-Fix-OAUTH-on-NetBSD.patch)
  download | inline diff:
From 7deb153caf552c9bcb380f88eddbca94be33a0c2 Mon Sep 17 00:00:00 2001
From: Thomas Munro <[email protected]>
Date: Sat, 1 Mar 2025 00:27:01 +1300
Subject: [PATCH] Fix OAUTH on NetBSD.

NetBSD's EVFILT_TIMER doesn't like zero relative timeouts and fails with
EINVAL.  Steal the workaround from the same problem on Linux from a few
lines up: round 0 up to 1.

This could be seen on the optional NetBSD CI task, but not on the NetBSD
build farm animals because they aren't finding curl and testing this
code.
---
 src/interfaces/libpq/fe-auth-oauth-curl.c | 10 ++++++++++
 1 file changed, 10 insertions(+)

diff --git a/src/interfaces/libpq/fe-auth-oauth-curl.c b/src/interfaces/libpq/fe-auth-oauth-curl.c
index ae339579f88..6e60a81574d 100644
--- a/src/interfaces/libpq/fe-auth-oauth-curl.c
+++ b/src/interfaces/libpq/fe-auth-oauth-curl.c
@@ -1363,6 +1363,16 @@ set_timer(struct async_ctx *actx, long timeout)
 #ifdef HAVE_SYS_EVENT_H
 	struct kevent ev;
 
+#ifdef __NetBSD__
+
+	/*
+	 * Work around NetBSD's rejection of zero timeouts (EINVAL), a bit like
+	 * timerfd above.
+	 */
+	if (timeout == 0)
+		timeout = 1;
+#endif
+
 	/* Enable/disable the timer itself. */
 	EV_SET(&ev, 1, EVFILT_TIMER, timeout < 0 ? EV_DELETE : (EV_ADD | EV_ONESHOT),
 		   0, timeout, 0);
-- 
2.48.1



^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-02-28 17:37  Jacob Champion <[email protected]>
  parent: Thomas Munro <[email protected]>
  0 siblings, 2 replies; 101+ messages in thread

From: Jacob Champion @ 2025-02-28 17:37 UTC (permalink / raw)
  To: Thomas Munro <[email protected]>; +Cc: Andres Freund <[email protected]>; Daniel Gustafsson <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

On Fri, Feb 28, 2025 at 5:44 AM Thomas Munro <[email protected]> wrote:
> If you trigger the new optional NetBSD CI task, the oauthvalidator
> tests implode[1].

Oh, thank you for reporting that. I need to pay more attention to the
BSD CI thread.

> Apparently that OS's kevent() doesn't like zero
> relative timeouts for EVFILT_TIMER[2].  I see that you worked around
> the same problem for Linux timerfd already by rounding 0 up to 1, so
> we could just do the same here, and it passes with the attached.

Just from inspection, that looks good to me. I'll look into running
the new BSD tasks on the other patches I posted above, too.

Should we maybe consider just doing that across the board, and put up
with the inefficiency? Admittedly 1ms is a lot more dead time than
1ns...

> A
> cute alternative, not tested, might be to put NOTE_ABSTIME into fflag
> if timeout == 0 (then it's an absolute time in the past, which should
> fire immediately).

That could work. I think I need to stare at the man pages more if we
go that direction, since (IIUC) NOTE_ABSTIME changes up some other
default behavior for the timer.

> But I'm curious, how hard would it be to do this ↓ instead and not
> have that problem on any OS?
>
>      * There might be an optimization opportunity here: if timeout == 0, we
>      * could signal drive_request to immediately call
>      * curl_multi_socket_action, rather than returning all the way up the
>      * stack only to come right back. But it's not clear that the additional
>      * code complexity is worth it.

I'm not sure if it's hard, so much as it is confusing. My first
attempt at it a while back gave me the feeling that I wouldn't
remember how it worked in a few months.

Here are the things that I think we would have to consider, at minimum:
1. Every call to curl_multi_socket_action/all now has to look for the
new "time out immediately" flag after returning.
2. If it is set, and if actx->running has been set to zero, we have to
decide what that means conceptually. (Is that an impossible case? Or
do we ignore the timeout and assume the request is done/failed?)
3. Otherwise, we need to clear the flag and immediately call
curl_multi_socket_action(CURL_SOCKET_TIMEOUT), and repeat until that
flag is no longer set. That feels brittle to me, because if there's
some misunderstanding in our code or some strange corner case in an
old version of Curl on some platform, and we keep getting a timeout of
zero, we'll hit an infinite loop. (The current behavior instead
returns control to the top level every time, and gives
curl_multi_socket_all() a chance to right the ship by checking the
status of all the outstanding sockets.)
4. Even if 2 and 3 are just FUD, there's another potential call to
set_timer(0) in OAUTH_STEP_TOKEN_REQUEST. The interval can only be
zero in debug mode, so if we add new code for that case alone, the
tests will be mostly exercising a non-production code path.

I prefer your patch, personally.

Thanks!
--Jacob






^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-02-28 21:57  Jacob Champion <[email protected]>
  parent: Jacob Champion <[email protected]>
  1 sibling, 1 reply; 101+ messages in thread

From: Jacob Champion @ 2025-02-28 21:57 UTC (permalink / raw)
  To: Thomas Munro <[email protected]>; +Cc: Andres Freund <[email protected]>; Daniel Gustafsson <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

On Fri, Feb 28, 2025 at 9:37 AM Jacob Champion
<[email protected]> wrote:
> Just from inspection, that looks good to me. I'll look into running
> the new BSD tasks on the other patches I posted above, too.

After your patch gets us past the zero timeout bug, NetBSD next runs into

    failed to fetch OpenID discovery document: Unsupported protocol
(libcurl: Received HTTP/0.9 when not allowed)'

...but only for a single test (nonempty oauth_client_secret), which is
very strange. And it doesn't always fail during the same HTTP request.
I'll look into it.

--Jacob






^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-02-28 23:38  Thomas Munro <[email protected]>
  parent: Jacob Champion <[email protected]>
  1 sibling, 0 replies; 101+ messages in thread

From: Thomas Munro @ 2025-02-28 23:38 UTC (permalink / raw)
  To: Jacob Champion <[email protected]>; +Cc: Andres Freund <[email protected]>; Daniel Gustafsson <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

On Sat, Mar 1, 2025 at 6:37 AM Jacob Champion
<[email protected]> wrote:
> Should we maybe consider just doing that across the board, and put up
> with the inefficiency? Admittedly 1ms is a lot more dead time than
> 1ns...

Last time I checked, NetBSD is still using scheduler ticks (100hz
periodic interrupt) for all this kind of stuff so it's even worse than
that :-)

> I prefer your patch, personally.

Cool, I'll commit it shortly unless someone else comes up with a better idea.






^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-03-01 00:36  Thomas Munro <[email protected]>
  parent: Jacob Champion <[email protected]>
  0 siblings, 1 reply; 101+ messages in thread

From: Thomas Munro @ 2025-03-01 00:36 UTC (permalink / raw)
  To: Jacob Champion <[email protected]>; +Cc: Andres Freund <[email protected]>; Daniel Gustafsson <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

On Sat, Mar 1, 2025 at 10:57 AM Jacob Champion
<[email protected]> wrote:
> After your patch gets us past the zero timeout bug, NetBSD next runs into
>
>     failed to fetch OpenID discovery document: Unsupported protocol
> (libcurl: Received HTTP/0.9 when not allowed)'
>
> ...but only for a single test (nonempty oauth_client_secret), which is
> very strange. And it doesn't always fail during the same HTTP request.
> I'll look into it.

In case it's relevant, it was green for me, but I also ran it in
combination with my 3x-go-faster patch on that other thread. . o O {
Timing/race stuff?  Normally the build farm shakes that stuff out a
bit more reliably than CI, but I doubt libcurl is set up on many
animals... }






^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-03-04 00:07  Jacob Champion <[email protected]>
  parent: Thomas Munro <[email protected]>
  0 siblings, 1 reply; 101+ messages in thread

From: Jacob Champion @ 2025-03-04 00:07 UTC (permalink / raw)
  To: Thomas Munro <[email protected]>; +Cc: Andres Freund <[email protected]>; Daniel Gustafsson <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

On Fri, Feb 28, 2025 at 4:37 PM Thomas Munro <[email protected]> wrote:
> In case it's relevant, it was green for me, but I also ran it in
> combination with my 3x-go-faster patch on that other thread. . o O {
> Timing/race stuff?  Normally the build farm shakes that stuff out a
> bit more reliably than CI, but I doubt libcurl is set up on many
> animals... }

That does help, thanks. Luckily, I can still sometimes reproduce with
that patch, which should speed things up nicely.

Commenting out the failing test causes the next test to fail with
basically the same error, so there's something stateful going on.
There are some suspicious messages that occasionally show up right
before the failure:

    # [libcurl] * IPv6: ::1
    # [libcurl] * IPv4: 127.0.0.1
    # [libcurl] *   Trying [::1]:65269...
    # [libcurl] * getsockname() failed with errno 22: Invalid argument
    # [libcurl] * connect to ::1 port 65269 from ::1 port 65270
failed: Connection refused
    # [libcurl] *   Trying 127.0.0.1:65269...
    # [libcurl] * Connected to localhost (127.0.0.1) port 65269

Later, Curl reconnects via IPv6 -- this time succeeding -- but then
the response gets mangled in some way. I assume headers are being
truncated, based on Curl's complaint about "HTTP/0.9".

The NetBSD man pages say that EINVAL is returned when the socket is
already shut down, suggesting some sort of bad interaction between
Curl and the test authorization server (and/or the OS?). I wonder if
my test server doesn't handle dual-stack setups correctly. I'll see if
I can get ktruss working on either side.

Thanks,
--Jacob






^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-03-04 04:10  Thomas Munro <[email protected]>
  parent: Jacob Champion <[email protected]>
  0 siblings, 1 reply; 101+ messages in thread

From: Thomas Munro @ 2025-03-04 04:10 UTC (permalink / raw)
  To: Jacob Champion <[email protected]>; +Cc: Andres Freund <[email protected]>; Daniel Gustafsson <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

On Tue, Mar 4, 2025 at 1:07 PM Jacob Champion
<[email protected]> wrote:
>     # [libcurl] * getsockname() failed with errno 22: Invalid argument

Weird.

> Later, Curl reconnects via IPv6 -- this time succeeding -- but then
> the response gets mangled in some way. I assume headers are being
> truncated, based on Curl's complaint about "HTTP/0.9".

And weirder.  With no evidence, I kinda wonder if that part could be a
bug in curl, if it gets a failure in an unexpected place like that and
gets confused, but let's start with the error...

> The NetBSD man pages say that EINVAL is returned when the socket is
> already shut down, suggesting some sort of bad interaction between
> Curl and the test authorization server (and/or the OS?). I wonder if
> my test server doesn't handle dual-stack setups correctly. I'll see if
> I can get ktruss working on either side.

POSIX says that about getsockname() too, as does the macOS man page,
but not those of FreeBSD, OpenBSD or Linux, but I don't think that's
the issue.  (From a quick peek: FreeBSD (in_getsockaddr) asserts that
sotoinpcb(so) is not NULL so it's always able to cough up the address
from the protocol control block object, while NetBSD (tcp_sockaddr)
and macOS/XNU (in6_getsockaddr) check for NULL and return EINVAL, so
at a wild guess, there may some path somewhere that knows the socket
is shutdown in both directions and all data has been drained so it can
be destroyed, and POSIX doesn't require sockets in this state to be
able to tell you their address so that's all OK?)

It's also unspecified if you haven't called connect() or bind() (well,
POSIX actually says that the address it gives you is unspecified, not
that the error is unspecified...).

I tried on a NetBSD 9 Vagrant box I had lying around, and ... ahh:

 28729      1 psql     write(0x2, 0x7f7fffddf3d0, 0x24) = 36
       "[libcurl] *   Trying [::1]:64244...\n"
 28729      1 psql     setsockopt(0x9, 0x6, 0x1, 0x7f7fffde015c, 0x4) = 0
 28729      1 psql     setsockopt(0x9, 0xffff, 0x800, 0x7f7fffde015c, 0x4) = 0
 26362      1 perl     __select50                  = 1
 28729      1 psql     connect                     Err#36 EINPROGRESS
 26362      1 perl     read                        = 36
 28729      1 psql     getsockname(0x9, 0x7f7fffde0240,
0x7f7fffde023c) Err#22 EINVAL

Other times it succeeds:

 28729      1 psql     connect                     Err#36 EINPROGRESS
 28729      1 psql     getsockname                 = 0

I think that is telling us that a non-blocking socket can be in a
state that is not yet connected enough even to tell you its local
address?  That is, connect() returns without having allocated a local
address, and does that part asynchronously too?  I don't know what to
think about that yet...

https://stackoverflow.com/questions/25333547/is-it-safe-to-call-getsockname-while-a-nonblocking-stre...






^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-03-04 17:08  Jacob Champion <[email protected]>
  parent: Thomas Munro <[email protected]>
  0 siblings, 1 reply; 101+ messages in thread

From: Jacob Champion @ 2025-03-04 17:08 UTC (permalink / raw)
  To: Thomas Munro <[email protected]>; +Cc: Andres Freund <[email protected]>; Daniel Gustafsson <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

On Mon, Mar 3, 2025 at 4:07 PM Jacob Champion
<[email protected]> wrote:
> I wonder if
> my test server doesn't handle dual-stack setups correctly.

Spoilers: it's this.

> I'll see if
> I can get ktruss working on either side.

ktruss shows absolutely no syscall activity on the authorization
server during the failing test, because Curl's talking to something
else. sockstat confirms that I completely forgot to listen on IPv6 in
the test server. Dual stack sockets only work from the IPv6
direction...

There must be some law of conservation of weirdness, where the
strangest failure modes have the most boring explanations. I'll work
on a fix.

On Mon, Mar 3, 2025 at 8:11 PM Thomas Munro <[email protected]> wrote:
> I think that is telling us that a non-blocking socket can be in a
> state that is not yet connected enough even to tell you its local
> address?  That is, connect() returns without having allocated a local
> address, and does that part asynchronously too?  I don't know what to
> think about that yet...

That is also really good to know, though. So that EINVAL message
might, in the end, be completely unrelated to the bug? (Curl doesn't
worry about the error, looks like, just prints it to the debug
stream.)

Thanks!
--Jacob






^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-03-04 22:37  Thomas Munro <[email protected]>
  parent: Jacob Champion <[email protected]>
  0 siblings, 1 reply; 101+ messages in thread

From: Thomas Munro @ 2025-03-04 22:37 UTC (permalink / raw)
  To: Jacob Champion <[email protected]>; +Cc: Andres Freund <[email protected]>; Daniel Gustafsson <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

On Wed, Mar 5, 2025 at 6:08 AM Jacob Champion
<[email protected]> wrote:
> ktruss shows absolutely no syscall activity on the authorization
> server during the failing test, because Curl's talking to something
> else. sockstat confirms that I completely forgot to listen on IPv6 in
> the test server. Dual stack sockets only work from the IPv6
> direction...
>
> There must be some law of conservation of weirdness, where the
> strangest failure modes have the most boring explanations. I'll work
> on a fix.

Heh, wow, that was confusing :-)  Actually I'm still confused (why
passing sometimes then?) but I'm sure all will become clear with your
patch...

> On Mon, Mar 3, 2025 at 8:11 PM Thomas Munro <[email protected]> wrote:
> > I think that is telling us that a non-blocking socket can be in a
> > state that is not yet connected enough even to tell you its local
> > address?  That is, connect() returns without having allocated a local
> > address, and does that part asynchronously too?  I don't know what to
> > think about that yet...
>
> That is also really good to know, though. So that EINVAL message
> might, in the end, be completely unrelated to the bug? (Curl doesn't
> worry about the error, looks like, just prints it to the debug
> stream.)

Yeah.  I wonder if it only happens if the connection is doomed to fail
already, or something.  But I don't plan to dig further if it's
harmless (maybe curl shouldn't really do that, IDK).






^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-03-04 22:44  Jacob Champion <[email protected]>
  parent: Thomas Munro <[email protected]>
  0 siblings, 1 reply; 101+ messages in thread

From: Jacob Champion @ 2025-03-04 22:44 UTC (permalink / raw)
  To: Thomas Munro <[email protected]>; +Cc: Andres Freund <[email protected]>; Daniel Gustafsson <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

On Tue, Mar 4, 2025 at 2:38 PM Thomas Munro <[email protected]> wrote:
> Heh, wow, that was confusing :-)  Actually I'm still confused (why
> passing sometimes then?)

Curl doesn't mind if the IPv6 connection fails outright; it'll use the
IPv4 in that case. But if something else ephemeral pops up on IPv6 and
starts speaking something that's not HTTP, that's a problem.

> but I'm sure all will become clear with your
> patch...

Maybe. My first attempt gets all the BSDs green except macOS -- which
now fails in a completely different test, haha... -_-

I'll keep you posted.

--Jacob






^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-03-06 20:57  Jacob Champion <[email protected]>
  parent: Jacob Champion <[email protected]>
  0 siblings, 3 replies; 101+ messages in thread

From: Jacob Champion @ 2025-03-06 20:57 UTC (permalink / raw)
  To: Thomas Munro <[email protected]>; +Cc: Andres Freund <[email protected]>; Daniel Gustafsson <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

On Tue, Mar 4, 2025 at 2:44 PM Jacob Champion
<[email protected]> wrote:
> Maybe. My first attempt gets all the BSDs green except macOS -- which
> now fails in a completely different test, haha... -_-

Small update: there is not one bug, but three that interact. ಠ_ಠ

1) The test server advertises an issuer of `https://localhost:<port;`,
but it doesn't listen on all localhost interfaces. When Curl tries to
contact the issuer on IPv6, its Happy Eyeballs handling usually falls
back to IPv4 after discovering that IPv6 is nonfunctional, but
occasionally it contacts something that was temporarily listening
there instead.

Since I don't really want to write a bunch of IPv6 fallback code for
the test server -- this should be testing OAuth, not finding all the
ways that buildfarm OSes can expose dual stack sockets -- I changed
the issuer to be IPv4-only. When I did this, the interval timing tests
immediately failed on macOS.

2) macOS's EVFILT_TIMER implementation seems to be different from the
other BSDs. On Mac, when you re-add a timer to a kqueue, any existing
timer-fired events for it are not cleared out and the kqueue might
remain readable. This breaks a postcondition of our set_timer()
function, which is that new timeouts are supposed to completely
replace previous timeouts.

With a dual stack issuer, the Happy Eyeballs timeouts would be
routinely cleared out by libcurl, setting up a clean slate for the
next call to set_timer(). But with an IPv4-only issuer, libcurl didn't
need to clear out the timeouts (they'd already fired), which meant
that our call to set the ping interval was ineffective.

3) There is a related performance bug on other platforms. If a Curl
timeout happens partway through a request (so libcurl won't clear it),
the timer-expired event will stay set and CPU will be burned to spin
pointlessly on drive_request(). This is much easier to notice after
taking Happy Eyeballs out of the picture. It doesn't cause logical
failures -- Curl basically discards the unnecessary calls -- but it's
definitely unintended.

--

Problem 1 is a simple patch. I am working on a fix for Problem 2, but
I got stuck trying to get a "perfect" solution working yesterday...
Since this is a partial(?) blocker for getting NetBSD going, I'm going
to pivot to an ugly-but-simple approach today.

I plan to defer working on Problem 3, which should just be a
performance bug, until the tests are green again. And I would like to
eventually add some stronger unit tests for the timer behavior, to
catch other potential OS-specific problems in the future.

Thanks,
--Jacob






^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-03-07 00:35  Jacob Champion <[email protected]>
  parent: Jacob Champion <[email protected]>
  2 siblings, 0 replies; 101+ messages in thread

From: Jacob Champion @ 2025-03-07 00:35 UTC (permalink / raw)
  To: Thomas Munro <[email protected]>; +Cc: Andres Freund <[email protected]>; Daniel Gustafsson <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

On Thu, Mar 6, 2025 at 12:57 PM Jacob Champion
<[email protected]> wrote:
> Problem 1 is a simple patch. I am working on a fix for Problem 2, but
> I got stuck trying to get a "perfect" solution working yesterday...
> Since this is a partial(?) blocker for getting NetBSD going, I'm going
> to pivot to an ugly-but-simple approach today.

Attached:
- 0001 fixes IPv6 failures,
- 0002 fixes set_timer() on Mac, and
- 0003-0005 are the existing followup patches from upthread.

Thanks!
--Jacob


Attachments:

  [application/octet-stream] 0001-oauth-Use-IPv4-only-issuer-in-oauth_validator-tests.patch (3.2K, ../../CAOYmi+=4htNLXvuTS58GL96qwBptToDop09PWFy6uzDHNz4qTw@mail.gmail.com/2-0001-oauth-Use-IPv4-only-issuer-in-oauth_validator-tests.patch)
  download | inline diff:
From 4e1024eb47a3d19145a8db42a48d55d608ad4054 Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Tue, 4 Mar 2025 09:41:19 -0800
Subject: [PATCH 1/5] oauth: Use IPv4-only issuer in oauth_validator tests

The test authorization server implemented in oauth_server.py does not
listen on IPv6. Most of the time, libcurl happily falls back to IPv4
after failing its initial connection, but on NetBSD, something is
consistently showing up on the unreserved IPv6 port and causing a test
failure.

Rather than deal with dual-stack details across all test platforms,
change the issuer to enforce the use of IPv4 only. (This elicits more
punishing timeout behavior from libcurl, so it's a useful change from
the testing perspective as well.)

Reported-by: Thomas Munro <[email protected]>
Discussion: https://postgr.es/m/CAOYmi%2Bn4EDOOUL27_OqYT2-F2rS6S%2B3mK-ppWb2Ec92UEoUbYA%40mail.gmail.com
---
 src/test/modules/oauth_validator/t/001_server.pl   | 2 +-
 src/test/modules/oauth_validator/t/OAuth/Server.pm | 4 ++--
 src/test/modules/oauth_validator/t/oauth_server.py | 2 +-
 3 files changed, 4 insertions(+), 4 deletions(-)

diff --git a/src/test/modules/oauth_validator/t/001_server.pl b/src/test/modules/oauth_validator/t/001_server.pl
index 6fa59fbeb25..30295364ebd 100644
--- a/src/test/modules/oauth_validator/t/001_server.pl
+++ b/src/test/modules/oauth_validator/t/001_server.pl
@@ -68,7 +68,7 @@ END
 }
 
 my $port = $webserver->port();
-my $issuer = "http://localhost:$port";
+my $issuer = "http://127.0.0.1:$port";
 
 unlink($node->data_dir . '/pg_hba.conf');
 $node->append_conf(
diff --git a/src/test/modules/oauth_validator/t/OAuth/Server.pm b/src/test/modules/oauth_validator/t/OAuth/Server.pm
index 655b2870b0b..52ae7afa991 100644
--- a/src/test/modules/oauth_validator/t/OAuth/Server.pm
+++ b/src/test/modules/oauth_validator/t/OAuth/Server.pm
@@ -15,7 +15,7 @@ OAuth::Server - runs a mock OAuth authorization server for testing
   $server->run;
 
   my $port = $server->port;
-  my $issuer = "http://localhost:$port";
+  my $issuer = "http://127.0.0.1:$port";
 
   # test against $issuer...
 
@@ -28,7 +28,7 @@ daemon implemented in t/oauth_server.py. (Python has a fairly usable HTTP server
 in its standard library, so the implementation was ported from Perl.)
 
 This authorization server does not use TLS (it implements a nonstandard, unsafe
-issuer at "http://localhost:<port>"), so libpq in particular will need to set
+issuer at "http://127.0.0.1:<port>"), so libpq in particular will need to set
 PGOAUTHDEBUG=UNSAFE to be able to talk to it.
 
 =cut
diff --git a/src/test/modules/oauth_validator/t/oauth_server.py b/src/test/modules/oauth_validator/t/oauth_server.py
index 4faf3323d38..5bc30be87fd 100755
--- a/src/test/modules/oauth_validator/t/oauth_server.py
+++ b/src/test/modules/oauth_validator/t/oauth_server.py
@@ -251,7 +251,7 @@ class OAuthHandler(http.server.BaseHTTPRequestHandler):
     def config(self) -> JsonObject:
         port = self.server.socket.getsockname()[1]
 
-        issuer = f"http://localhost:{port}"
+        issuer = f"http://127.0.0.1:{port}"
         if self._alt_issuer:
             issuer += "/alternate"
         elif self._parameterized:
-- 
2.34.1



  [application/octet-stream] 0002-oauth-Fix-postcondition-for-set_timer-on-BSD.patch (3.6K, ../../CAOYmi+=4htNLXvuTS58GL96qwBptToDop09PWFy6uzDHNz4qTw@mail.gmail.com/3-0002-oauth-Fix-postcondition-for-set_timer-on-BSD.patch)
  download | inline diff:
From 410dce1670a04de81d533dd1b5456e363b144ca8 Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Thu, 6 Mar 2025 15:02:37 -0800
Subject: [PATCH 2/5] oauth: Fix postcondition for set_timer on BSD

On macOS, readding an EVFILT_TIMER to a kqueue does not appear to clear
out previously queued timer events, so checks for timer expiration do
not work correctly during token retrieval. Switching to IPv4-only
communication exposes the problem, because libcurl is no longer clearing
out other timeouts related to Happy Eyeballs dual-stack handling.

Fully remove and re-register the kqueue timer events during each call to
set_timer(), to clear out any stale expirations.

Discussion: https://postgr.es/m/CAOYmi%2Bn4EDOOUL27_OqYT2-F2rS6S%2B3mK-ppWb2Ec92UEoUbYA%40mail.gmail.com
---
 src/interfaces/libpq/fe-auth-oauth-curl.c | 48 +++++++++++++++++------
 1 file changed, 35 insertions(+), 13 deletions(-)

diff --git a/src/interfaces/libpq/fe-auth-oauth-curl.c b/src/interfaces/libpq/fe-auth-oauth-curl.c
index 6e60a81574d..2d6d4b1a123 100644
--- a/src/interfaces/libpq/fe-auth-oauth-curl.c
+++ b/src/interfaces/libpq/fe-auth-oauth-curl.c
@@ -1326,6 +1326,10 @@ register_socket(CURL *curl, curl_socket_t socket, int what, void *ctx,
  * in the set at all times and just disarm it when it's not needed. For kqueue,
  * the timer is removed completely when disabled to prevent stale timeouts from
  * remaining in the queue.
+ *
+ * To meet Curl requirements for the CURLMOPT_TIMERFUNCTION, implementations of
+ * set_timer must handle repeated calls by fully discarding any previous running
+ * or expired timer.
  */
 static bool
 set_timer(struct async_ctx *actx, long timeout)
@@ -1373,26 +1377,44 @@ set_timer(struct async_ctx *actx, long timeout)
 		timeout = 1;
 #endif
 
-	/* Enable/disable the timer itself. */
-	EV_SET(&ev, 1, EVFILT_TIMER, timeout < 0 ? EV_DELETE : (EV_ADD | EV_ONESHOT),
-		   0, timeout, 0);
+	/*
+	 * Always disable the timer, and remove it from the multiplexer, to clear
+	 * out any already-queued events. (On some BSDs, adding an EVFILT_TIMER to
+	 * a kqueue that already has one will clear stale events, but not on
+	 * macOS.)
+	 *
+	 * If there was no previous timer set, the kevent calls will result in
+	 * ENOENT, which is fine.
+	 */
+	EV_SET(&ev, 1, EVFILT_TIMER, EV_DELETE, 0, 0, 0);
 	if (kevent(actx->timerfd, &ev, 1, NULL, 0, NULL) < 0 && errno != ENOENT)
 	{
-		actx_error(actx, "setting kqueue timer to %ld: %m", timeout);
+		actx_error(actx, "deleting kqueue timer: %m", timeout);
 		return false;
 	}
 
-	/*
-	 * Add/remove the timer to/from the mux. (In contrast with epoll, if we
-	 * allowed the timer to remain registered here after being disabled, the
-	 * mux queue would retain any previous stale timeout notifications and
-	 * remain readable.)
-	 */
-	EV_SET(&ev, actx->timerfd, EVFILT_READ, timeout < 0 ? EV_DELETE : EV_ADD,
-		   0, 0, 0);
+	EV_SET(&ev, actx->timerfd, EVFILT_READ, EV_DELETE, 0, 0, 0);
 	if (kevent(actx->mux, &ev, 1, NULL, 0, NULL) < 0 && errno != ENOENT)
 	{
-		actx_error(actx, "could not update timer on kqueue: %m");
+		actx_error(actx, "removing kqueue timer from multiplexer: %m");
+		return false;
+	}
+
+	/* If we're not adding a timer, we're done. */
+	if (timeout < 0)
+		return true;
+
+	EV_SET(&ev, 1, EVFILT_TIMER, (EV_ADD | EV_ONESHOT), 0, timeout, 0);
+	if (kevent(actx->timerfd, &ev, 1, NULL, 0, NULL) < 0)
+	{
+		actx_error(actx, "setting kqueue timer to %ld: %m", timeout);
+		return false;
+	}
+
+	EV_SET(&ev, actx->timerfd, EVFILT_READ, EV_ADD, 0, 0, 0);
+	if (kevent(actx->mux, &ev, 1, NULL, 0, NULL) < 0)
+	{
+		actx_error(actx, "adding kqueue timer to multiplexer: %m");
 		return false;
 	}
 
-- 
2.34.1



  [application/octet-stream] 0003-oauth-Disallow-synchronous-DNS-in-libcurl.patch (4.5K, ../../CAOYmi+=4htNLXvuTS58GL96qwBptToDop09PWFy6uzDHNz4qTw@mail.gmail.com/4-0003-oauth-Disallow-synchronous-DNS-in-libcurl.patch)
  download | inline diff:
From c2e098c592a8f2d2c3d8f12e82b2736a630ca282 Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Mon, 24 Feb 2025 15:02:01 -0800
Subject: [PATCH 3/5] oauth: Disallow synchronous DNS in libcurl

There is concern that a blocking DNS lookup in libpq could stall a
backend process (say, via FDW). Since there's currently no strong
evidence that synchronous DNS is a popular option, disallow it entirely
rather than warning at configure time. We can revisit if anyone
complains.

Per query from Andres Freund.

Discussion: https://postgr.es/m/p4bd7mn6dxr2zdak74abocyltpfdxif4pxqzixqpxpetjwt34h%40qc6jgfmoddvq
---
 config/programs.m4 | 10 +++++-----
 configure          | 14 +++++---------
 meson.build        | 18 ++++++------------
 3 files changed, 16 insertions(+), 26 deletions(-)

diff --git a/config/programs.m4 b/config/programs.m4
index 061b13376ac..0a07feb37cc 100644
--- a/config/programs.m4
+++ b/config/programs.m4
@@ -316,7 +316,7 @@ AC_DEFUN([PGAC_CHECK_LIBCURL],
               [Define to 1 if curl_global_init() is guaranteed to be thread-safe.])
   fi
 
-  # Warn if a thread-friendly DNS resolver isn't built.
+  # Fail if a thread-friendly DNS resolver isn't built.
   AC_CACHE_CHECK([for curl support for asynchronous DNS], [pgac_cv__libcurl_async_dns],
   [AC_RUN_IFELSE([AC_LANG_PROGRAM([
 #include <curl/curl.h>
@@ -332,10 +332,10 @@ AC_DEFUN([PGAC_CHECK_LIBCURL],
   [pgac_cv__libcurl_async_dns=yes],
   [pgac_cv__libcurl_async_dns=no],
   [pgac_cv__libcurl_async_dns=unknown])])
-  if test x"$pgac_cv__libcurl_async_dns" != xyes ; then
-    AC_MSG_WARN([
+  if test x"$pgac_cv__libcurl_async_dns" = xno ; then
+    AC_MSG_ERROR([
 *** The installed version of libcurl does not support asynchronous DNS
-*** lookups. Connection timeouts will not be honored during DNS resolution,
-*** which may lead to hangs in client programs.])
+*** lookups. Rebuild libcurl with the AsynchDNS feature enabled in order
+*** to use it with libpq.])
   fi
 ])# PGAC_CHECK_LIBCURL
diff --git a/configure b/configure
index 93fddd69981..559f535f5cd 100755
--- a/configure
+++ b/configure
@@ -12493,7 +12493,7 @@ $as_echo "#define HAVE_THREADSAFE_CURL_GLOBAL_INIT 1" >>confdefs.h
 
   fi
 
-  # Warn if a thread-friendly DNS resolver isn't built.
+  # Fail if a thread-friendly DNS resolver isn't built.
   { $as_echo "$as_me:${as_lineno-$LINENO}: checking for curl support for asynchronous DNS" >&5
 $as_echo_n "checking for curl support for asynchronous DNS... " >&6; }
 if ${pgac_cv__libcurl_async_dns+:} false; then :
@@ -12535,15 +12535,11 @@ fi
 fi
 { $as_echo "$as_me:${as_lineno-$LINENO}: result: $pgac_cv__libcurl_async_dns" >&5
 $as_echo "$pgac_cv__libcurl_async_dns" >&6; }
-  if test x"$pgac_cv__libcurl_async_dns" != xyes ; then
-    { $as_echo "$as_me:${as_lineno-$LINENO}: WARNING:
-*** The installed version of libcurl does not support asynchronous DNS
-*** lookups. Connection timeouts will not be honored during DNS resolution,
-*** which may lead to hangs in client programs." >&5
-$as_echo "$as_me: WARNING:
+  if test x"$pgac_cv__libcurl_async_dns" = xno ; then
+    as_fn_error $? "
 *** The installed version of libcurl does not support asynchronous DNS
-*** lookups. Connection timeouts will not be honored during DNS resolution,
-*** which may lead to hangs in client programs." >&2;}
+*** lookups. Rebuild libcurl with the AsynchDNS feature enabled in order
+*** to use it with libpq." "$LINENO" 5
   fi
 
 fi
diff --git a/meson.build b/meson.build
index 13c13748e5d..b6daa5b7040 100644
--- a/meson.build
+++ b/meson.build
@@ -909,9 +909,7 @@ if not libcurlopt.disabled()
       cdata.set('HAVE_THREADSAFE_CURL_GLOBAL_INIT', 1)
     endif
 
-    # Warn if a thread-friendly DNS resolver isn't built.
-    libcurl_async_dns = false
-
+    # Fail if a thread-friendly DNS resolver isn't built.
     if not meson.is_cross_build()
       r = cc.run('''
         #include <curl/curl.h>
@@ -931,16 +929,12 @@ if not libcurlopt.disabled()
       )
 
       assert(r.compiled())
-      if r.returncode() == 0
-        libcurl_async_dns = true
-      endif
-    endif
-
-    if not libcurl_async_dns
-      warning('''
+      if r.returncode() != 0
+        error('''
 *** The installed version of libcurl does not support asynchronous DNS
-*** lookups. Connection timeouts will not be honored during DNS resolution,
-*** which may lead to hangs in client programs.''')
+*** lookups. Rebuild libcurl with the AsynchDNS feature enabled in order
+*** to use it with libpq.''')
+      endif
     endif
   endif
 
-- 
2.34.1



  [application/octet-stream] 0004-oauth-Improve-validator-docs-on-interruptibility.patch (1.9K, ../../CAOYmi+=4htNLXvuTS58GL96qwBptToDop09PWFy6uzDHNz4qTw@mail.gmail.com/5-0004-oauth-Improve-validator-docs-on-interruptibility.patch)
  download | inline diff:
From d9f12352eec4002d30aa397dd3921f4340401c08 Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Tue, 25 Feb 2025 07:42:43 -0800
Subject: [PATCH 4/5] oauth: Improve validator docs on interruptibility

Andres pointed out that EINTR handling is inadequate for real-world use
cases. Direct module writers to our wait APIs instead.

Discussion: https://postgr.es/m/p4bd7mn6dxr2zdak74abocyltpfdxif4pxqzixqpxpetjwt34h%40qc6jgfmoddvq
---
 doc/src/sgml/oauth-validators.sgml | 12 +++++++-----
 1 file changed, 7 insertions(+), 5 deletions(-)

diff --git a/doc/src/sgml/oauth-validators.sgml b/doc/src/sgml/oauth-validators.sgml
index 356f11d3bd8..704089dd7b3 100644
--- a/doc/src/sgml/oauth-validators.sgml
+++ b/doc/src/sgml/oauth-validators.sgml
@@ -209,11 +209,13 @@
       <para>
        Modules must remain interruptible by signals so that the server can
        correctly handle authentication timeouts and shutdown signals from
-       <application>pg_ctl</application>. For example, a module receiving
-       <symbol>EINTR</symbol>/<symbol>EAGAIN</symbol> from a blocking call
-       should call <function>CHECK_FOR_INTERRUPTS()</function> before retrying.
-       The same should be done during any long-running loops. Failure to follow
-       this guidance may result in unresponsive backend sessions.
+       <application>pg_ctl</application>. For example, blocking calls on sockets
+       should generally be replaced with code that handles both socket events
+       and interrupts without races (see <function>WaitLatchOrSocket()</function>,
+       <function>WaitEventSetWait()</function>, et al), and long-running loops
+       should periodically call <function>CHECK_FOR_INTERRUPTS()</function>.
+       Failure to follow this guidance may result in unresponsive backend
+       sessions.
       </para>
      </listitem>
     </varlistentry>
-- 
2.34.1



  [application/octet-stream] 0005-oauth-Simplify-copy-of-PGoauthBearerRequest.patch (992B, ../../CAOYmi+=4htNLXvuTS58GL96qwBptToDop09PWFy6uzDHNz4qTw@mail.gmail.com/6-0005-oauth-Simplify-copy-of-PGoauthBearerRequest.patch)
  download | inline diff:
From b5beb488b7727a0ec88401833f7d08a37438bbf4 Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Mon, 24 Feb 2025 15:43:09 -0800
Subject: [PATCH 5/5] oauth: Simplify copy of PGoauthBearerRequest

Follow-up to 03366b61d. Since there are no more const members in the
PGoauthBearerRequest struct, the previous memcpy() can be replaced with
simple assignment.
---
 src/interfaces/libpq/fe-auth-oauth.c | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/interfaces/libpq/fe-auth-oauth.c b/src/interfaces/libpq/fe-auth-oauth.c
index fb1e9a1a8aa..cf1a25e2ccc 100644
--- a/src/interfaces/libpq/fe-auth-oauth.c
+++ b/src/interfaces/libpq/fe-auth-oauth.c
@@ -781,7 +781,7 @@ setup_token_request(PGconn *conn, fe_oauth_state *state)
 			goto fail;
 		}
 
-		memcpy(request_copy, &request, sizeof(request));
+		*request_copy = request;
 
 		conn->async_auth = run_user_oauth_flow;
 		conn->cleanup_async_auth = cleanup_user_oauth_flow;
-- 
2.34.1



^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-03-07 05:12  Thomas Munro <[email protected]>
  parent: Jacob Champion <[email protected]>
  2 siblings, 1 reply; 101+ messages in thread

From: Thomas Munro @ 2025-03-07 05:12 UTC (permalink / raw)
  To: Jacob Champion <[email protected]>; +Cc: Andres Freund <[email protected]>; Daniel Gustafsson <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

On Fri, Mar 7, 2025 at 9:57 AM Jacob Champion
<[email protected]> wrote:
> 2) macOS's EVFILT_TIMER implementation seems to be different from the
> other BSDs. On Mac, when you re-add a timer to a kqueue, any existing
> timer-fired events for it are not cleared out and the kqueue might
> remain readable. This breaks a postcondition of our set_timer()
> function, which is that new timeouts are supposed to completely
> replace previous timeouts.

I don't see that behaviour on my Mac with a simple program, and that
seems like it couldn't possibly be intended.  Hmm...  <browses source
code painfully> I wonder if this atomic generation scheme has a hole
in it, under concurrency...

https://github.com/apple-oss-distributions/xnu/blob/8d741a5de7ff4191bf97d57b9f54c2f6d4a15585/bsd/ker...

The code on the other OSes just dequeues it when reprogramming the
timer, which involves a lock and no doubt a few more cycles, and is
clearly not quite as exciting but ...





^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-03-07 17:30  Jacob Champion <[email protected]>
  parent: Thomas Munro <[email protected]>
  0 siblings, 1 reply; 101+ messages in thread

From: Jacob Champion @ 2025-03-07 17:30 UTC (permalink / raw)
  To: Thomas Munro <[email protected]>; +Cc: Andres Freund <[email protected]>; Daniel Gustafsson <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

On Thu, Mar 6, 2025 at 9:13 PM Thomas Munro <[email protected]> wrote:
> I don't see that behaviour on my Mac with a simple program, and that
> seems like it couldn't possibly be intended.

What version of macOS?

Just to make sure I'm not chasing ghosts, I've attached my test
program. Here are my CI results for running it:

= FreeBSD =

[      6 us] timer is set
[   1039 us] kqueue is readable
[   1050 us] timer is reset
[   1052 us] kqueue is not readable

= NetBSD =

[      3 us] timer is set
[  14993 us] kqueue is readable
[  15000 us] timer is reset
[  15002 us] kqueue is not readable

= OpenBSD =

[     24 us] timer is set
[  19660 us] kqueue is readable
[  19709 us] timer is reset
[  19712 us] kqueue is not readable

= macOS Sonoma =

[      4 us] timer is set
[   1282 us] kqueue is readable
[   1286 us] timer is reset
[   1287 us] kqueue is still readable

--Jacob


Attachments:

  [application/octet-stream] kqueue_test.c (1.6K, ../../CAOYmi+kC9232rEPTMUV8NsGZOFWw-2dmPs=Zz0MT4HXmoBwPqQ@mail.gmail.com/2-kqueue_test.c)
  download

^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-03-07 21:51  Thomas Munro <[email protected]>
  parent: Jacob Champion <[email protected]>
  0 siblings, 1 reply; 101+ messages in thread

From: Thomas Munro @ 2025-03-07 21:51 UTC (permalink / raw)
  To: Jacob Champion <[email protected]>; +Cc: Andres Freund <[email protected]>; Daniel Gustafsson <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

On Sat, Mar 8, 2025 at 6:31 AM Jacob Champion
<[email protected]> wrote:
> On Thu, Mar 6, 2025 at 9:13 PM Thomas Munro <[email protected]> wrote:
> > I don't see that behaviour on my Mac with a simple program, and that
> > seems like it couldn't possibly be intended.
>
> What version of macOS?
>
> Just to make sure I'm not chasing ghosts, I've attached my test
> program. Here are my CI results for running it:

Ah, right, yeah I see that here too.  I thought you were saying that
kevent() could report an already triggered alarm even though we'd
replaced it (it doesn') but of course you meant poll(kq) as libpq
does.

I believe I know exactly why: kqueues are considered readable (by
poll/select/other kqueues) if there are any events queued[1].  Apple's
EVFILT_TIMER implementation is doing that trick[2] where it leaves
them queued, but filt_timerprocess() filters them out if its own
private _FIRED flag isn't set, so kevent() itself won't wake up or
return them.  That trick doesn't survive nesting.  I think I would
call that a bug.  (I think I would keep the atomic CAS piece -- it
means you don't have to drain the timer callout synchronously when
reprogramming it which is a cool trick, but I think they overshot when
they left the knote queued.)

Maybe just do the delete-and-add in one call?

    EV_SET(&ev[0], 1, EVFILT_TIMER, EV_DELETE, 0, 0, 0);
    EV_SET(&ev[1], 1, EVFILT_TIMER, EV_ADD | EV_ONESHOT, 0, timeout, 0);
    if (kevent(kq, &ev[0], 2, NULL, 0, NULL) < 0)

[1] https://github.com/apple-oss-distributions/xnu/blob/8d741a5de7ff4191bf97d57b9f54c2f6d4a15585/bsd/ker...
[2] https://github.com/apple-oss-distributions/xnu/blob/8d741a5de7ff4191bf97d57b9f54c2f6d4a15585/bsd/ker...





^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-03-07 23:02  Jacob Champion <[email protected]>
  parent: Thomas Munro <[email protected]>
  0 siblings, 1 reply; 101+ messages in thread

From: Jacob Champion @ 2025-03-07 23:02 UTC (permalink / raw)
  To: Thomas Munro <[email protected]>; +Cc: Andres Freund <[email protected]>; Daniel Gustafsson <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

On Fri, Mar 7, 2025 at 1:52 PM Thomas Munro <[email protected]> wrote:
> I believe I know exactly why: kqueues are considered readable (by
> poll/select/other kqueues) if there are any events queued[1].  Apple's
> EVFILT_TIMER implementation is doing that trick[2] where it leaves
> them queued, but filt_timerprocess() filters them out if its own
> private _FIRED flag isn't set, so kevent() itself won't wake up or
> return them.  That trick doesn't survive nesting.  I think I would
> call that a bug.

Bleh. Thank you for the analysis!

> Maybe just do the delete-and-add in one call?
>
>     EV_SET(&ev[0], 1, EVFILT_TIMER, EV_DELETE, 0, 0, 0);
>     EV_SET(&ev[1], 1, EVFILT_TIMER, EV_ADD | EV_ONESHOT, 0, timeout, 0);
>     if (kevent(kq, &ev[0], 2, NULL, 0, NULL) < 0)

I think that requires me to copy the EV_RECEIPT handling from
register_socket(), to make sure an ENOENT is correctly ignored on
delete but doesn't mask failures from the addition. Do you prefer that
to the separate calls? (Or, better yet, is it easier than I'm making
it?)

Thanks!
--Jacob





^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-03-17 11:36  Nazir Bilal Yavuz <[email protected]>
  parent: Jacob Champion <[email protected]>
  0 siblings, 1 reply; 101+ messages in thread

From: Nazir Bilal Yavuz @ 2025-03-17 11:36 UTC (permalink / raw)
  To: Jacob Champion <[email protected]>; +Cc: Thomas Munro <[email protected]>; Andres Freund <[email protected]>; Daniel Gustafsson <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

Hi,

I just wanted to report that the 'oauth_validator/t/001_server.pl'
test failed on FreeBSD in one of my local CI runs [1]. I looked at the
thread but could not find the same error report; if this is already
known, please excuse me.

HEAD was at 3943f5cff6 and there were no other changes. Sharing the
failure here for visibility:

[11:09:56.548] stderr:
[11:09:56.548] #   Failed test 'stress-async: stdout matches'
[11:09:56.548] #   at
/tmp/cirrus-ci-build/src/test/modules/oauth_validator/t/001_server.pl
line 409.
[11:09:56.548] #                   ''
[11:09:56.548] #     doesn't match '(?^:connection succeeded)'
[11:09:56.548] #   Failed test 'stress-async: stderr matches'
[11:09:56.548] #   at
/tmp/cirrus-ci-build/src/test/modules/oauth_validator/t/001_server.pl
line 410.
[11:09:56.548] #                   '[libcurl] * Host localhost:39251
was resolved.
[11:09:56.548] # [libcurl] * IPv6: ::1
[11:09:56.548] # [libcurl] * IPv4: 127.0.0.1
[11:09:56.548] # [libcurl] *   Trying [::1]:39251...
[11:09:56.548] # [libcurl] * Immediate connect fail for ::1: Connection refused
[11:09:56.548] # [libcurl] *   Trying 127.0.0.1:39251...
[11:09:56.548] # [libcurl] * Connected to localhost (127.0.0.1) port 39251
[11:09:56.548] # [libcurl] * using HTTP/1.x
[11:09:56.548] # [libcurl] > GET
/param/.well-known/openid-configuration HTTP/1.1
[11:09:56.548] # [libcurl] > Host: localhost:39251
[11:09:56.548] # [libcurl] >
[11:09:56.548] # [libcurl] * Request completely sent off
[11:09:56.548] # [libcurl] * HTTP 1.0, assume close after body
[11:09:56.548] # [libcurl] < HTTP/1.0 200 OK
[11:09:56.548] # [libcurl] < Server: BaseHTTP/0.6 Python/3.11.11
[11:09:56.548] # [libcurl] < Date: Mon, 17 Mar 2025 11:09:55 GMT
[11:09:56.548] # [libcurl] < Content-Type: application/json
[11:09:56.548] # [libcurl] < Content-Length: 400
[11:09:56.548] # [libcurl] <
[11:09:56.548] # [libcurl] < {"issuer":
"http://localhost:39251/param";, "token_endpoint":
"http://localhost:39251/param/token";, "device_authorization_endpoint":
"http://localhost:39251/param/authorize";, "response_types_supported":
["token"], "subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["RS256"],
"grant_types_supported": ["authorization_code",
"urn:ietf:params:oauth:grant-type:device_code"]}
[11:09:56.548] # [libcurl] * shutting down connection #0
[11:09:56.548] # [libcurl] * Hostname localhost was found in DNS cache
[11:09:56.548] # [libcurl] *   Trying [::1]:39251...
[11:09:56.548] # [libcurl] * Immediate connect fail for ::1: Connection refused
[11:09:56.548] # [libcurl] *   Trying 127.0.0.1:39251...
[11:09:56.548] # [libcurl] * Connected to localhost (127.0.0.1) port 39251
[11:09:56.548] # [libcurl] * using HTTP/1.x
[11:09:56.548] # [libcurl] > POST /param/authorize HTTP/1.1
[11:09:56.548] # [libcurl] > Host: localhost:39251
[11:09:56.548] # [libcurl] > Content-Length: 92
[11:09:56.548] # [libcurl] > Content-Type: application/x-www-form-urlencoded
[11:09:56.548] # [libcurl] >
[11:09:56.548] # [libcurl] >
scope=openid+postgres&client_id=eyJpbnRlcnZhbCI6MSwicmV0cmllcyI6MSwic3RhZ2UiOiJhbGwifQ%3D%3D
[11:09:56.548] # [libcurl] * upload completely sent off: 92 bytes
[11:09:56.548] # [libcurl] * HTTP 1.0, assume close after body
[11:09:56.548] # [libcurl] < HTTP/1.0 200 OK
[11:09:56.548] # [libcurl] < Server: BaseHTTP/0.6 Python/3.11.11
[11:09:56.548] # [libcurl] < Date: Mon, 17 Mar 2025 11:09:55 GMT
[11:09:56.548] # [libcurl] < Content-Type: application/json
[11:09:56.548] # [libcurl] < Content-Length: 132
[11:09:56.548] # [libcurl] <
[11:09:56.548] # [libcurl] < {"device_code": "postgres", "user_code":
"postgresuser", "verification_uri": "https://example.com/";,
"expires_in": 5, "interval": 1}
[11:09:56.548] # [libcurl] * shutting down connection #1
[11:09:56.548] # [libcurl] * Hostname localhost was found in DNS cache
[11:09:56.548] # [libcurl] *   Trying [::1]:39251...
[11:09:56.548] # [libcurl] * Connected to localhost (::1) port 39251
[11:09:56.548] # [libcurl] * using HTTP/1.x
[11:09:56.548] # [libcurl] > POST /param/token HTTP/1.1
[11:09:56.548] # [libcurl] > Host: localhost:39251
[11:09:56.548] # [libcurl] > Content-Length: 157
[11:09:56.548] # [libcurl] > Content-Type: application/x-www-form-urlencoded
[11:09:56.548] # [libcurl] >
[11:09:56.548] # [libcurl] >
device_code=postgres&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code&client_id=eyJpbnRlcnZhbCI6MSwicmV0cmllcyI6MSwic3RhZ2UiOiJhbGwifQ%3D%3D
[11:09:56.548] # [libcurl] * upload completely sent off: 157 bytes
[11:09:56.548] # [libcurl] * Received HTTP/0.9 when not allowed
[11:09:56.548] # [libcurl] * closing connection #2
[11:09:56.548] # connection to database failed: connection to server
on socket "/tmp/xZKYtq40nL/.s.PGSQL.21400" failed: failed to obtain
access token: Unsupported protocol (libcurl: Received HTTP/0.9 when
not allowed)
[11:09:56.548] # '
[11:09:56.548] #           matches '(?^:connection to database failed)'
[11:09:56.548] # Looks like you failed 2 tests of 121.
[11:09:56.548]
[11:09:56.548] (test program exited with status code 2)

[1] https://cirrus-ci.com/task/4621590844932096

-- 
Regards,
Nazir Bilal Yavuz
Microsoft





^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-03-17 15:08  Jacob Champion <[email protected]>
  parent: Nazir Bilal Yavuz <[email protected]>
  0 siblings, 2 replies; 101+ messages in thread

From: Jacob Champion @ 2025-03-17 15:08 UTC (permalink / raw)
  To: Nazir Bilal Yavuz <[email protected]>; +Cc: Thomas Munro <[email protected]>; Andres Freund <[email protected]>; Daniel Gustafsson <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

On Mon, Mar 17, 2025 at 4:37 AM Nazir Bilal Yavuz <[email protected]> wrote:
>
> Hi,
>
> I just wanted to report that the 'oauth_validator/t/001_server.pl'
> test failed on FreeBSD in one of my local CI runs [1]. I looked at the
> thread but could not find the same error report; if this is already
> known, please excuse me.

Thanks for the report! Yes, this looks like the issue that NetBSD was having:

> [11:09:56.548] # [libcurl] *   Trying [::1]:39251...
> [11:09:56.548] # [libcurl] * Connected to localhost (::1) port 39251

Curl should not have connected to ::1 (the test server isn't listening
on IPv6). Whatever is talking on that port doesn't understand HTTP,
and we later fail with the "HTTP/0.9" error -- a slightly confusing
way to describe a protocol violation.

0001 will fix that. I think we should get that and 0002 in, ASAP. (And
the others.) Thomas has shown me a side quest to get rid of the second
kqueue instance, but so far that is not bearing fruit and we shouldn't
wait on it.

Thanks again!
--Jacob





^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-03-19 04:09  Thomas Munro <[email protected]>
  parent: Jacob Champion <[email protected]>
  1 sibling, 1 reply; 101+ messages in thread

From: Thomas Munro @ 2025-03-19 04:09 UTC (permalink / raw)
  To: Jacob Champion <[email protected]>; +Cc: Nazir Bilal Yavuz <[email protected]>; Andres Freund <[email protected]>; Daniel Gustafsson <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

On Tue, Mar 18, 2025 at 4:08 AM Jacob Champion
<[email protected]> wrote:
> 0001 will fix that. I think we should get that and 0002 in, ASAP. (And
> the others.)

All pushed (wasn't sure if Daniel was going to but once I got tangled
up in all that kqueue stuff he probably quite reasonably assumed that
I would :-)).

> Thomas has shown me a side quest to get rid of the second
> kqueue instance, but so far that is not bearing fruit and we shouldn't
> wait on it.

Cool, thanks for looking into it anyway.  (Feel free to post a
nonworking patch with a got-stuck-here-because problem statement...)





^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-03-19 04:17  Tom Lane <[email protected]>
  parent: Jacob Champion <[email protected]>
  1 sibling, 2 replies; 101+ messages in thread

From: Tom Lane @ 2025-03-19 04:17 UTC (permalink / raw)
  To: Thomas Munro <[email protected]>; +Cc: Jacob Champion <[email protected]>; Nazir Bilal Yavuz <[email protected]>; Andres Freund <[email protected]>; Daniel Gustafsson <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

Thomas Munro <[email protected]> writes:
> All pushed

You may have noticed it already, but indri reports that this
printf-like call isn't right:

fe-auth-oauth-curl.c:1392:49: error: data argument not used by format string [-Werror,-Wformat-extra-args]
 1392 |                 actx_error(actx, "deleting kqueue timer: %m", timeout);
      |                                  ~~~~~~~~~~~~~~~~~~~~~~~~~~~  ^
fe-auth-oauth-curl.c:324:59: note: expanded from macro 'actx_error'
  324 |         appendPQExpBuffer(&(ACTX)->errbuf, libpq_gettext(FMT), ##__VA_ARGS__)
      |                                                          ~~~     ^

"timeout" isn't being used anymore.

			regards, tom lane





^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-03-19 04:34  Thomas Munro <[email protected]>
  parent: Tom Lane <[email protected]>
  1 sibling, 1 reply; 101+ messages in thread

From: Thomas Munro @ 2025-03-19 04:34 UTC (permalink / raw)
  To: Tom Lane <[email protected]>; +Cc: Jacob Champion <[email protected]>; Nazir Bilal Yavuz <[email protected]>; Andres Freund <[email protected]>; Daniel Gustafsson <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

On Wed, Mar 19, 2025 at 5:17 PM Tom Lane <[email protected]> wrote:
> fe-auth-oauth-curl.c:1392:49: error: data argument not used by format string [-Werror,-Wformat-extra-args]
>  1392 |                 actx_error(actx, "deleting kqueue timer: %m", timeout);
>       |                                  ~~~~~~~~~~~~~~~~~~~~~~~~~~~  ^
> fe-auth-oauth-curl.c:324:59: note: expanded from macro 'actx_error'
>   324 |         appendPQExpBuffer(&(ACTX)->errbuf, libpq_gettext(FMT), ##__VA_ARGS__)
>       |                                                          ~~~     ^
>
> "timeout" isn't being used anymore.

Yeah.  Thanks, fixed.

Now I'm wondering about teaching CI to fail on compiler warnings, ie
not just the special warnings task but also in the Mac etc builds.
The reason it doesn't is because it's sort of annoying to stop the
main tests because of a format string snafu, but we must be able to
put a new step at the end after all tests that scans the build logs
for warning and then raises the alarm...





^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-03-19 04:57  Tom Lane <[email protected]>
  parent: Tom Lane <[email protected]>
  1 sibling, 2 replies; 101+ messages in thread

From: Tom Lane @ 2025-03-19 04:57 UTC (permalink / raw)
  To: Thomas Munro <[email protected]>; +Cc: Jacob Champion <[email protected]>; Nazir Bilal Yavuz <[email protected]>; Andres Freund <[email protected]>; Daniel Gustafsson <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

BTW, I was pretty seriously disheartened just now to realize that
this feature was implemented by making libpq depend on libcurl.
I'd misread the relevant commit messages to say that libcurl was
just being used as test infrastructure; but nope, it's a genuine
build and runtime dependency.  I wonder how much big-picture
thinking went into that.  I can see at least two objections:

* This represents a pretty large expansion of dependency footprint,
not just for us but for the umpteen hundred packages that depend on
libpq.  libcurl alone maybe wouldn't be so bad, but have you looked
at libcurl's dependencies?  On RHEL8,

$ ldd /usr/lib64/libcurl.so.4.5.0
        linux-vdso.so.1 (0x00007fffd3075000)
        libnghttp2.so.14 => /lib64/libnghttp2.so.14 (0x00007f992097a000)
        libidn2.so.0 => /lib64/libidn2.so.0 (0x00007f992075c000)
        libssh.so.4 => /lib64/libssh.so.4 (0x00007f99204ec000)
        libpsl.so.5 => /lib64/libpsl.so.5 (0x00007f99202db000)
        libssl.so.1.1 => /lib64/libssl.so.1.1 (0x00007f9920046000)
        libcrypto.so.1.1 => /lib64/libcrypto.so.1.1 (0x00007f991fb5b000)
        libgssapi_krb5.so.2 => /lib64/libgssapi_krb5.so.2 (0x00007f991f906000)
        libkrb5.so.3 => /lib64/libkrb5.so.3 (0x00007f991f61b000)
        libk5crypto.so.3 => /lib64/libk5crypto.so.3 (0x00007f991f404000)
        libcom_err.so.2 => /lib64/libcom_err.so.2 (0x00007f991f200000)
        libldap-2.4.so.2 => /lib64/libldap-2.4.so.2 (0x00007f991efb1000)
        liblber-2.4.so.2 => /lib64/liblber-2.4.so.2 (0x00007f991eda1000)
        libbrotlidec.so.1 => /lib64/libbrotlidec.so.1 (0x00007f991eb94000)
        libz.so.1 => /lib64/libz.so.1 (0x00007f991e97c000)
        libpthread.so.0 => /lib64/libpthread.so.0 (0x00007f991e75c000)
        libc.so.6 => /lib64/libc.so.6 (0x00007f991e386000)
        libunistring.so.2 => /lib64/libunistring.so.2 (0x00007f991e005000)
        librt.so.1 => /lib64/librt.so.1 (0x00007f991ddfd000)
        /lib64/ld-linux-x86-64.so.2 (0x00007f9920e30000)
        libdl.so.2 => /lib64/libdl.so.2 (0x00007f991dbf9000)
        libkrb5support.so.0 => /lib64/libkrb5support.so.0 (0x00007f991d9e8000)
        libkeyutils.so.1 => /lib64/libkeyutils.so.1 (0x00007f991d7e4000)
        libresolv.so.2 => /lib64/libresolv.so.2 (0x00007f991d5cc000)
        libsasl2.so.3 => /lib64/libsasl2.so.3 (0x00007f991d3ae000)
        libm.so.6 => /lib64/libm.so.6 (0x00007f991d02c000)
        libbrotlicommon.so.1 => /lib64/libbrotlicommon.so.1 (0x00007f991ce0b000)
        libselinux.so.1 => /lib64/libselinux.so.1 (0x00007f991cbe0000)
        libcrypt.so.1 => /lib64/libcrypt.so.1 (0x00007f991c9b7000)
        libpcre2-8.so.0 => /lib64/libpcre2-8.so.0 (0x00007f991c733000)

* Given libcurl's very squishy portfolio:

  libcurl is a free and easy-to-use client-side URL transfer library, supporting
  FTP, FTPS, HTTP, HTTPS, SCP, SFTP, TFTP, TELNET, DICT, LDAP, LDAPS, FILE, IMAP,
  SMTP, POP3 and RTSP. libcurl supports SSL certificates, HTTP POST, HTTP PUT,
  FTP uploading, HTTP form based upload, proxies, cookies, user+password
  authentication (Basic, Digest, NTLM, Negotiate, Kerberos4), file transfer
  resume, http proxy tunneling and more.

it's not exactly hard to imagine them growing a desire to handle
"postgresql://" URLs, which they would surely do by invoking libpq.
Then we'll have circular build dependencies and circular runtime
dependencies, not to mention inter-library recursion at runtime.


This is not quite a hill that I wish to die on, but I will
flatly predict that we will regret this.

			regards, tom lane





^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-03-19 13:31  Bruce Momjian <[email protected]>
  parent: Tom Lane <[email protected]>
  1 sibling, 0 replies; 101+ messages in thread

From: Bruce Momjian @ 2025-03-19 13:31 UTC (permalink / raw)
  To: Tom Lane <[email protected]>; +Cc: Thomas Munro <[email protected]>; Jacob Champion <[email protected]>; Nazir Bilal Yavuz <[email protected]>; Andres Freund <[email protected]>; Daniel Gustafsson <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

On Wed, Mar 19, 2025 at 12:57:29AM -0400, Tom Lane wrote:
> * Given libcurl's very squishy portfolio:
> 
>   libcurl is a free and easy-to-use client-side URL transfer library, supporting
>   FTP, FTPS, HTTP, HTTPS, SCP, SFTP, TFTP, TELNET, DICT, LDAP, LDAPS, FILE, IMAP,
>   SMTP, POP3 and RTSP. libcurl supports SSL certificates, HTTP POST, HTTP PUT,
>   FTP uploading, HTTP form based upload, proxies, cookies, user+password
>   authentication (Basic, Digest, NTLM, Negotiate, Kerberos4), file transfer
>   resume, http proxy tunneling and more.
> 
> it's not exactly hard to imagine them growing a desire to handle
> "postgresql://" URLs, which they would surely do by invoking libpq.
> Then we'll have circular build dependencies and circular runtime
> dependencies, not to mention inter-library recursion at runtime.
> 
> 
> This is not quite a hill that I wish to die on, but I will
> flatly predict that we will regret this.

I regularly see curl security fixes in my Debian updates, so there is a
security issue that any serious curl bug could also make Postgres
vulnerable.  I might be willing to die on that hill.

-- 
  Bruce Momjian  <[email protected]>        https://momjian.us
  EDB                                      https://enterprisedb.com

  Do not let urgent matters crowd out time for investment in the future.





^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-03-19 13:38  Daniel Gustafsson <[email protected]>
  parent: Tom Lane <[email protected]>
  1 sibling, 3 replies; 101+ messages in thread

From: Daniel Gustafsson @ 2025-03-19 13:38 UTC (permalink / raw)
  To: Tom Lane <[email protected]>; +Cc: Thomas Munro <[email protected]>; Jacob Champion <[email protected]>; Nazir Bilal Yavuz <[email protected]>; Andres Freund <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

> On 19 Mar 2025, at 05:57, Tom Lane <[email protected]> wrote:
> 
> BTW, I was pretty seriously disheartened just now to realize that
> this feature was implemented by making libpq depend on libcurl.
> I'd misread the relevant commit messages to say that libcurl was
> just being used as test infrastructure; but nope, it's a genuine
> build and runtime dependency.  I wonder how much big-picture
> thinking went into that.

A considerable amount. 

libcurl is not a dependency for OAuth support in libpq, the support was
designed to be exensible such that clients can hook in their own flow
implementations.  This part does not require libcurl.  It is however a
dependency for the RFC 8628 implementation which is included when building with
--with-libcurl, this in order to ship something which can be used out of the
box (for actual connections *and* testing) without clients being forced to
provide their own implementation.

This obviously means that the RFC8628 part could be moved to contrib/, but I
fear we wouldn't make life easier for packagers by doing that.

> * Given libcurl's very squishy portfolio:
>  ...
> it's not exactly hard to imagine them growing a desire to handle
> "postgresql://" URLs,

While there is no guarantee that such a pull request wont be submitted,
speaking as a (admittedly not very active at the moment) libcurl maintainer I
consider it highly unlikely that it would be accepted.  A postgres connnection
does not fit into what libcurl/curl is and wants to be.

--
Daniel Gustafsson






^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-03-19 13:46  Andres Freund <[email protected]>
  parent: Thomas Munro <[email protected]>
  0 siblings, 0 replies; 101+ messages in thread

From: Andres Freund @ 2025-03-19 13:46 UTC (permalink / raw)
  To: Thomas Munro <[email protected]>; +Cc: Tom Lane <[email protected]>; Jacob Champion <[email protected]>; Nazir Bilal Yavuz <[email protected]>; Daniel Gustafsson <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

Hi,

On 2025-03-19 17:34:18 +1300, Thomas Munro wrote:
> On Wed, Mar 19, 2025 at 5:17 PM Tom Lane <[email protected]> wrote:
> > fe-auth-oauth-curl.c:1392:49: error: data argument not used by format string [-Werror,-Wformat-extra-args]
> >  1392 |                 actx_error(actx, "deleting kqueue timer: %m", timeout);
> >       |                                  ~~~~~~~~~~~~~~~~~~~~~~~~~~~  ^
> > fe-auth-oauth-curl.c:324:59: note: expanded from macro 'actx_error'
> >   324 |         appendPQExpBuffer(&(ACTX)->errbuf, libpq_gettext(FMT), ##__VA_ARGS__)
> >       |                                                          ~~~     ^
> >
> > "timeout" isn't being used anymore.
> 
> Yeah.  Thanks, fixed.
> 
> Now I'm wondering about teaching CI to fail on compiler warnings, ie
> not just the special warnings task but also in the Mac etc builds.
> The reason it doesn't is because it's sort of annoying to stop the
> main tests because of a format string snafu, but we must be able to
> put a new step at the end after all tests that scans the build logs
> for warning and then raises the alarm...

The best way would probably be to tee the output of the build to a log file
and then have a script at the end to check for errors in that.

Greetings,

Andres Freund





^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-03-19 20:11  Thomas Munro <[email protected]>
  parent: Daniel Gustafsson <[email protected]>
  2 siblings, 0 replies; 101+ messages in thread

From: Thomas Munro @ 2025-03-19 20:11 UTC (permalink / raw)
  To: Daniel Gustafsson <[email protected]>; +Cc: Tom Lane <[email protected]>; Jacob Champion <[email protected]>; Nazir Bilal Yavuz <[email protected]>; Andres Freund <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

On Thu, Mar 20, 2025 at 2:38 AM Daniel Gustafsson <[email protected]> wrote:
> > On 19 Mar 2025, at 05:57, Tom Lane <[email protected]> wrote:
> >
> > BTW, I was pretty seriously disheartened just now to realize that
> > this feature was implemented by making libpq depend on libcurl.
> > I'd misread the relevant commit messages to say that libcurl was
> > just being used as test infrastructure; but nope, it's a genuine
> > build and runtime dependency.  I wonder how much big-picture
> > thinking went into that.
>
> A considerable amount.
>
> libcurl is not a dependency for OAuth support in libpq, the support was
> designed to be exensible such that clients can hook in their own flow
> implementations.  This part does not require libcurl.  It is however a
> dependency for the RFC 8628 implementation which is included when building with
> --with-libcurl, this in order to ship something which can be used out of the
> box (for actual connections *and* testing) without clients being forced to
> provide their own implementation.
>
> This obviously means that the RFC8628 part could be moved to contrib/, but I
> fear we wouldn't make life easier for packagers by doing that.

How feasible/fragile/weird would it be to dlopen() it on demand?
Looks like it'd take ~20 function pointers:

                 U curl_easy_cleanup
                 U curl_easy_escape
                 U curl_easy_getinfo
                 U curl_easy_init
                 U curl_easy_setopt
                 U curl_easy_strerror
                 U curl_free
                 U curl_global_init
                 U curl_multi_add_handle
                 U curl_multi_cleanup
                 U curl_multi_info_read
                 U curl_multi_init
                 U curl_multi_remove_handle
                 U curl_multi_setopt
                 U curl_multi_socket_action
                 U curl_multi_socket_all
                 U curl_multi_strerror
                 U curl_slist_append
                 U curl_slist_free_all
                 U curl_version_info





^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-03-19 21:03  Tom Lane <[email protected]>
  parent: Daniel Gustafsson <[email protected]>
  2 siblings, 1 reply; 101+ messages in thread

From: Tom Lane @ 2025-03-19 21:03 UTC (permalink / raw)
  To: Thomas Munro <[email protected]>; +Cc: Daniel Gustafsson <[email protected]>; Jacob Champion <[email protected]>; Nazir Bilal Yavuz <[email protected]>; Andres Freund <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

Thomas Munro <[email protected]> writes:
> On Thu, Mar 20, 2025 at 2:38 AM Daniel Gustafsson <[email protected]> wrote:
>> On 19 Mar 2025, at 05:57, Tom Lane <[email protected]> wrote:
>>> BTW, I was pretty seriously disheartened just now to realize that
>>> this feature was implemented by making libpq depend on libcurl.

> How feasible/fragile/weird would it be to dlopen() it on demand?

FWIW, that would not really move the needle one bit so far as
my worries are concerned.  What I'm unhappy about is the very
sizable expansion of our build dependency footprint as well
as the sizable expansion of the 'package requires' footprint.
The fact that the new dependencies are mostly indirect doesn't
soften that blow at all.

To address that (without finding some less kitchen-sink-y OAuth
implementation to depend on), we'd need to shove the whole thing
into a separately-built, separately-installable package.

What I expect is likely to happen is that packagers will try to do
that themselves to avoid the dependency bloat.  AFAICT our current
setup will make that quite painful for them, and in any case I
don't believe it's work we should make them do.  If they fail to
do that, the burden of the extra dependencies will fall on end
users.  Either way, it's not going to make us look good.

			regards, tom lane





^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-03-19 22:02  Thomas Munro <[email protected]>
  parent: Tom Lane <[email protected]>
  0 siblings, 2 replies; 101+ messages in thread

From: Thomas Munro @ 2025-03-19 22:02 UTC (permalink / raw)
  To: Tom Lane <[email protected]>; +Cc: Daniel Gustafsson <[email protected]>; Jacob Champion <[email protected]>; Nazir Bilal Yavuz <[email protected]>; Andres Freund <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

On Thu, Mar 20, 2025 at 10:04 AM Tom Lane <[email protected]> wrote:
> Thomas Munro <[email protected]> writes:
> > How feasible/fragile/weird would it be to dlopen() it on demand?
>
> FWIW, that would not really move the needle one bit so far as
> my worries are concerned.  What I'm unhappy about is the very
> sizable expansion of our build dependency footprint as well
> as the sizable expansion of the 'package requires' footprint.
> The fact that the new dependencies are mostly indirect doesn't
> soften that blow at all.
>
> To address that (without finding some less kitchen-sink-y OAuth
> implementation to depend on), we'd need to shove the whole thing
> into a separately-built, separately-installable package.
>
> What I expect is likely to happen is that packagers will try to do
> that themselves to avoid the dependency bloat.  AFAICT our current
> setup will make that quite painful for them, and in any case I
> don't believe it's work we should make them do.  If they fail to
> do that, the burden of the extra dependencies will fall on end
> users.  Either way, it's not going to make us look good.

It would increase the build dependencies, assuming a package
maintainer wants to enable as many features as possible, but it would
*not* increase the 'package requires' footprint, merely the 'package
suggests' footprint (as Debian calls it), and it's up to the user
whether they install suggested extra packages, no?





^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-03-19 22:14  Thomas Munro <[email protected]>
  parent: Thomas Munro <[email protected]>
  1 sibling, 0 replies; 101+ messages in thread

From: Thomas Munro @ 2025-03-19 22:14 UTC (permalink / raw)
  To: Tom Lane <[email protected]>; +Cc: Daniel Gustafsson <[email protected]>; Jacob Champion <[email protected]>; Nazir Bilal Yavuz <[email protected]>; Andres Freund <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

On Thu, Mar 20, 2025 at 11:02 AM Thomas Munro <[email protected]> wrote:
> On Thu, Mar 20, 2025 at 10:04 AM Tom Lane <[email protected]> wrote:
> > Thomas Munro <[email protected]> writes:
> > > How feasible/fragile/weird would it be to dlopen() it on demand?

. o O { There may also be security reasons to reject the idea, would
need to look into that... }





^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-03-19 22:19  Tom Lane <[email protected]>
  parent: Thomas Munro <[email protected]>
  1 sibling, 1 reply; 101+ messages in thread

From: Tom Lane @ 2025-03-19 22:19 UTC (permalink / raw)
  To: Thomas Munro <[email protected]>; +Cc: Daniel Gustafsson <[email protected]>; Jacob Champion <[email protected]>; Nazir Bilal Yavuz <[email protected]>; Andres Freund <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

Thomas Munro <[email protected]> writes:
> It would increase the build dependencies, assuming a package
> maintainer wants to enable as many features as possible, but it would
> *not* increase the 'package requires' footprint, merely the 'package
> suggests' footprint (as Debian calls it), and it's up to the user
> whether they install suggested extra packages, no?

Maybe I'm confused, but what I saw was a hard dependency on libcurl,
as well as several of its dependencies:

$ ./configure --with-libcurl
...
$ make
...
$ ldd src/interfaces/libpq/libpq.so.5.18 
        linux-vdso.so.1 (0x00007ffc145fe000)
        libcurl.so.4 => /lib64/libcurl.so.4 (0x00007f2c2fa36000)
        libm.so.6 => /lib64/libm.so.6 (0x00007f2c2f95b000)
        libc.so.6 => /lib64/libc.so.6 (0x00007f2c2f600000)
        libnghttp2.so.14 => /lib64/libnghttp2.so.14 (0x00007f2c2f931000)
        libidn2.so.0 => /lib64/libidn2.so.0 (0x00007f2c2f910000)
        libssh.so.4 => /lib64/libssh.so.4 (0x00007f2c2f89b000)
        libpsl.so.5 => /lib64/libpsl.so.5 (0x00007f2c2f885000)
        libssl.so.3 => /lib64/libssl.so.3 (0x00007f2c2f51a000)
        libcrypto.so.3 => /lib64/libcrypto.so.3 (0x00007f2c2f000000)
        libgssapi_krb5.so.2 => /lib64/libgssapi_krb5.so.2 (0x00007f2c2f82f000)
        libkrb5.so.3 => /lib64/libkrb5.so.3 (0x00007f2c2ef26000)
        libk5crypto.so.3 => /lib64/libk5crypto.so.3 (0x00007f2c2f816000)
        libcom_err.so.2 => /lib64/libcom_err.so.2 (0x00007f2c2f80d000)
        libldap.so.2 => /lib64/libldap.so.2 (0x00007f2c2eebf000)
        liblber.so.2 => /lib64/liblber.so.2 (0x00007f2c2eead000)
        libbrotlidec.so.1 => /lib64/libbrotlidec.so.1 (0x00007f2c2ee9f000)
        libz.so.1 => /lib64/libz.so.1 (0x00007f2c2ee85000)
        /lib64/ld-linux-x86-64.so.2 (0x00007f2c2fb43000)
        libunistring.so.2 => /lib64/libunistring.so.2 (0x00007f2c2ed00000)
        libkrb5support.so.0 => /lib64/libkrb5support.so.0 (0x00007f2c2ecef000)
        libkeyutils.so.1 => /lib64/libkeyutils.so.1 (0x00007f2c2ece8000)
        libresolv.so.2 => /lib64/libresolv.so.2 (0x00007f2c2ecd4000)
        libevent-2.1.so.7 => /lib64/libevent-2.1.so.7 (0x00007f2c2ec7b000)
        libsasl2.so.3 => /lib64/libsasl2.so.3 (0x00007f2c2ec5b000)
        libbrotlicommon.so.1 => /lib64/libbrotlicommon.so.1 (0x00007f2c2ec38000)
        libselinux.so.1 => /lib64/libselinux.so.1 (0x00007f2c2ec0b000)
        libcrypt.so.2 => /lib64/libcrypt.so.2 (0x00007f2c2ebd1000)
        libpcre2-8.so.0 => /lib64/libpcre2-8.so.0 (0x00007f2c2eb35000)

I don't think that will be satisfied by 'package suggests'.
Even if it somehow manages to load, the result of trying to
use OAuth would be a segfault rather than any useful message.

			regards, tom lane





^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-03-19 22:28  Thomas Munro <[email protected]>
  parent: Tom Lane <[email protected]>
  0 siblings, 1 reply; 101+ messages in thread

From: Thomas Munro @ 2025-03-19 22:28 UTC (permalink / raw)
  To: Tom Lane <[email protected]>; +Cc: Daniel Gustafsson <[email protected]>; Jacob Champion <[email protected]>; Nazir Bilal Yavuz <[email protected]>; Andres Freund <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

On Thu, Mar 20, 2025 at 11:19 AM Tom Lane <[email protected]> wrote:
> Thomas Munro <[email protected]> writes:
> > It would increase the build dependencies, assuming a package
> > maintainer wants to enable as many features as possible, but it would
> > *not* increase the 'package requires' footprint, merely the 'package
> > suggests' footprint (as Debian calls it), and it's up to the user
> > whether they install suggested extra packages, no?
>
> Maybe I'm confused, but what I saw was a hard dependency on libcurl,
> as well as several of its dependencies:

> I don't think that will be satisfied by 'package suggests'.
> Even if it somehow manages to load, the result of trying to
> use OAuth would be a segfault rather than any useful message.

I was imagining that it would just error out if you try to use that
stuff and it fails to open libcurl.  Then it's up to end users: if
they want to use libpq + OAuth, they have to install both libpq5 and
libcurl packages, and if they don't their connections will just fail,
presumably with some error message explaining why.  Or something like
that...





^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-03-19 23:11  Bruce Momjian <[email protected]>
  parent: Thomas Munro <[email protected]>
  0 siblings, 0 replies; 101+ messages in thread

From: Bruce Momjian @ 2025-03-19 23:11 UTC (permalink / raw)
  To: Thomas Munro <[email protected]>; +Cc: Tom Lane <[email protected]>; Daniel Gustafsson <[email protected]>; Jacob Champion <[email protected]>; Nazir Bilal Yavuz <[email protected]>; Andres Freund <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

On Thu, Mar 20, 2025 at 11:28:50AM +1300, Thomas Munro wrote:
> On Thu, Mar 20, 2025 at 11:19 AM Tom Lane <[email protected]> wrote:
> > Thomas Munro <[email protected]> writes:
> > > It would increase the build dependencies, assuming a package
> > > maintainer wants to enable as many features as possible, but it would
> > > *not* increase the 'package requires' footprint, merely the 'package
> > > suggests' footprint (as Debian calls it), and it's up to the user
> > > whether they install suggested extra packages, no?
> >
> > Maybe I'm confused, but what I saw was a hard dependency on libcurl,
> > as well as several of its dependencies:
> 
> > I don't think that will be satisfied by 'package suggests'.
> > Even if it somehow manages to load, the result of trying to
> > use OAuth would be a segfault rather than any useful message.
> 
> I was imagining that it would just error out if you try to use that
> stuff and it fails to open libcurl.  Then it's up to end users: if
> they want to use libpq + OAuth, they have to install both libpq5 and
> libcurl packages, and if they don't their connections will just fail,
> presumably with some error message explaining why.  Or something like
> that...

Am I understanding that curl is being used just to honor the RFC and it
is only for testing?  That seems like a small reason to add such a
dependency.

-- 
  Bruce Momjian  <[email protected]>        https://momjian.us
  EDB                                      https://enterprisedb.com

  Do not let urgent matters crowd out time for investment in the future.





^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-03-19 23:59  Bruce Momjian <[email protected]>
  parent: Daniel Gustafsson <[email protected]>
  2 siblings, 1 reply; 101+ messages in thread

From: Bruce Momjian @ 2025-03-19 23:59 UTC (permalink / raw)
  To: Daniel Gustafsson <[email protected]>; +Cc: Tom Lane <[email protected]>; Thomas Munro <[email protected]>; Jacob Champion <[email protected]>; Nazir Bilal Yavuz <[email protected]>; Andres Freund <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

On Wed, Mar 19, 2025 at 02:38:08PM +0100, Daniel Gustafsson wrote:
> > On 19 Mar 2025, at 05:57, Tom Lane <[email protected]> wrote:
> > 
> > BTW, I was pretty seriously disheartened just now to realize that
> > this feature was implemented by making libpq depend on libcurl.
> > I'd misread the relevant commit messages to say that libcurl was
> > just being used as test infrastructure; but nope, it's a genuine
> > build and runtime dependency.  I wonder how much big-picture
> > thinking went into that.
> 
> A considerable amount. 
> 
> libcurl is not a dependency for OAuth support in libpq, the support was
> designed to be exensible such that clients can hook in their own flow
> implementations.  This part does not require libcurl.  It is however a
> dependency for the RFC 8628 implementation which is included when building with
> --with-libcurl, this in order to ship something which can be used out of the
> box (for actual connections *and* testing) without clients being forced to
> provide their own implementation.
> 
> This obviously means that the RFC8628 part could be moved to contrib/, but I
> fear we wouldn't make life easier for packagers by doing that.

I see it now ---- without having RFC 8628 built into the server, clients
have to implement it.  Do we know what percentage would need to do that?
The spec:

	https://datatracker.ietf.org/doc/html/rfc8628

Do we think packagers will use the --with-libcurl configure option?

It does kind of make sense for curl to handle OAUTH since curl has to
simulate a browser.  I assume we can't call a shell to invoke curl from
the command line.

-- 
  Bruce Momjian  <[email protected]>        https://momjian.us
  EDB                                      https://enterprisedb.com

  Do not let urgent matters crowd out time for investment in the future.





^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-03-20 20:33  Jacob Champion <[email protected]>
  parent: Bruce Momjian <[email protected]>
  0 siblings, 1 reply; 101+ messages in thread

From: Jacob Champion @ 2025-03-20 20:33 UTC (permalink / raw)
  To: Bruce Momjian <[email protected]>; pgsql-hackers; Tom Lane <[email protected]>; +Cc: Daniel Gustafsson <[email protected]>; Thomas Munro <[email protected]>; Nazir Bilal Yavuz <[email protected]>; Andres Freund <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>

Hi all,

With the understanding that the patchset is no longer just "my" baby...

= Dependencies =

I like seeing risk/reward discussions. I agonized over the choice of
HTTP dependency, and I transitioned from an "easier" OAuth library
over to Curl early on because of the same tradeoffs.

That said... Tom, I think the dependency list you've presented is not
quite fair, because it doesn't show what libpq's dependency list was
before adding Curl. From your email, and my local Rocky 9 install, I
think these are the net-new dependencies that we (and packagers) need
to worry about:

        libcurl.so.4
        libnghttp2.so.14
        libidn2.so.0
        libssh.so.4
        libpsl.so.5
        libunistring.so.2
        libbrotlidec.so.1
        libbrotlicommon.so.1
        libz.so.1

That's more than I'd like, to be perfectly honest. I'm least happy
about libssh, because we're not using SFTP but we have to pay for it.
And the Deb-alikes add librtmp, which I'm not thrilled about either.

The rest are, IMO, natural dependencies of a mature HTTP client: the
HTTP/1 and HTTP/2 engines, Punycode, the Public Suffix List, UTF
handling, and common response compression types. Those are kind of
part and parcel of communicating on the web. (If we find an HTTP
client that does all those things itself, awesome, but then we have to
ask how well they did it.)

So one question for the collective is -- putting Curl itself aside --
is having a basic-but-usable OAuth flow, out of the box, worth the
costs of a generic HTTP client? A non-trivial footprint *will* be
there, whether it's one library or several, whether we delay-load it
or not, whether we have the unused SFTP/RTMP dependencies or not. But
we could still find ways to reduce that cost for people who aren't
using it, if necessary.

= Asides =

I would also like to point out: End users opt into this by
preregistering a client ID with an OAuth issuer ID, then providing
that pair of IDs in the connection string. We will not just start
crawling the web because a server tells us to. I don't want to
downplay the additional risk of having it in the address space, but
the design goal is that vulnerabilities in the HTTP logic should not
affect users who have not explicitly consented to the use of OAuth.

There were some other questions/statements made upthread that I want
to clarify too:

On Wed, Mar 19, 2025 at 4:11 PM Bruce Momjian <[email protected]> wrote:
> Am I understanding that curl is being used just to honor the RFC and it
> is only for testing?

No. (I see you found it later, but to state clearly for the record:
it's not just for testing.) libcurl is used for the Device
Authorization flow implementation. You don't have to use Device
Authorization to use OAuth, but we don't provide any alternative flows
in-tree; you'd have to use the libpq API to insert your own flow.

> I see it now ---- without having RFC 8628 built into the server,

(libpq, not the server. We do not ship server-side plugins at all, yet.)

> clients
> have to implement it.  Do we know what percentage would need to do that?

For version 1 of the feature, Device Authorization is the only option
for our utilities (psql et al). I can't really speculate on
percentages; it depends on what percentage want to use OAuth and don't
like (or can't use) our builtin flow. Obviously the percentage goes up
to 100% if we don't provide one. Plus we lose significant testability,
plus no one can use it from psql.

> Do we think packagers will use the --with-libcurl configure option?

Well, hopefully, yes. The tradeoffs of the builtin flow were chosen
explicitly so that existing clients could use it with minimal-to-no
code changes.

Thanks!
--Jacob





^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-03-20 20:50  Bruce Momjian <[email protected]>
  parent: Jacob Champion <[email protected]>
  0 siblings, 1 reply; 101+ messages in thread

From: Bruce Momjian @ 2025-03-20 20:50 UTC (permalink / raw)
  To: Jacob Champion <[email protected]>; +Cc: pgsql-hackers; Tom Lane <[email protected]>; Daniel Gustafsson <[email protected]>; Thomas Munro <[email protected]>; Nazir Bilal Yavuz <[email protected]>; Andres Freund <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>

On Thu, Mar 20, 2025 at 01:33:26PM -0700, Jacob Champion wrote:
> That's more than I'd like, to be perfectly honest. I'm least happy
> about libssh, because we're not using SFTP but we have to pay for it.
> And the Deb-alikes add librtmp, which I'm not thrilled about either.
> 
> The rest are, IMO, natural dependencies of a mature HTTP client: the
> HTTP/1 and HTTP/2 engines, Punycode, the Public Suffix List, UTF
> handling, and common response compression types. Those are kind of
> part and parcel of communicating on the web. (If we find an HTTP
> client that does all those things itself, awesome, but then we have to
> ask how well they did it.)
> 
> So one question for the collective is -- putting Curl itself aside --
> is having a basic-but-usable OAuth flow, out of the box, worth the
> costs of a generic HTTP client? A non-trivial footprint *will* be
> there, whether it's one library or several, whether we delay-load it
> or not, whether we have the unused SFTP/RTMP dependencies or not. But
> we could still find ways to reduce that cost for people who aren't
> using it, if necessary.

One observation is that security scanning tools are going to see the
curl dependency and look at any CSVs related to them and ask us, whether
they are using OAUTH or not.

-- 
  Bruce Momjian  <[email protected]>        https://momjian.us
  EDB                                      https://enterprisedb.com

  Do not let urgent matters crowd out time for investment in the future.





^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-03-20 21:08  Tom Lane <[email protected]>
  parent: Bruce Momjian <[email protected]>
  0 siblings, 1 reply; 101+ messages in thread

From: Tom Lane @ 2025-03-20 21:08 UTC (permalink / raw)
  To: Bruce Momjian <[email protected]>; +Cc: Jacob Champion <[email protected]>; pgsql-hackers; Daniel Gustafsson <[email protected]>; Thomas Munro <[email protected]>; Nazir Bilal Yavuz <[email protected]>; Andres Freund <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>

Bruce Momjian <[email protected]> writes:
> On Thu, Mar 20, 2025 at 01:33:26PM -0700, Jacob Champion wrote:
>> So one question for the collective is -- putting Curl itself aside --
>> is having a basic-but-usable OAuth flow, out of the box, worth the
>> costs of a generic HTTP client?

> One observation is that security scanning tools are going to see the
> curl dependency and look at any CSVs related to them and ask us, whether
> they are using OAUTH or not.

Yes.  Also, none of this has addressed my complaint about the extent
of the build and install dependencies.  Yes, simply not selecting
--with-libcurl removes the problem ... but most packagers are under
very heavy pressure to enable all features of a package.



^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-03-20 23:26  Andres Freund <[email protected]>
  parent: Tom Lane <[email protected]>
  0 siblings, 2 replies; 101+ messages in thread

From: Andres Freund @ 2025-03-20 23:26 UTC (permalink / raw)
  To: Tom Lane <[email protected]>; +Cc: Bruce Momjian <[email protected]>; Jacob Champion <[email protected]>; pgsql-hackers; Daniel Gustafsson <[email protected]>; Thomas Munro <[email protected]>; Nazir Bilal Yavuz <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>

Hi,

On 2025-03-20 17:08:54 -0400, Tom Lane wrote:
> Bruce Momjian <[email protected]> writes:
> > On Thu, Mar 20, 2025 at 01:33:26PM -0700, Jacob Champion wrote:
> >> So one question for the collective is -- putting Curl itself aside --
> >> is having a basic-but-usable OAuth flow, out of the box, worth the
> >> costs of a generic HTTP client?
> 
> > One observation is that security scanning tools are going to see the
> > curl dependency and look at any CSVs related to them and ask us, whether
> > they are using OAUTH or not.
> 
> Yes.  Also, none of this has addressed my complaint about the extent
> of the build and install dependencies.  Yes, simply not selecting
> --with-libcurl removes the problem ... but most packagers are under
> very heavy pressure to enable all features of a package.

How about we provide the current libpq.so without linking to curl and also a
libpq-oauth.so that has curl support? If we do it right libpq-oauth.so would
itself link to libpq.so, making libpq-oauth.so a fairly small library.

That way packagers can split libpq-oauth.so into a separate package, while
still just building once.

That'd be a bit of work on the buildsystem side, but it seems doable.


> From what's been said here, only a small minority of users are likely
> to have any interest in this feature.  So my answer to "is it worth
> the cost" is no, and would be no even if I had a lower estimate of
> the costs.

I think this is likely going to be rather widely used, way more widely than
e.g. kerberos or ldap support in libpq. My understanding is that there's a
fair bit of pressure in lots of companies to centralize authentication towards
centralized systems, even for server applications.


> I don't have any problem with making a solution available to those
> users who want it --- but I really do NOT want this to be part of
> stock libpq nor done as part of the core Postgres build.  I do not
> think that the costs of that have been fully accounted for, especially
> not the fact that almost all of those costs fall on people other than
> us.

I am on board with not having it as part of stock libpq, but I don't see what
we gain by not building it as part of postgres (if the dependencies are
available, of course).

Greetings,

Andres Freund





^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-03-21 12:40  Andrew Dunstan <[email protected]>
  parent: Andres Freund <[email protected]>
  1 sibling, 1 reply; 101+ messages in thread

From: Andrew Dunstan @ 2025-03-21 12:40 UTC (permalink / raw)
  To: Andres Freund <[email protected]>; Tom Lane <[email protected]>; +Cc: Bruce Momjian <[email protected]>; Jacob Champion <[email protected]>; pgsql-hackers; Daniel Gustafsson <[email protected]>; Thomas Munro <[email protected]>; Nazir Bilal Yavuz <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>


On 2025-03-20 Th 7:26 PM, Andres Freund wrote:
> Hi,
>
> On 2025-03-20 17:08:54 -0400, Tom Lane wrote:
>> Bruce Momjian <[email protected]> writes:
>>> On Thu, Mar 20, 2025 at 01:33:26PM -0700, Jacob Champion wrote:
>>>> So one question for the collective is -- putting Curl itself aside --
>>>> is having a basic-but-usable OAuth flow, out of the box, worth the
>>>> costs of a generic HTTP client?
>>> One observation is that security scanning tools are going to see the
>>> curl dependency and look at any CSVs related to them and ask us, whether
>>> they are using OAUTH or not.
>> Yes.  Also, none of this has addressed my complaint about the extent
>> of the build and install dependencies.  Yes, simply not selecting
>> --with-libcurl removes the problem ... but most packagers are under
>> very heavy pressure to enable all features of a package.
> How about we provide the current libpq.so without linking to curl and also a
> libpq-oauth.so that has curl support? If we do it right libpq-oauth.so would
> itself link to libpq.so, making libpq-oauth.so a fairly small library.
>
> That way packagers can split libpq-oauth.so into a separate package, while
> still just building once.
>
> That'd be a bit of work on the buildsystem side, but it seems doable.
>

That certainly seems worth exploring.


>>  From what's been said here, only a small minority of users are likely
>> to have any interest in this feature.  So my answer to "is it worth
>> the cost" is no, and would be no even if I had a lower estimate of
>> the costs.
> I think this is likely going to be rather widely used, way more widely than
> e.g. kerberos or ldap support in libpq. My understanding is that there's a
> fair bit of pressure in lots of companies to centralize authentication towards
> centralized systems, even for server applications.


Indeed. There is still work to do on OAUTH2 but the demand you mention 
is just going to keep increasing.


>
>
>> I don't have any problem with making a solution available to those
>> users who want it --- but I really do NOT want this to be part of
>> stock libpq nor done as part of the core Postgres build.  I do not
>> think that the costs of that have been fully accounted for, especially
>> not the fact that almost all of those costs fall on people other than
>> us.
> I am on board with not having it as part of stock libpq, but I don't see what
> we gain by not building it as part of postgres (if the dependencies are
> available, of course).
>

+1.


cheers


andrew

--
Andrew Dunstan
EDB: https://www.enterprisedb.com






^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-03-21 18:02  Daniel Gustafsson <[email protected]>
  parent: Andrew Dunstan <[email protected]>
  0 siblings, 1 reply; 101+ messages in thread

From: Daniel Gustafsson @ 2025-03-21 18:02 UTC (permalink / raw)
  To: Andrew Dunstan <[email protected]>; +Cc: Andres Freund <[email protected]>; Tom Lane <[email protected]>; Bruce Momjian <[email protected]>; Jacob Champion <[email protected]>; pgsql-hackers; Thomas Munro <[email protected]>; Nazir Bilal Yavuz <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>

> On 21 Mar 2025, at 13:40, Andrew Dunstan <[email protected]> wrote:
> On 2025-03-20 Th 7:26 PM, Andres Freund wrote:

>> How about we provide the current libpq.so without linking to curl and also a
>> libpq-oauth.so that has curl support? If we do it right libpq-oauth.so would
>> itself link to libpq.so, making libpq-oauth.so a fairly small library.
>> 
>> That way packagers can split libpq-oauth.so into a separate package, while
>> still just building once.
>> 
>> That'd be a bit of work on the buildsystem side, but it seems doable.
> 
> That certainly seems worth exploring.

This is being worked on.

--
Daniel Gustafsson






^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-03-26 19:09  Jacob Champion <[email protected]>
  parent: Daniel Gustafsson <[email protected]>
  0 siblings, 0 replies; 101+ messages in thread

From: Jacob Champion @ 2025-03-26 19:09 UTC (permalink / raw)
  To: Daniel Gustafsson <[email protected]>; +Cc: Andrew Dunstan <[email protected]>; Andres Freund <[email protected]>; Tom Lane <[email protected]>; Bruce Momjian <[email protected]>; pgsql-hackers; Thomas Munro <[email protected]>; Nazir Bilal Yavuz <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>

On Fri, Mar 21, 2025 at 11:02 AM Daniel Gustafsson <[email protected]> wrote:
> >> How about we provide the current libpq.so without linking to curl and also a
> >> libpq-oauth.so that has curl support? If we do it right libpq-oauth.so would
> >> itself link to libpq.so, making libpq-oauth.so a fairly small library.
> >>
> >> That way packagers can split libpq-oauth.so into a separate package, while
> >> still just building once.
> >>
> >> That'd be a bit of work on the buildsystem side, but it seems doable.
> >
> > That certainly seems worth exploring.
>
> This is being worked on.

Attached is a proof of concept, with code from Daniel and myself,
which passes the CI as a starting point.

Roughly speaking, some things to debate are
- the module API itself
- how much to duplicate from libpq vs how much to export
- is this even what you had in mind

libpq-oauth.so is dlopen'd when needed. If it's not found or it
doesn't have the right symbols, builtin OAuth will not happen. Right
now we have an SO version of 1; maybe we want to remove the SO version
entirely to better indicate that it shouldn't be linked?

Two symbols are exported for the async authentication callbacks. Since
the module understands PGconn internals, maybe we could simplify this
to a single callback that manipulates the connection directly.

To keep the diff small to start, the current patch probably exports
too much. I think appendPQExpBufferVA makes sense, considering we
export much of the PQExpBuffer API already, but I imagine we won't
want to expose the pg_g_threadlock symbol. (libpq could maybe push
that pointer into the libpq-oauth module at load time, instead of
having the module pull it.) And we could probably go either way with
the PQauthDataHook; I prefer having a getter and setter for future
flexibility, but it would be simpler to just export the hook directly.

The following functions are duplicated from libpq:
- libpq_block_sigpipe
- libpq_reset_sigpipe
- libpq_binddomain
- libpq_[n]gettext
- libpq_append_conn_error
- oauth_unsafe_debugging_enabled

Those don't seem too bad to me, though maybe there's a good way to
deduplicate. But i18n needs further work. It builds right now, but I
don't think it works yet.

WDYT?

Thanks,
--Jacob


Attachments:

  [application/octet-stream] 0001-WIP-split-Device-Authorization-flow-into-dlopen-d-mo.patch (20.8K, ../../CAOYmi+=PkcF8DRw49Jp-9AZDobahOHwnH2p0snYPsv94x==3oA@mail.gmail.com/2-0001-WIP-split-Device-Authorization-flow-into-dlopen-d-mo.patch)
  download | inline diff:
From 47fc34de68fe61b796f532f755a17331dff111e3 Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Wed, 26 Mar 2025 10:55:28 -0700
Subject: [PATCH] WIP: split Device Authorization flow into dlopen'd module

See notes on mailing list.

Co-authored-by: Daniel Gustafsson <[email protected]>
---
 meson.build                                   |  12 +-
 src/interfaces/Makefile                       |   9 +
 src/interfaces/libpq-oauth/Makefile           |  53 +++++
 src/interfaces/libpq-oauth/exports.txt        |   3 +
 .../fe-auth-oauth-curl.c                      | 187 +++++++++++++++++-
 .../libpq-oauth/fe-auth-oauth-curl.h          |  23 +++
 src/interfaces/libpq-oauth/meson.build        |  64 ++++++
 src/interfaces/libpq-oauth/po/meson.build     |   3 +
 src/interfaces/libpq/Makefile                 |   4 -
 src/interfaces/libpq/exports.txt              |   3 +
 src/interfaces/libpq/fe-auth-oauth.c          |  52 ++++-
 src/interfaces/libpq/fe-auth-oauth.h          |   4 +-
 src/interfaces/libpq/fe-auth.h                |   3 -
 src/interfaces/libpq/libpq-fe.h               |   1 +
 src/interfaces/libpq/meson.build              |   4 -
 15 files changed, 389 insertions(+), 36 deletions(-)
 create mode 100644 src/interfaces/libpq-oauth/Makefile
 create mode 100644 src/interfaces/libpq-oauth/exports.txt
 rename src/interfaces/{libpq => libpq-oauth}/fe-auth-oauth-curl.c (94%)
 create mode 100644 src/interfaces/libpq-oauth/fe-auth-oauth-curl.h
 create mode 100644 src/interfaces/libpq-oauth/meson.build
 create mode 100644 src/interfaces/libpq-oauth/po/meson.build

diff --git a/meson.build b/meson.build
index 7cf518a2765..69e91529259 100644
--- a/meson.build
+++ b/meson.build
@@ -107,6 +107,7 @@ os_deps = []
 backend_both_deps = []
 backend_deps = []
 libpq_deps = []
+libpq_oauth_deps = []
 
 pg_sysroot = ''
 
@@ -3136,17 +3137,18 @@ libpq_deps += [
 
   gssapi,
   ldap_r,
-  # XXX libcurl must link after libgssapi_krb5 on FreeBSD to avoid segfaults
-  # during gss_acquire_cred(). This is possibly related to Curl's Heimdal
-  # dependency on that platform?
-  libcurl,
   libintl,
   ssl,
 ]
 
+libpq_oauth_deps += [
+  libcurl,
+]
+
 subdir('src/interfaces/libpq')
-# fe_utils depends on libpq
+# fe_utils and libpq-oauth depends on libpq
 subdir('src/fe_utils')
+subdir('src/interfaces/libpq-oauth')
 
 # for frontend binaries
 frontend_code = declare_dependency(
diff --git a/src/interfaces/Makefile b/src/interfaces/Makefile
index 7d56b29d28f..322a498823d 100644
--- a/src/interfaces/Makefile
+++ b/src/interfaces/Makefile
@@ -14,7 +14,16 @@ include $(top_builddir)/src/Makefile.global
 
 SUBDIRS = libpq ecpg
 
+ifeq ($(with_libcurl), yes)
+SUBDIRS += libpq-oauth
+endif
+
 $(recurse)
 
 all-ecpg-recurse: all-libpq-recurse
 install-ecpg-recurse: install-libpq-recurse
+
+ifeq ($(with_libcurl), yes)
+all-libpq-oauth-recurse: all-libpq-recurse
+install-libpq-oauth-recurse: install-libpq-recurse
+endif
diff --git a/src/interfaces/libpq-oauth/Makefile b/src/interfaces/libpq-oauth/Makefile
new file mode 100644
index 00000000000..d623a4157e6
--- /dev/null
+++ b/src/interfaces/libpq-oauth/Makefile
@@ -0,0 +1,53 @@
+#-------------------------------------------------------------------------
+#
+# Makefile for libpq-oauth
+#
+# Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/interfaces/libpq-oauth/Makefile
+#
+#-------------------------------------------------------------------------
+
+subdir = src/interfaces/libpq-oauth
+top_builddir = ../../..
+include $(top_builddir)/src/Makefile.global
+
+PGFILEDESC = "libpq-oauth - device authorization oauth support"
+NAME = pq-oauth
+SO_MAJOR_VERSION = 1
+SO_MINOR_VERSION = $(MAJORVERSION)
+
+override CPPFLAGS := -I$(libpq_srcdir) -I$(top_builddir)/src/port $(CPPFLAGS)
+
+OBJS = \
+	$(WIN32RES) \
+	fe-auth-oauth-curl.o
+
+SHLIB_LINK_INTERNAL = $(libpq_pgport_shlib)
+SHLIB_LINK = -lcurl
+SHLIB_PREREQS = submake-libpq
+
+SHLIB_EXPORTS = exports.txt
+
+PKG_CONFIG_REQUIRES_PRIVATE = libpq
+#
+# Make dependencies on pg_config_paths.h visible in all builds.
+fe-auth-oauth-curl.o: fe-auth-oauth-curl.c $(top_builddir)/src/port/pg_config_paths.h
+
+$(top_builddir)/src/port/pg_config_paths.h:
+	$(MAKE) -C $(top_builddir)/src/port pg_config_paths.h
+
+all: all-lib
+
+# Shared library stuff
+include $(top_srcdir)/src/Makefile.shlib
+
+install: all installdirs install-lib
+
+installdirs: installdirs-lib
+
+uninstall: uninstall-lib
+
+clean distclean: clean-lib
+	rm -f $(OBJS)
diff --git a/src/interfaces/libpq-oauth/exports.txt b/src/interfaces/libpq-oauth/exports.txt
new file mode 100644
index 00000000000..ac9333763c4
--- /dev/null
+++ b/src/interfaces/libpq-oauth/exports.txt
@@ -0,0 +1,3 @@
+# src/interfaces/libpq-oauth/exports.txt
+pg_fe_run_oauth_flow      1
+pg_fe_cleanup_oauth_flow  2
diff --git a/src/interfaces/libpq/fe-auth-oauth-curl.c b/src/interfaces/libpq-oauth/fe-auth-oauth-curl.c
similarity index 94%
rename from src/interfaces/libpq/fe-auth-oauth-curl.c
rename to src/interfaces/libpq-oauth/fe-auth-oauth-curl.c
index 9e0e8a9f2be..556e436ee93 100644
--- a/src/interfaces/libpq/fe-auth-oauth-curl.c
+++ b/src/interfaces/libpq-oauth/fe-auth-oauth-curl.c
@@ -29,8 +29,10 @@
 #include "common/jsonapi.h"
 #include "fe-auth.h"
 #include "fe-auth-oauth.h"
+#include "fe-auth-oauth-curl.h"
 #include "libpq-int.h"
 #include "mb/pg_wchar.h"
+#include "pg_config_paths.h"
 
 /*
  * It's generally prudent to set a maximum response size to buffer in memory,
@@ -230,6 +232,173 @@ struct async_ctx
 	bool		debugging;		/* can we give unsafe developer assistance? */
 };
 
+#ifdef ENABLE_NLS
+
+static void
+libpq_binddomain(void)
+{
+	/*
+	 * At least on Windows, there are gettext implementations that fail if
+	 * multiple threads call bindtextdomain() concurrently.  Use a mutex and
+	 * flag variable to ensure that we call it just once per process.  It is
+	 * not known that similar bugs exist on non-Windows platforms, but we
+	 * might as well do it the same way everywhere.
+	 */
+	static volatile bool already_bound = false;
+	static pthread_mutex_t binddomain_mutex = PTHREAD_MUTEX_INITIALIZER;
+
+	if (!already_bound)
+	{
+		/* bindtextdomain() does not preserve errno */
+#ifdef WIN32
+		int			save_errno = GetLastError();
+#else
+		int			save_errno = errno;
+#endif
+
+		(void) pthread_mutex_lock(&binddomain_mutex);
+
+		if (!already_bound)
+		{
+			const char *ldir;
+
+			/*
+			 * No relocatable lookup here because the calling executable could
+			 * be anywhere
+			 */
+			ldir = getenv("PGLOCALEDIR");
+			if (!ldir)
+				ldir = LOCALEDIR;
+			bindtextdomain(PG_TEXTDOMAIN("libpq"), ldir);
+			already_bound = true;
+		}
+
+		(void) pthread_mutex_unlock(&binddomain_mutex);
+
+#ifdef WIN32
+		SetLastError(save_errno);
+#else
+		errno = save_errno;
+#endif
+	}
+}
+
+char *
+libpq_gettext(const char *msgid)
+{
+	libpq_binddomain();
+	return dgettext(PG_TEXTDOMAIN("libpq"), msgid);
+}
+
+char *
+libpq_ngettext(const char *msgid, const char *msgid_plural, unsigned long n)
+{
+	libpq_binddomain();
+	return dngettext(PG_TEXTDOMAIN("libpq"), msgid, msgid_plural, n);
+}
+
+#endif							/* ENABLE_NLS */
+
+static void __libpq_append_conn_error(PGconn *conn, const char *fmt,...) pg_attribute_printf(2, 3);
+
+/*
+ * Append a formatted string to the error message buffer of the given
+ * connection, after translating it.  A newline is automatically appended; the
+ * format should not end with a newline.
+ */
+static void
+__libpq_append_conn_error(PGconn *conn, const char *fmt,...)
+{
+	int			save_errno = errno;
+	bool		done;
+	va_list		args;
+
+	Assert(fmt[strlen(fmt) - 1] != '\n');
+
+	if (PQExpBufferBroken(&conn->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(&conn->errorMessage, libpq_gettext(fmt), args);
+		va_end(args);
+	} while (!done);
+
+	appendPQExpBufferChar(&conn->errorMessage, '\n');
+}
+
+/*
+ * Returns true if the PGOAUTHDEBUG=UNSAFE flag is set in the environment.
+ */
+static bool
+__oauth_unsafe_debugging_enabled(void)
+{
+	const char *env = getenv("PGOAUTHDEBUG");
+
+	return (env && strcmp(env, "UNSAFE") == 0);
+}
+
+static int
+__pq_block_sigpipe(sigset_t *osigset, bool *sigpipe_pending)
+{
+	sigset_t	sigpipe_sigset;
+	sigset_t	sigset;
+
+	sigemptyset(&sigpipe_sigset);
+	sigaddset(&sigpipe_sigset, SIGPIPE);
+
+	/* Block SIGPIPE and save previous mask for later reset */
+	SOCK_ERRNO_SET(pthread_sigmask(SIG_BLOCK, &sigpipe_sigset, osigset));
+	if (SOCK_ERRNO)
+		return -1;
+
+	/* We can have a pending SIGPIPE only if it was blocked before */
+	if (sigismember(osigset, SIGPIPE))
+	{
+		/* Is there a pending SIGPIPE? */
+		if (sigpending(&sigset) != 0)
+			return -1;
+
+		if (sigismember(&sigset, SIGPIPE))
+			*sigpipe_pending = true;
+		else
+			*sigpipe_pending = false;
+	}
+	else
+		*sigpipe_pending = false;
+
+	return 0;
+}
+static void
+__pq_reset_sigpipe(sigset_t *osigset, bool sigpipe_pending, bool got_epipe)
+{
+	int			save_errno = SOCK_ERRNO;
+	int			signo;
+	sigset_t	sigset;
+
+	/* Clear SIGPIPE only if none was pending */
+	if (got_epipe && !sigpipe_pending)
+	{
+		if (sigpending(&sigset) == 0 &&
+			sigismember(&sigset, SIGPIPE))
+		{
+			sigset_t	sigpipe_sigset;
+
+			sigemptyset(&sigpipe_sigset);
+			sigaddset(&sigpipe_sigset, SIGPIPE);
+
+			sigwait(&sigpipe_sigset, &signo);
+		}
+	}
+
+	/* Restore saved block mask */
+	pthread_sigmask(SIG_SETMASK, osigset, NULL);
+
+	SOCK_ERRNO_SET(save_errno);
+}
 /*
  * Tears down the Curl handles and frees the async_ctx.
  */
@@ -252,7 +421,7 @@ 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,
+			__libpq_append_conn_error(conn,
 									"libcurl easy handle removal failed: %s",
 									curl_multi_strerror(err));
 	}
@@ -272,7 +441,7 @@ free_async_ctx(PGconn *conn, struct async_ctx *actx)
 		CURLMcode	err = curl_multi_cleanup(actx->curlm);
 
 		if (err)
-			libpq_append_conn_error(conn,
+			__libpq_append_conn_error(conn,
 									"libcurl multi handle cleanup failed: %s",
 									curl_multi_strerror(err));
 	}
@@ -2556,7 +2725,7 @@ initialize_curl(PGconn *conn)
 		goto done;
 	else if (init_successful == PG_BOOL_NO)
 	{
-		libpq_append_conn_error(conn,
+		__libpq_append_conn_error(conn,
 								"curl_global_init previously failed during OAuth setup");
 		goto done;
 	}
@@ -2575,7 +2744,7 @@ initialize_curl(PGconn *conn)
 	 */
 	if (curl_global_init(CURL_GLOBAL_ALL & ~CURL_GLOBAL_WIN32) != CURLE_OK)
 	{
-		libpq_append_conn_error(conn,
+		__libpq_append_conn_error(conn,
 								"curl_global_init failed during OAuth setup");
 		init_successful = PG_BOOL_NO;
 		goto done;
@@ -2597,7 +2766,7 @@ 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"
+		__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"
@@ -2649,7 +2818,7 @@ pg_fe_run_oauth_flow_impl(PGconn *conn)
 		actx = calloc(1, sizeof(*actx));
 		if (!actx)
 		{
-			libpq_append_conn_error(conn, "out of memory");
+			__libpq_append_conn_error(conn, "out of memory");
 			return PGRES_POLLING_FAILED;
 		}
 
@@ -2657,7 +2826,7 @@ pg_fe_run_oauth_flow_impl(PGconn *conn)
 		actx->timerfd = -1;
 
 		/* Should we enable unsafe features? */
-		actx->debugging = oauth_unsafe_debugging_enabled();
+		actx->debugging = __oauth_unsafe_debugging_enabled();
 
 		state->async_ctx = actx;
 
@@ -2895,7 +3064,7 @@ pg_fe_run_oauth_flow(PGconn *conn)
 	 * difficult corner case to exercise in practice, and unfortunately it's
 	 * not really clear whether it's necessary in all cases.
 	 */
-	masked = (pq_block_sigpipe(&osigset, &sigpipe_pending) == 0);
+	masked = (__pq_block_sigpipe(&osigset, &sigpipe_pending) == 0);
 #endif
 
 	result = pg_fe_run_oauth_flow_impl(conn);
@@ -2907,7 +3076,7 @@ pg_fe_run_oauth_flow(PGconn *conn)
 		 * Undo the SIGPIPE mask. Assume we may have gotten EPIPE (we have no
 		 * way of knowing at this level).
 		 */
-		pq_reset_sigpipe(&osigset, sigpipe_pending, true /* EPIPE, maybe */ );
+		__pq_reset_sigpipe(&osigset, sigpipe_pending, true /* EPIPE, maybe */ );
 	}
 #endif
 
diff --git a/src/interfaces/libpq-oauth/fe-auth-oauth-curl.h b/src/interfaces/libpq-oauth/fe-auth-oauth-curl.h
new file mode 100644
index 00000000000..907f360d9d1
--- /dev/null
+++ b/src/interfaces/libpq-oauth/fe-auth-oauth-curl.h
@@ -0,0 +1,23 @@
+/*-------------------------------------------------------------------------
+ *
+ * fe-auth-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/fe-auth-oauth-curl.h
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#ifndef FE_AUTH_OAUTH_CURL_H
+#define FE_AUTH_OAUTH_CURL_H
+
+#include "libpq-fe.h"
+
+extern PGDLLEXPORT PostgresPollingStatusType pg_fe_run_oauth_flow(PGconn *conn);
+extern PGDLLEXPORT void pg_fe_cleanup_oauth_flow(PGconn *conn);
+
+#endif							/* FE_AUTH_OAUTH_CURL_H */
diff --git a/src/interfaces/libpq-oauth/meson.build b/src/interfaces/libpq-oauth/meson.build
new file mode 100644
index 00000000000..bd348a0afc4
--- /dev/null
+++ b/src/interfaces/libpq-oauth/meson.build
@@ -0,0 +1,64 @@
+# Copyright (c) 2022-2025, PostgreSQL Global Development Group
+
+if not libcurl.found() or host_system == 'windows'
+  subdir_done()
+endif
+
+libpq_sources = files(
+  'fe-auth-oauth-curl.c',
+)
+libpq_so_sources = [] # for shared lib, in addition to the above
+
+export_file = custom_target('libpq-oauth.exports',
+  kwargs: gen_export_kwargs,
+)
+
+# port needs to be in include path due to pthread-win32.h
+libpq_oauth_inc = include_directories('.', '../libpq', '../../port')
+libpq_c_args = ['-DSO_MAJOR_VERSION=1']
+
+# Not using both_libraries() here as
+# 1) resource files should only be in the shared library
+# 2) we want the .pc file to include a dependency to {pgport,common}_static for
+#    libpq_st, and {pgport,common}_shlib for libpq_sh
+#
+# We could try to avoid building the source files twice, but it probably adds
+# more complexity than its worth (reusing object files requires also linking
+# to the library on windows or breaks precompiled headers).
+libpq_oauth_st = static_library('libpq-oauth',
+  libpq_sources,
+  include_directories: [libpq_oauth_inc],
+  c_args: libpq_c_args,
+  c_pch: pch_postgres_fe_h,
+  dependencies: [frontend_stlib_code, libpq_deps],
+  kwargs: default_lib_args,
+)
+
+libpq_oauth_so = shared_library('libpq-oauth',
+  libpq_sources + libpq_so_sources,
+  include_directories: [libpq_oauth_inc, postgres_inc],
+  c_args: libpq_c_args,
+  c_pch: pch_postgres_fe_h,
+  version: '1.' + pg_version_major.to_string(),
+  soversion: host_system != 'windows' ? '1' : '',
+  darwin_versions: ['1', '1.' + pg_version_major.to_string()],
+  dependencies: [frontend_shlib_code, libpq, libpq_oauth_deps],
+  link_depends: export_file,
+  link_args: export_fmt.format(export_file.full_path()),
+  kwargs: default_lib_args,
+)
+
+libpq_oauth = declare_dependency(
+  link_with: [libpq_oauth_so],
+  include_directories: [include_directories('.')]
+)
+
+pkgconfig.generate(
+  name: 'libpq-oauth',
+  description: 'PostgreSQL libpq library, device authorization oauth support',
+  url: pg_url,
+  libraries: libpq_oauth,
+  libraries_private: [frontend_stlib_code, libpq_oauth_deps],
+)
+
+subdir('po', if_found: libintl)
diff --git a/src/interfaces/libpq-oauth/po/meson.build b/src/interfaces/libpq-oauth/po/meson.build
new file mode 100644
index 00000000000..1ca1faaf726
--- /dev/null
+++ b/src/interfaces/libpq-oauth/po/meson.build
@@ -0,0 +1,3 @@
+# Copyright (c) 2022-2025, PostgreSQL Global Development Group
+
+nls_targets += [i18n.gettext('libpq-oauth' + '1' + '-' + pg_version_major.to_string())]
diff --git a/src/interfaces/libpq/Makefile b/src/interfaces/libpq/Makefile
index 90b0b65db6f..8cf8d9e54d8 100644
--- a/src/interfaces/libpq/Makefile
+++ b/src/interfaces/libpq/Makefile
@@ -64,10 +64,6 @@ OBJS += \
 	fe-secure-gssapi.o
 endif
 
-ifeq ($(with_libcurl),yes)
-OBJS += fe-auth-oauth-curl.o
-endif
-
 ifeq ($(PORTNAME), cygwin)
 override shlib = cyg$(NAME)$(DLSUFFIX)
 endif
diff --git a/src/interfaces/libpq/exports.txt b/src/interfaces/libpq/exports.txt
index d5143766858..bc0ed85482a 100644
--- a/src/interfaces/libpq/exports.txt
+++ b/src/interfaces/libpq/exports.txt
@@ -210,3 +210,6 @@ PQsetAuthDataHook         207
 PQgetAuthDataHook         208
 PQdefaultAuthDataHook     209
 PQfullProtocolVersion     210
+appendPQExpBufferVA       211
+pg_g_threadlock           212
+PQauthDataHook            213
diff --git a/src/interfaces/libpq/fe-auth-oauth.c b/src/interfaces/libpq/fe-auth-oauth.c
index cf1a25e2ccc..55f980f3d05 100644
--- a/src/interfaces/libpq/fe-auth-oauth.c
+++ b/src/interfaces/libpq/fe-auth-oauth.c
@@ -15,6 +15,10 @@
 
 #include "postgres_fe.h"
 
+#ifndef WIN32
+#include <dlfcn.h>
+#endif
+
 #include "common/base64.h"
 #include "common/hmac.h"
 #include "common/jsonapi.h"
@@ -721,6 +725,44 @@ cleanup_user_oauth_flow(PGconn *conn)
 	state->async_ctx = NULL;
 }
 
+static bool
+use_builtin_flow(PGconn *conn, fe_oauth_state *state)
+{
+#ifdef WIN32
+	return false;
+#else
+	PostgresPollingStatusType (*flow) (PGconn *conn);
+	void		(*cleanup) (PGconn *conn);
+
+	state->builtin_flow = dlopen(
+#if defined(__darwin__)
+								 "libpq-oauth.1.dylib",
+#else
+								 "libpq-oauth.so.1",
+#endif
+								 RTLD_NOW | RTLD_LOCAL);
+	if (!state->builtin_flow)
+	{
+		fprintf(stderr, "failed dlopen: %s\n", dlerror()); // XXX
+		return false;
+	}
+
+	flow = dlsym(state->builtin_flow, "pg_fe_run_oauth_flow");
+	cleanup = dlsym(state->builtin_flow, "pg_fe_cleanup_oauth_flow");
+
+	if (!(flow && cleanup))
+	{
+		fprintf(stderr, "failed dlsym: %s\n", dlerror()); // XXX
+		return false;
+	}
+
+	conn->async_auth = flow;
+	conn->cleanup_async_auth = cleanup;
+
+	return true;
+#endif							/* !WIN32 */
+}
+
 /*
  * Chooses an OAuth client flow for the connection, which will retrieve a Bearer
  * token for presentation to the server.
@@ -792,18 +834,10 @@ setup_token_request(PGconn *conn, fe_oauth_state *state)
 		libpq_append_conn_error(conn, "user-defined OAuth flow failed");
 		goto fail;
 	}
-	else
+	else if (!use_builtin_flow(conn, state))
 	{
-#if USE_LIBCURL
-		/* Hand off to our built-in OAuth flow. */
-		conn->async_auth = pg_fe_run_oauth_flow;
-		conn->cleanup_async_auth = pg_fe_cleanup_oauth_flow;
-
-#else
 		libpq_append_conn_error(conn, "no custom OAuth flows are available, and libpq was not built with libcurl support");
 		goto fail;
-
-#endif
 	}
 
 	return true;
diff --git a/src/interfaces/libpq/fe-auth-oauth.h b/src/interfaces/libpq/fe-auth-oauth.h
index 3f1a7503a01..699ba42acc2 100644
--- a/src/interfaces/libpq/fe-auth-oauth.h
+++ b/src/interfaces/libpq/fe-auth-oauth.h
@@ -33,10 +33,10 @@ typedef struct
 
 	PGconn	   *conn;
 	void	   *async_ctx;
+
+	void	   *builtin_flow;
 } fe_oauth_state;
 
-extern PostgresPollingStatusType pg_fe_run_oauth_flow(PGconn *conn);
-extern void pg_fe_cleanup_oauth_flow(PGconn *conn);
 extern void pqClearOAuthToken(PGconn *conn);
 extern bool oauth_unsafe_debugging_enabled(void);
 
diff --git a/src/interfaces/libpq/fe-auth.h b/src/interfaces/libpq/fe-auth.h
index de98e0d20c4..1d4991f8996 100644
--- a/src/interfaces/libpq/fe-auth.h
+++ b/src/interfaces/libpq/fe-auth.h
@@ -18,9 +18,6 @@
 #include "libpq-int.h"
 
 
-extern PQauthDataHook_type PQauthDataHook;
-
-
 /* Prototypes for functions in fe-auth.c */
 extern int	pg_fe_sendauth(AuthRequest areq, int payloadlen, PGconn *conn,
 						   bool *async);
diff --git a/src/interfaces/libpq/libpq-fe.h b/src/interfaces/libpq/libpq-fe.h
index 7d3a9df6fd5..696a6587dd4 100644
--- a/src/interfaces/libpq/libpq-fe.h
+++ b/src/interfaces/libpq/libpq-fe.h
@@ -812,6 +812,7 @@ typedef int (*PQauthDataHook_type) (PGauthData type, PGconn *conn, void *data);
 extern void PQsetAuthDataHook(PQauthDataHook_type hook);
 extern PQauthDataHook_type PQgetAuthDataHook(void);
 extern int	PQdefaultAuthDataHook(PGauthData type, PGconn *conn, void *data);
+extern PQauthDataHook_type PQauthDataHook;
 
 /* === in encnames.c === */
 
diff --git a/src/interfaces/libpq/meson.build b/src/interfaces/libpq/meson.build
index 19f4a52a97a..02a88408e34 100644
--- a/src/interfaces/libpq/meson.build
+++ b/src/interfaces/libpq/meson.build
@@ -38,10 +38,6 @@ if gssapi.found()
   )
 endif
 
-if libcurl.found()
-  libpq_sources += files('fe-auth-oauth-curl.c')
-endif
-
 export_file = custom_target('libpq.exports',
   kwargs: gen_export_kwargs,
 )
-- 
2.34.1



^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-03-31 14:06  Christoph Berg <[email protected]>
  parent: Andres Freund <[email protected]>
  1 sibling, 0 replies; 101+ messages in thread

From: Christoph Berg @ 2025-03-31 14:06 UTC (permalink / raw)
  To: Andres Freund <[email protected]>; +Cc: Tom Lane <[email protected]>; Bruce Momjian <[email protected]>; Jacob Champion <[email protected]>; pgsql-hackers; Daniel Gustafsson <[email protected]>; Thomas Munro <[email protected]>; Nazir Bilal Yavuz <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>

Re: Andres Freund
> > Yes.  Also, none of this has addressed my complaint about the extent
> > of the build and install dependencies.  Yes, simply not selecting
> > --with-libcurl removes the problem ... but most packagers are under
> > very heavy pressure to enable all features of a package.

And this feature is kind of only useful if it's available anywhere. If
only half of your clients are able to use SSO, you'd probably stick
with passwords anyway. So it needs to be enabled by default.

> How about we provide the current libpq.so without linking to curl and also a
> libpq-oauth.so that has curl support? If we do it right libpq-oauth.so would
> itself link to libpq.so, making libpq-oauth.so a fairly small library.
> 
> That way packagers can split libpq-oauth.so into a separate package, while
> still just building once.

That's definitely a good plan. The blast radius of build dependencies
isn't really a problem, the install/run-time is.

Perhaps we could do the same with libldap and libgssapi? (Though
admittedly I have never seen any complaints or nagging questions from
security people about these.)

Christoph





^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-04-03 18:02  Jacob Champion <[email protected]>
  parent: Thomas Munro <[email protected]>
  0 siblings, 1 reply; 101+ messages in thread

From: Jacob Champion @ 2025-04-03 18:02 UTC (permalink / raw)
  To: Thomas Munro <[email protected]>; +Cc: Nazir Bilal Yavuz <[email protected]>; Andres Freund <[email protected]>; Daniel Gustafsson <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

On Tue, Mar 18, 2025 at 9:09 PM Thomas Munro <[email protected]> wrote:
> All pushed (wasn't sure if Daniel was going to but once I got tangled
> up in all that kqueue stuff he probably quite reasonably assumed that
> I would :-)).

Attached are two more followups, separate from the libcurl split:
- 0001 is a patch originally proposed at [1]. Christoph pointed out
that the build fails on a platform that tries to enable Curl without
having either epoll() or kqueue(), due to a silly mistake I made in
the #ifdefs.
- 0002 should fix some timeouts in 002_client.pl reported by Andres on
Discord. I allowed a short connect_timeout to propagate into tests
which should not have it.

(The goal of 0001 is to get things building for now. After I finish
splitting the implementation into its own module, it'll make more
sense to simply not build that module on platforms that can't
implement a useful flow.)

Thanks,
--Jacob

[1] https://postgr.es/m/CAOYmi%2B%3D4898tXuTvb2LstorRo9JsAnBcn8LE%3DqrgVPiPW8ZfCw%40mail.gmail.com


Attachments:

  [application/octet-stream] 0002-oauth-Remove-unneeded-timeouts-from-t-002_client.patch (1.1K, ../../CAOYmi+mHu_5i2waPRzCiX906gg2HNR3OpSGR1Vz=faLrCoAWcg@mail.gmail.com/2-0002-oauth-Remove-unneeded-timeouts-from-t-002_client.patch)
  download | inline diff:
From 65c03c649084f9a7b54d172dc14f442e68b3aab0 Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Thu, 3 Apr 2025 10:12:45 -0700
Subject: [PATCH 2/2] oauth: Remove unneeded timeouts from t/002_client

The connect_timeout=1 setting for the --hang-forever test was kept in
place for later tests, causing unexpected timeouts on slower buildfarm
animals. Remove it.

Reported-by: Andres Freund <[email protected]>
---
 src/test/modules/oauth_validator/t/002_client.pl | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/src/test/modules/oauth_validator/t/002_client.pl b/src/test/modules/oauth_validator/t/002_client.pl
index ab83258d736..54769f12f57 100644
--- a/src/test/modules/oauth_validator/t/002_client.pl
+++ b/src/test/modules/oauth_validator/t/002_client.pl
@@ -122,6 +122,9 @@ test(
 	flags => ["--hang-forever"],
 	expected_stderr => qr/failed: timeout expired/);
 
+# Remove the timeout for later tests.
+$common_connstr = "$base_connstr oauth_issuer=$issuer oauth_client_id=myID";
+
 # Test various misbehaviors of the client hook.
 my @cases = (
 	{
-- 
2.34.1



  [application/octet-stream] 0001-oauth-Fix-build-on-platforms-without-epoll-kqueue.patch (2.4K, ../../CAOYmi+mHu_5i2waPRzCiX906gg2HNR3OpSGR1Vz=faLrCoAWcg@mail.gmail.com/3-0001-oauth-Fix-build-on-platforms-without-epoll-kqueue.patch)
  download | inline diff:
From a1da0ea92c77fdc59c4f14e3af3b5b0f93cfe4df Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Mon, 31 Mar 2025 16:07:33 -0700
Subject: [PATCH 1/2] oauth: Fix build on platforms without epoll/kqueue

register_socket() missed a variable declaration if neither
HAVE_SYS_EPOLL_H nor HAVE_SYS_EVENT_H was defined.

While we're fixing that, adjust the tests to check pg_config.h for one
of the multiplexer implementations, rather than assuming that Windows is
the only platform without support. (Christoph reported this on
hurd-amd64, an experimental Debian.)

Reported-by: Christoph Berg <[email protected]>
Discussion: https://postgr.es/m/Z-sPFl27Y0ZC-VBl%40msg.df7cb.de
---
 src/interfaces/libpq/fe-auth-oauth-curl.c        | 4 ++--
 src/test/modules/oauth_validator/t/001_server.pl | 6 ++++--
 2 files changed, 6 insertions(+), 4 deletions(-)

diff --git a/src/interfaces/libpq/fe-auth-oauth-curl.c b/src/interfaces/libpq/fe-auth-oauth-curl.c
index 9e0e8a9f2be..cd9c0323bb6 100644
--- a/src/interfaces/libpq/fe-auth-oauth-curl.c
+++ b/src/interfaces/libpq/fe-auth-oauth-curl.c
@@ -1172,8 +1172,9 @@ static int
 register_socket(CURL *curl, curl_socket_t socket, int what, void *ctx,
 				void *socketp)
 {
-#ifdef HAVE_SYS_EPOLL_H
 	struct async_ctx *actx = ctx;
+
+#ifdef HAVE_SYS_EPOLL_H
 	struct epoll_event ev = {0};
 	int			res;
 	int			op = EPOLL_CTL_ADD;
@@ -1231,7 +1232,6 @@ register_socket(CURL *curl, curl_socket_t socket, int what, void *ctx,
 	return 0;
 #endif
 #ifdef HAVE_SYS_EVENT_H
-	struct async_ctx *actx = ctx;
 	struct kevent ev[2] = {0};
 	struct kevent ev_out[2];
 	struct timespec timeout = {0};
diff --git a/src/test/modules/oauth_validator/t/001_server.pl b/src/test/modules/oauth_validator/t/001_server.pl
index 30295364ebd..d88994abc24 100644
--- a/src/test/modules/oauth_validator/t/001_server.pl
+++ b/src/test/modules/oauth_validator/t/001_server.pl
@@ -26,9 +26,11 @@ if (!$ENV{PG_TEST_EXTRA} || $ENV{PG_TEST_EXTRA} !~ /\boauth\b/)
 	  'Potentially unsafe test oauth not enabled in PG_TEST_EXTRA';
 }
 
-if ($windows_os)
+unless (check_pg_config("#define HAVE_SYS_EVENT_H 1")
+	or check_pg_config("#define HAVE_SYS_EPOLL_H 1"))
 {
-	plan skip_all => 'OAuth server-side tests are not supported on Windows';
+	plan skip_all =>
+	  'OAuth server-side tests are not supported on this platform';
 }
 
 if ($ENV{with_libcurl} ne 'yes')
-- 
2.34.1



^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-04-03 19:50  Daniel Gustafsson <[email protected]>
  parent: Jacob Champion <[email protected]>
  0 siblings, 0 replies; 101+ messages in thread

From: Daniel Gustafsson @ 2025-04-03 19:50 UTC (permalink / raw)
  To: Jacob Champion <[email protected]>; +Cc: Thomas Munro <[email protected]>; Nazir Bilal Yavuz <[email protected]>; Andres Freund <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

> On 3 Apr 2025, at 20:02, Jacob Champion <[email protected]> wrote:
> 
> On Tue, Mar 18, 2025 at 9:09 PM Thomas Munro <[email protected]> wrote:
>> All pushed (wasn't sure if Daniel was going to but once I got tangled
>> up in all that kqueue stuff he probably quite reasonably assumed that
>> I would :-)).
> 
> Attached are two more followups, separate from the libcurl split:
> - 0001 is a patch originally proposed at [1]. Christoph pointed out
> that the build fails on a platform that tries to enable Curl without
> having either epoll() or kqueue(), due to a silly mistake I made in
> the #ifdefs.
> - 0002 should fix some timeouts in 002_client.pl reported by Andres on
> Discord. I allowed a short connect_timeout to propagate into tests
> which should not have it.

Thanks, both LGTM so pushed.

--
Daniel Gustafsson






^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-06-12 19:58  Jacob Champion <[email protected]>
  parent: Jacob Champion <[email protected]>
  2 siblings, 1 reply; 101+ messages in thread

From: Jacob Champion @ 2025-06-12 19:58 UTC (permalink / raw)
  To: Thomas Munro <[email protected]>; +Cc: Andres Freund <[email protected]>; Daniel Gustafsson <[email protected]>; Peter Eisentraut <[email protected]>; Antonin Houska <[email protected]>; pgsql-hackers

On Thu, Mar 6, 2025 at 12:57 PM Jacob Champion
<[email protected]> wrote:
> 3) There is a related performance bug on other platforms. If a Curl
> timeout happens partway through a request (so libcurl won't clear it),
> the timer-expired event will stay set and CPU will be burned to spin
> pointlessly on drive_request(). This is much easier to notice after
> taking Happy Eyeballs out of the picture. It doesn't cause logical
> failures -- Curl basically discards the unnecessary calls -- but it's
> definitely unintended.
>
> ...
>
> I plan to defer working on Problem 3, which should just be a
> performance bug, until the tests are green again. And I would like to
> eventually add some stronger unit tests for the timer behavior, to
> catch other potential OS-specific problems in the future.

To follow up on this: I had intended to send a patch fixing the timer
bug this week, but after fixing it, the performance problem did not
disappear. Turns out: other file descriptors can get stuck open on
BSD, depending on how complicated Curl wants to make the order of
operations, and the existing tests aren't always enough to expose it.
(It also depends on the Curl version installed.)

I will split this off into its own thread soon, because this
megathread is just too big, but I wanted to make a note here and file
an open item. As part of that, I have a set of more rigorous unit
tests for the libcurl-libpq interaction that I'm working on, since the
external view of "the flow worked/didn't work" is not enough to
indicate internal health.

--Jacob





^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-06-20 10:08  Ivan Kush <[email protected]>
  parent: Jacob Champion <[email protected]>
  0 siblings, 1 reply; 101+ messages in thread

From: Ivan Kush @ 2025-06-20 10:08 UTC (permalink / raw)
  To: Jacob Champion <[email protected]>; +Cc: pgsql-hackers; [email protected]

Hello!

This patch fixes CPPFLAGS, LDFLAGS, LIBS when checking AsyncDNS libcurl 
support in configure

Custom parameters and paths to libcurl were mistakenly excluded from 
CPPFLAGS, LDFLAGS, and LIBS, although AsyncDNS check was OK.

For example, the command `pkg-config --libs libcurl` gives 
`-L/usr/local/lib -lcurl`. LDFLAGS will not contain `-L/usr/local/lib`.

This patch fixes such behaviour.


Test case:

I've tested custom Postgres in an old Debian based Linux distro. This 
distro contains old libcurl (< 7.61, package libcurl3) that was compiled 
with symbols CURL_OPENSSL_3. So I've installed newer version of 
libcurlssl, package libcurl4-openssl-dev, that contains symbols 
CURL_OPENSSL_4 and compiled my libcurl version > 7.61.

After compilation during testing some Postgres shared libraries or 
binaries that was linked with libcurl showed an error "version 
CURL_OPENSSL_3 not found (required by …/libcurl.so.4)"

-- 
Best wishes,
Ivan Kush
Tantor Labs LLC


Attachments:

  [text/x-patch] 0001_oauth_ Fix_CPPFLAGS,_LDFLAGS,_LIBS_when_checking_AsyncDNS_libcurl_support.patch (1.9K, ../../[email protected]/2-0001_oauth_%20Fix_CPPFLAGS%2C_LDFLAGS%2C_LIBS_when_checking_AsyncDNS_libcurl_support.patch)
  download | inline diff:
From 8a24c24f85c40e2aa0c40afc8f9cd7a19afa66c3 Mon Sep 17 00:00:00 2001
From: Ivan Kush <[email protected]>
Date: Fri, 20 Jun 2025 12:16:47 +0300
Subject: [PATCH] oauth: Fix CPPFLAGS, LDFLAGS, LIBS when checking AsyncDNS libcurl support

Custom parameters and paths to libcurl were mistakenly excluded from CPPFLAGS,
LDFLAGS, and LIBS.
For example, the command `pkg-config --libs libcurl` gives
`-L/usr/local/lib -lcurl`. LDFLAGS will not contain `-L/usr/local/lib`

This patch fixes this.

Author: Ivan Kush <[email protected]>
Author: Lev Nikolaev <[email protected]>
Reviewed-by:
Discussion:
---
 config/programs.m4 | 7 +++----
 configure          | 8 +++-----
 2 files changed, 6 insertions(+), 9 deletions(-)

diff --git a/config/programs.m4 b/config/programs.m4
index 0ad1e58b48d..2556e469323 100644
--- a/config/programs.m4
+++ b/config/programs.m4
@@ -348,9 +348,8 @@ AC_DEFUN([PGAC_CHECK_LIBCURL],
 *** The installed version of libcurl does not support asynchronous DNS
 *** lookups. Rebuild libcurl with the AsynchDNS feature enabled in order
 *** to use it with libpq.])
+    CPPFLAGS=$pgac_save_CPPFLAGS
+    LDFLAGS=$pgac_save_LDFLAGS
+    LIBS=$pgac_save_LIBS
   fi
-
-  CPPFLAGS=$pgac_save_CPPFLAGS
-  LDFLAGS=$pgac_save_LDFLAGS
-  LIBS=$pgac_save_LIBS
 ])# PGAC_CHECK_LIBCURL
diff --git a/configure b/configure
index 4f15347cc95..46a011d1d1b 100755
--- a/configure
+++ b/configure
@@ -12883,12 +12883,10 @@ $as_echo "$pgac_cv__libcurl_async_dns" >&6; }
 *** The installed version of libcurl does not support asynchronous DNS
 *** lookups. Rebuild libcurl with the AsynchDNS feature enabled in order
 *** to use it with libpq." "$LINENO" 5
+    CPPFLAGS=$pgac_save_CPPFLAGS
+    LDFLAGS=$pgac_save_LDFLAGS
+    LIBS=$pgac_save_LIBS
   fi
-
-  CPPFLAGS=$pgac_save_CPPFLAGS
-  LDFLAGS=$pgac_save_LDFLAGS
-  LIBS=$pgac_save_LIBS
-
 fi

 if test "$with_gssapi" = yes ; then
--
2.34.1



^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-06-23 15:32  Jacob Champion <[email protected]>
  parent: Ivan Kush <[email protected]>
  0 siblings, 1 reply; 101+ messages in thread

From: Jacob Champion @ 2025-06-23 15:32 UTC (permalink / raw)
  To: Ivan Kush <[email protected]>; +Cc: pgsql-hackers; [email protected]

On Fri, Jun 20, 2025 at 3:08 AM Ivan Kush <[email protected]> wrote:
>
> Hello!
>
> This patch fixes CPPFLAGS, LDFLAGS, LIBS when checking AsyncDNS libcurl
> support in configure

Hi Ivan, thanks for the report! Your patch puts new logic directly
after an AC_MSG_ERROR() call, so any effect has to come from the fact
that we're no longer restoring the old compiler and linker flags.
That's not what we want -- Curl needs to be isolated from the rest of
the build.

Let's focus on the error you're seeing:

> After compilation during testing some Postgres shared libraries or
> binaries that was linked with libcurl showed an error "version
> CURL_OPENSSL_3 not found (required by …/libcurl.so.4)"

What's your configure line? You need to make sure that your custom
libcurl is used at configure-time, compile-time, and run-time.

And which binaries are complaining? The only thing that should ever be
linked against libcurl is libpq-oauth-18.so.

Thanks,
--Jacob





^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-07-02 12:45  Ivan Kush <[email protected]>
  parent: Jacob Champion <[email protected]>
  0 siblings, 1 reply; 101+ messages in thread

From: Ivan Kush @ 2025-07-02 12:45 UTC (permalink / raw)
  To: Jacob Champion <[email protected]>; +Cc: pgsql-hackers; [email protected]

Thanks for the clarification! I thought linker flags should be installed 
globally for all compilation targets.


Another question:

Why don't we set LIBS in the configure in "checking for curl_multi_init" 
using LIBCURL_LIBS or LIBCURL_LDFLAGS?
https://github.com/postgres/postgres/blob/master/configure#L12734

Like this:
     LIBS="$(LIBCURL_LDFLAGS) $(LIBCURL_LDLIBS)"

And set LIBS with -lcurl.

As I understand we need to check the properties of libcurl we are 
compiling with?
It may be some local libcurl from /opt/my_libcurl. So LIBCURL_... may 
contain a flag like -L/opt/my_libcurl
Without these LIBCURL... variables we will check a system libcurl, not 
our local.

I mean why don't we set LIBS


current *configure*

$as_echo_n "checking for curl_multi_init in -lcurl... " >&6; }
....
else
   ac_check_lib_save_LIBS=$LIBS
LIBS="-lcurl  $LIBS"
cat confdefs.h - <<_ACEOF >conftest.$ac_ext
/* end confdefs.h.  */

https://github.com/postgres/postgres/blob/master/configure#L12734

For example, I've logged flags after this code sample and they don't 
contain -L/opt/my_libcurl

     IVK configure:13648: CFLAGS=-Wall -Wmissing-prototypes 
-Wpointer-arith -Wdeclaration-after-statement -Werror=vla -Wendif-labels 
-Wmissing-format-attribute -Wformat-security -fno-strict-aliasing 
-fwrapv -fexcess-precision=standard -pipe -O2
     IVK configure:13649: LDFLAGS=-Wl,-z,relro -Wl,-z,now -flto=auto 
-ffat-lto-objects -L/usr/lib/llvm-10/lib -L/usr/local/lib/zstd
     IVK configure:13650: LIBS=-lcurl  -lz -lreadline -lpthread -lrt 
-ldl -lm
     IVK configure:13651: LDLIBS=

On 25-06-23 18:32, Jacob Champion wrote:
> On Fri, Jun 20, 2025 at 3:08 AM Ivan Kush <[email protected]> wrote:
>> Hello!
>>
>> This patch fixes CPPFLAGS, LDFLAGS, LIBS when checking AsyncDNS libcurl
>> support in configure
> Hi Ivan, thanks for the report! Your patch puts new logic directly
> after an AC_MSG_ERROR() call, so any effect has to come from the fact
> that we're no longer restoring the old compiler and linker flags.
> That's not what we want -- Curl needs to be isolated from the rest of
> the build.
>
> Let's focus on the error you're seeing:
>
>> After compilation during testing some Postgres shared libraries or
>> binaries that was linked with libcurl showed an error "version
>> CURL_OPENSSL_3 not found (required by …/libcurl.so.4)"
> What's your configure line? You need to make sure that your custom
> libcurl is used at configure-time, compile-time, and run-time.
>
> And which binaries are complaining? The only thing that should ever be
> linked against libcurl is libpq-oauth-18.so.
>
> Thanks,
> --Jacob

-- 
Best wishes,
Ivan Kush
Tantor Labs LLC






^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-07-02 14:46  Jacob Champion <[email protected]>
  parent: Ivan Kush <[email protected]>
  0 siblings, 1 reply; 101+ messages in thread

From: Jacob Champion @ 2025-07-02 14:46 UTC (permalink / raw)
  To: Ivan Kush <[email protected]>; +Cc: pgsql-hackers; [email protected]

On Wed, Jul 2, 2025 at 5:45 AM Ivan Kush <[email protected]> wrote:
>
> Thanks for the clarification! I thought linker flags should be installed
> globally for all compilation targets.

Not for libcurl, since the libpq-oauth module split.

> Another question:
>
> Why don't we set LIBS in the configure in "checking for curl_multi_init"
> using LIBCURL_LIBS or LIBCURL_LDFLAGS?
> [...]
> Without these LIBCURL... variables we will check a system libcurl, not
> our local.

Ah, that's definitely a bug. I've tested alternate PKG_CONFIG_PATHs,
but I haven't regularly tested on systems that have no system libcurl
at all. So those header and lib checks need to be moved after the use
of LIBCURL_CPPFLAGS and LIBCURL_LDFLAGS to prevent a false failure.
Otherwise they're only useful for the LIBCURL_LDLIBS assignment.

I wonder if I should just get rid of those to better match the Meson
implementation... but the error messages from the checks will likely
be nicer than compilation failures during the later test programs. Hm.

(Thanks for the report!)

--Jacob





^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-07-09 17:36  Tom Lane <[email protected]>
  parent: Jacob Champion <[email protected]>
  0 siblings, 2 replies; 101+ messages in thread

From: Tom Lane @ 2025-07-09 17:36 UTC (permalink / raw)
  To: Jacob Champion <[email protected]>; +Cc: Ivan Kush <[email protected]>; pgsql-hackers; [email protected]

Jacob Champion <[email protected]> writes:
> On Wed, Jul 2, 2025 at 5:45 AM Ivan Kush <[email protected]> wrote:
>> Why don't we set LIBS in the configure in "checking for curl_multi_init"
>> using LIBCURL_LIBS or LIBCURL_LDFLAGS?
>> [...]
>> Without these LIBCURL... variables we will check a system libcurl, not
>> our local.

> Ah, that's definitely a bug.

I just ran into a vaguely-related failure: on RHEL8, building
with --with-libcurl leads to failures during check-world:

../../../../src/interfaces/libpq/libpq.so: undefined reference to `dlopen'
../../../../src/interfaces/libpq/libpq.so: undefined reference to `dlclose'
../../../../src/interfaces/libpq/libpq.so: undefined reference to `dlerror'
../../../../src/interfaces/libpq/libpq.so: undefined reference to `dlsym'
collect2: error: ld returned 1 exit status

Per "man dlopen", you have to link with libdl to use these functions
on this platform.  (Curiously, although RHEL9 still says that in the
documentation, it doesn't seem to actually need -ldl.)  I was able
to resolve this by adding -ldl in libpq's Makefile:

-SHLIB_LINK += $(filter -lcrypt -ldes -lcom_err -lcrypto -lk5crypto -lkrb5 -lgssapi_krb5 -lgss -lgssapi -lssl -lsocket -lnsl -lresolv -lintl -lm, $(LIBS)) $(LDAP_LIBS_FE) $(PTHREAD_LIBS)
+SHLIB_LINK += $(filter -lcrypt -ldes -lcom_err -lcrypto -lk5crypto -lkrb5 -lgssapi_krb5 -lgss -lgssapi -lssl -lsocket -lnsl -lresolv -lintl -ldl -lm, $(LIBS)) $(LDAP_LIBS_FE) $(PTHREAD_LIBS)

It doesn't look like the Meson support needs such explicit tracking of
required libraries, but perhaps I'm missing something?  I'm not able
to test that directly for lack of a usable ninja version on this
platform.

Apologies for not noticing this sooner.  I don't think I'd tried
--with-libcurl since the changes to split out libpq-oauth.

			regards, tom lane





^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-07-09 17:46  Andres Freund <[email protected]>
  parent: Tom Lane <[email protected]>
  1 sibling, 0 replies; 101+ messages in thread

From: Andres Freund @ 2025-07-09 17:46 UTC (permalink / raw)
  To: Tom Lane <[email protected]>; +Cc: Jacob Champion <[email protected]>; Ivan Kush <[email protected]>; pgsql-hackers; [email protected]

Hi,

On 2025-07-09 13:36:26 -0400, Tom Lane wrote:
> It doesn't look like the Meson support needs such explicit tracking of
> required libraries, but perhaps I'm missing something?

It should be fine, -ldl is added to "os_deps" if needed, and os_deps is used
for all code in pg.

Greetings,

Andres Freund





^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-07-09 17:59  Jacob Champion <[email protected]>
  parent: Tom Lane <[email protected]>
  1 sibling, 1 reply; 101+ messages in thread

From: Jacob Champion @ 2025-07-09 17:59 UTC (permalink / raw)
  To: Tom Lane <[email protected]>; +Cc: Ivan Kush <[email protected]>; pgsql-hackers; [email protected]

On Wed, Jul 9, 2025 at 10:36 AM Tom Lane <[email protected]> wrote:
> Per "man dlopen", you have to link with libdl to use these functions
> on this platform.  (Curiously, although RHEL9 still says that in the
> documentation, it doesn't seem to actually need -ldl.)  I was able
> to resolve this by adding -ldl in libpq's Makefile:
>
> -SHLIB_LINK += $(filter -lcrypt -ldes -lcom_err -lcrypto -lk5crypto -lkrb5 -lgssapi_krb5 -lgss -lgssapi -lssl -lsocket -lnsl -lresolv -lintl -lm, $(LIBS)) $(LDAP_LIBS_FE) $(PTHREAD_LIBS)
> +SHLIB_LINK += $(filter -lcrypt -ldes -lcom_err -lcrypto -lk5crypto -lkrb5 -lgssapi_krb5 -lgss -lgssapi -lssl -lsocket -lnsl -lresolv -lintl -ldl -lm, $(LIBS)) $(LDAP_LIBS_FE) $(PTHREAD_LIBS)

Hmm, okay. That analysis and fix look good to me. (It looks like none
of the RHEL animals are testing with Curl yet, and locally I was using
Rocky 9...)

I'll work up a patch to send through the CI. I can't currently test
RHEL8 easily -- Rocky 8 is incompatible with my Macbook? -- which I
will need to rectify eventually, but I can't this week.

Thanks!
--Jacob





^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-07-09 18:13  Tom Lane <[email protected]>
  parent: Jacob Champion <[email protected]>
  0 siblings, 1 reply; 101+ messages in thread

From: Tom Lane @ 2025-07-09 18:13 UTC (permalink / raw)
  To: Jacob Champion <[email protected]>; +Cc: Ivan Kush <[email protected]>; pgsql-hackers; [email protected]

Jacob Champion <[email protected]> writes:
> I'll work up a patch to send through the CI. I can't currently test
> RHEL8 easily -- Rocky 8 is incompatible with my Macbook? -- which I
> will need to rectify eventually, but I can't this week.

No need, I already tested locally.  Will push shortly.

			regards, tom lane





^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-07-09 18:39  Jacob Champion <[email protected]>
  parent: Tom Lane <[email protected]>
  0 siblings, 1 reply; 101+ messages in thread

From: Jacob Champion @ 2025-07-09 18:39 UTC (permalink / raw)
  To: Tom Lane <[email protected]>; +Cc: Ivan Kush <[email protected]>; pgsql-hackers; [email protected]

On Wed, Jul 9, 2025 at 11:13 AM Tom Lane <[email protected]> wrote:
> Jacob Champion <[email protected]> writes:
> > I'll work up a patch to send through the CI. I can't currently test
> > RHEL8 easily -- Rocky 8 is incompatible with my Macbook? -- which I
> > will need to rectify eventually, but I can't this week.
>
> No need, I already tested locally.  Will push shortly.

Thank you very much!

Here is a draft patch for Ivan's reported issue; I still need to put
it through its paces with some more unusual setups, but I want to get
cfbot on it.

--Jacob


Attachments:

  [application/octet-stream] WIP-oauth-run-Autoconf-tests-with-correct-compiler-f.patch (2.6K, ../../CAOYmi+ne9_EvK7Z-y78z_6VXnYgXoRbiumMQeWCcy8vHf5t-cw@mail.gmail.com/2-WIP-oauth-run-Autoconf-tests-with-correct-compiler-f.patch)
  download | inline diff:
From 2186e74f79a1dea452f1e25b70e1e7bfdde72d8f Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Wed, 9 Jul 2025 11:33:30 -0700
Subject: [PATCH] WIP: oauth: run Autoconf tests with correct compiler flags

Reported-by: Ivan Kush <[email protected]>
Discussion: https://postgr.es/m/8a611028-51a1-408c-b592-832e2e6e1fc9%40tantorlabs.com
Backpatch-through: 18
---
 config/programs.m4 | 15 +++++++++------
 configure          | 15 +++++++++------
 2 files changed, 18 insertions(+), 12 deletions(-)

diff --git a/config/programs.m4 b/config/programs.m4
index 0ad1e58b48d..b667aec4458 100644
--- a/config/programs.m4
+++ b/config/programs.m4
@@ -284,6 +284,15 @@ AC_DEFUN([PGAC_CHECK_STRIP],
 
 AC_DEFUN([PGAC_CHECK_LIBCURL],
 [
+  # libcurl compiler/linker flags are kept separate from the global flags, so
+  # they have to be added back temporarily for the following tests.
+  pgac_save_CPPFLAGS=$CPPFLAGS
+  pgac_save_LDFLAGS=$LDFLAGS
+  pgac_save_LIBS=$LIBS
+
+  CPPFLAGS="$LIBCURL_CPPFLAGS $CPPFLAGS"
+  LDFLAGS="$LIBCURL_LDFLAGS $LDFLAGS"
+
   AC_CHECK_HEADER(curl/curl.h, [],
 				  [AC_MSG_ERROR([header file <curl/curl.h> is required for --with-libcurl])])
   AC_CHECK_LIB(curl, curl_multi_init, [
@@ -292,12 +301,6 @@ AC_DEFUN([PGAC_CHECK_LIBCURL],
 			   ],
 			   [AC_MSG_ERROR([library 'curl' does not provide curl_multi_init])])
 
-  pgac_save_CPPFLAGS=$CPPFLAGS
-  pgac_save_LDFLAGS=$LDFLAGS
-  pgac_save_LIBS=$LIBS
-
-  CPPFLAGS="$LIBCURL_CPPFLAGS $CPPFLAGS"
-  LDFLAGS="$LIBCURL_LDFLAGS $LDFLAGS"
   LIBS="$LIBCURL_LDLIBS $LIBS"
 
   # Check to see whether the current platform supports threadsafe Curl
diff --git a/configure b/configure
index cfaf3757dd7..cf54332a799 100755
--- a/configure
+++ b/configure
@@ -12717,6 +12717,15 @@ fi
 
 if test "$with_libcurl" = yes ; then
 
+  # libcurl compiler/linker flags are kept separate from the global flags, so
+  # they have to be added back temporarily for the following tests.
+  pgac_save_CPPFLAGS=$CPPFLAGS
+  pgac_save_LDFLAGS=$LDFLAGS
+  pgac_save_LIBS=$LIBS
+
+  CPPFLAGS="$LIBCURL_CPPFLAGS $CPPFLAGS"
+  LDFLAGS="$LIBCURL_LDFLAGS $LDFLAGS"
+
   ac_fn_c_check_header_mongrel "$LINENO" "curl/curl.h" "ac_cv_header_curl_curl_h" "$ac_includes_default"
 if test "x$ac_cv_header_curl_curl_h" = xyes; then :
 
@@ -12774,12 +12783,6 @@ else
 fi
 
 
-  pgac_save_CPPFLAGS=$CPPFLAGS
-  pgac_save_LDFLAGS=$LDFLAGS
-  pgac_save_LIBS=$LIBS
-
-  CPPFLAGS="$LIBCURL_CPPFLAGS $CPPFLAGS"
-  LDFLAGS="$LIBCURL_LDFLAGS $LDFLAGS"
   LIBS="$LIBCURL_LDLIBS $LIBS"
 
   # Check to see whether the current platform supports threadsafe Curl
-- 
2.34.1



^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-07-09 19:07  Tom Lane <[email protected]>
  parent: Jacob Champion <[email protected]>
  0 siblings, 1 reply; 101+ messages in thread

From: Tom Lane @ 2025-07-09 19:07 UTC (permalink / raw)
  To: Jacob Champion <[email protected]>; +Cc: Ivan Kush <[email protected]>; pgsql-hackers; [email protected]

Jacob Champion <[email protected]> writes:
> Here is a draft patch for Ivan's reported issue; I still need to put
> it through its paces with some more unusual setups, but I want to get
> cfbot on it.

I'm confused about why this moves up the temporary changes of
CPPFLAGS and LDFLAGS, but not LIBS?  Maybe that's actually correct,
but it looks strange (and perhaps deserves a comment about why).

			regards, tom lane





^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-07-09 19:28  Jacob Champion <[email protected]>
  parent: Tom Lane <[email protected]>
  0 siblings, 1 reply; 101+ messages in thread

From: Jacob Champion @ 2025-07-09 19:28 UTC (permalink / raw)
  To: Tom Lane <[email protected]>; +Cc: Ivan Kush <[email protected]>; pgsql-hackers; [email protected]

On Wed, Jul 9, 2025 at 12:07 PM Tom Lane <[email protected]> wrote:
> Jacob Champion <[email protected]> writes:
> > Here is a draft patch for Ivan's reported issue; I still need to put
> > it through its paces with some more unusual setups, but I want to get
> > cfbot on it.
>
> I'm confused about why this moves up the temporary changes of
> CPPFLAGS and LDFLAGS, but not LIBS?  Maybe that's actually correct,
> but it looks strange (and perhaps deserves a comment about why).

Yeah, that's fair. It's because LIBCURL_LDLIBS isn't set until that
AC_CHECK_LIB test is run, and the test needs LIBCURL_LDFLAGS to be in
force.

(Upthread, I was idly wondering if those AC_CHECKs should just be
removed -- after all, PKG_CHECK_MODULES just told us where Curl was --
but I'm nervous that this might make more niche use cases like
cross-compilation harder to use in practice?)

Does the attached help clarify?

Thanks,
--Jacob


Attachments:

  [application/octet-stream] v2-0001-WIP-oauth-run-Autoconf-tests-with-correct-compile.patch (3.2K, ../../CAOYmi+kps=uRhx==UjmE2smqgjvigTRemxK5W=WfQ1MCA0ReiQ@mail.gmail.com/2-v2-0001-WIP-oauth-run-Autoconf-tests-with-correct-compile.patch)
  download | inline diff:
From 59d6df6fca487622ddb64b219b721f9990ac5809 Mon Sep 17 00:00:00 2001
From: Jacob Champion <[email protected]>
Date: Wed, 9 Jul 2025 11:33:30 -0700
Subject: [PATCH v2] WIP: oauth: run Autoconf tests with correct compiler flags

Reported-by: Ivan Kush <[email protected]>
Reviewed-by: Tom Lane <[email protected]>
Discussion: https://postgr.es/m/8a611028-51a1-408c-b592-832e2e6e1fc9%40tantorlabs.com
Backpatch-through: 18
---
 config/programs.m4 | 18 ++++++++++++------
 configure          | 18 ++++++++++++------
 2 files changed, 24 insertions(+), 12 deletions(-)

diff --git a/config/programs.m4 b/config/programs.m4
index 0ad1e58b48d..c73d9307ea8 100644
--- a/config/programs.m4
+++ b/config/programs.m4
@@ -284,20 +284,26 @@ AC_DEFUN([PGAC_CHECK_STRIP],
 
 AC_DEFUN([PGAC_CHECK_LIBCURL],
 [
+  # libcurl compiler/linker flags are kept separate from the global flags, so
+  # they have to be added back temporarily for the following tests.
+  pgac_save_CPPFLAGS=$CPPFLAGS
+  pgac_save_LDFLAGS=$LDFLAGS
+  pgac_save_LIBS=$LIBS
+
+  CPPFLAGS="$LIBCURL_CPPFLAGS $CPPFLAGS"
+  LDFLAGS="$LIBCURL_LDFLAGS $LDFLAGS"
+
   AC_CHECK_HEADER(curl/curl.h, [],
 				  [AC_MSG_ERROR([header file <curl/curl.h> is required for --with-libcurl])])
+
+  # LIBCURL_LDLIBS is determined here. Like the compiler flags, it should not
+  # pollute the global LIBS setting.
   AC_CHECK_LIB(curl, curl_multi_init, [
 				 AC_DEFINE([HAVE_LIBCURL], [1], [Define to 1 if you have the `curl' library (-lcurl).])
 				 AC_SUBST(LIBCURL_LDLIBS, -lcurl)
 			   ],
 			   [AC_MSG_ERROR([library 'curl' does not provide curl_multi_init])])
 
-  pgac_save_CPPFLAGS=$CPPFLAGS
-  pgac_save_LDFLAGS=$LDFLAGS
-  pgac_save_LIBS=$LIBS
-
-  CPPFLAGS="$LIBCURL_CPPFLAGS $CPPFLAGS"
-  LDFLAGS="$LIBCURL_LDFLAGS $LDFLAGS"
   LIBS="$LIBCURL_LDLIBS $LIBS"
 
   # Check to see whether the current platform supports threadsafe Curl
diff --git a/configure b/configure
index cfaf3757dd7..6d7c22e153f 100755
--- a/configure
+++ b/configure
@@ -12717,6 +12717,15 @@ fi
 
 if test "$with_libcurl" = yes ; then
 
+  # libcurl compiler/linker flags are kept separate from the global flags, so
+  # they have to be added back temporarily for the following tests.
+  pgac_save_CPPFLAGS=$CPPFLAGS
+  pgac_save_LDFLAGS=$LDFLAGS
+  pgac_save_LIBS=$LIBS
+
+  CPPFLAGS="$LIBCURL_CPPFLAGS $CPPFLAGS"
+  LDFLAGS="$LIBCURL_LDFLAGS $LDFLAGS"
+
   ac_fn_c_check_header_mongrel "$LINENO" "curl/curl.h" "ac_cv_header_curl_curl_h" "$ac_includes_default"
 if test "x$ac_cv_header_curl_curl_h" = xyes; then :
 
@@ -12725,6 +12734,9 @@ else
 fi
 
 
+
+  # LIBCURL_LDLIBS is determined here. Like the compiler flags, it should not
+  # pollute the global LIBS setting.
   { $as_echo "$as_me:${as_lineno-$LINENO}: checking for curl_multi_init in -lcurl" >&5
 $as_echo_n "checking for curl_multi_init in -lcurl... " >&6; }
 if ${ac_cv_lib_curl_curl_multi_init+:} false; then :
@@ -12774,12 +12786,6 @@ else
 fi
 
 
-  pgac_save_CPPFLAGS=$CPPFLAGS
-  pgac_save_LDFLAGS=$LDFLAGS
-  pgac_save_LIBS=$LIBS
-
-  CPPFLAGS="$LIBCURL_CPPFLAGS $CPPFLAGS"
-  LDFLAGS="$LIBCURL_LDFLAGS $LDFLAGS"
   LIBS="$LIBCURL_LDLIBS $LIBS"
 
   # Check to see whether the current platform supports threadsafe Curl
-- 
2.34.1



^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-07-09 19:42  Tom Lane <[email protected]>
  parent: Jacob Champion <[email protected]>
  0 siblings, 1 reply; 101+ messages in thread

From: Tom Lane @ 2025-07-09 19:42 UTC (permalink / raw)
  To: Jacob Champion <[email protected]>; +Cc: Ivan Kush <[email protected]>; pgsql-hackers; [email protected]

Jacob Champion <[email protected]> writes:
> On Wed, Jul 9, 2025 at 12:07 PM Tom Lane <[email protected]> wrote:
>> I'm confused about why this moves up the temporary changes of
>> CPPFLAGS and LDFLAGS, but not LIBS?  Maybe that's actually correct,
>> but it looks strange (and perhaps deserves a comment about why).

> Does the attached help clarify?

Yes, thanks.

> (Upthread, I was idly wondering if those AC_CHECKs should just be
> removed -- after all, PKG_CHECK_MODULES just told us where Curl was --
> but I'm nervous that this might make more niche use cases like
> cross-compilation harder to use in practice?)

Nah, let's keep them.  We do document for at least some libraries
how to manually specify the include and link options without
depending on pkg-config.  If someone tries that with libcurl,
it'd be good to have sanity checks on the results.

			regards, tom lane





^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-07-09 23:54  Jacob Champion <[email protected]>
  parent: Tom Lane <[email protected]>
  0 siblings, 1 reply; 101+ messages in thread

From: Jacob Champion @ 2025-07-09 23:54 UTC (permalink / raw)
  To: Tom Lane <[email protected]>; Ivan Kush <[email protected]>; +Cc: pgsql-hackers; [email protected]

On Wed, Jul 9, 2025 at 12:42 PM Tom Lane <[email protected]> wrote:
> Nah, let's keep them.  We do document for at least some libraries
> how to manually specify the include and link options without
> depending on pkg-config.  If someone tries that with libcurl,
> it'd be good to have sanity checks on the results.

Sounds good, thanks for the review!

On Wed, Jul 9, 2025 at 11:39 AM Jacob Champion
<[email protected]> wrote:
> Here is a draft patch for Ivan's reported issue; I still need to put
> it through its paces with some more unusual setups, but I want to get
> cfbot on it.

On HEAD, Rocky 9 fails to build with a custom Curl PKG_CONFIG_PATH and
no libcurl-devel installed. With this patch, that build now succeeds,
and it still succeeds after libcurl-devel is reinstalled, with the
compiler tests continuing to use the custom libcurl and not the
system's.

So I'll give Ivan a little time in case he'd like to test/review
again, but otherwise I plan to push it this week.

Thanks,
--Jacob





^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-07-10 14:41  [email protected]
  parent: Jacob Champion <[email protected]>
  0 siblings, 1 reply; 101+ messages in thread

From: [email protected] @ 2025-07-10 14:41 UTC (permalink / raw)
  To: Jacob Champion <[email protected]>; Tom Lane <[email protected]>; pgsql-hackers; [email protected]


I agree with the patch. Works in my OSes

On 7/10/25 2:54 AM, Jacob Champion <[email protected]> wrote:
> On Wed, Jul 9, 2025 at 12:42 PM Tom Lane <[email protected]> wrote:
> > Nah, let's keep them.  We do document for at least some libraries
> > how to manually specify the include and link options without
> > depending on pkg-config.  If someone tries that with libcurl,
> > it'd be good to have sanity checks on the results.
> 
> Sounds good, thanks for the review!
> 
> On Wed, Jul 9, 2025 at 11:39 AM Jacob Champion
> <[email protected]> wrote:
> > Here is a draft patch for Ivan's reported issue; I still need to put
> > it through its paces with some more unusual setups, but I want to get
> > cfbot on it.
> 
> On HEAD, Rocky 9 fails to build with a custom Curl PKG_CONFIG_PATH and
> no libcurl-devel installed. With this patch, that build now succeeds,
> and it still succeeds after libcurl-devel is reinstalled, with the
> compiler tests continuing to use the custom libcurl and not the
> system's.
> 
> So I'll give Ivan a little time in case he'd like to test/review
> again, but otherwise I plan to push it this week.
> 
> Thanks,
> --Jacob
> 





^ permalink  raw  reply  [nested|flat] 101+ messages in thread

* Re: [PoC] Federated Authn/z with OAUTHBEARER
@ 2025-07-11 17:33  Jacob Champion <[email protected]>
  parent: [email protected]
  0 siblings, 0 replies; 101+ messages in thread

From: Jacob Champion @ 2025-07-11 17:33 UTC (permalink / raw)
  To: [email protected]; +Cc: Tom Lane <[email protected]>; pgsql-hackers; [email protected]

On Thu, Jul 10, 2025 at 7:41 AM <[email protected]> wrote:
> I agree with the patch. Works in my OSes

Thanks Ivan! Committed.

--Jacob





^ permalink  raw  reply  [nested|flat] 101+ messages in thread


end of thread, other threads:[~2025-07-11 17:33 UTC | newest]

Thread overview: 101+ messages (download: mbox mbox.gz follow: Atom feed)
-- links below jump to the message on this page --
2019-12-20 01:25 [PATCH v29 11/11] Add documentations about Incremental View Maintenance Yugo Nagata <[email protected]>
2025-01-21 00:40 Re: [PoC] Federated Authn/z with OAUTHBEARER Jacob Champion <[email protected]>
2025-01-21 00:43 ` Re: [PoC] Federated Authn/z with OAUTHBEARER Jacob Champion <[email protected]>
2025-01-21 09:29 ` Re: [PoC] Federated Authn/z with OAUTHBEARER Daniel Gustafsson <[email protected]>
2025-01-21 16:46   ` Re: [PoC] Federated Authn/z with OAUTHBEARER Jacob Champion <[email protected]>
2025-01-27 22:49     ` Re: [PoC] Federated Authn/z with OAUTHBEARER Daniel Gustafsson <[email protected]>
2025-01-28 00:59       ` Re: [PoC] Federated Authn/z with OAUTHBEARER Jacob Champion <[email protected]>
2025-01-31 16:23         ` Re: [PoC] Federated Authn/z with OAUTHBEARER Daniel Gustafsson <[email protected]>
2025-02-06 22:02           ` Re: [PoC] Federated Authn/z with OAUTHBEARER Daniel Gustafsson <[email protected]>
2025-02-07 05:48             ` Re: [PoC] Federated Authn/z with OAUTHBEARER Jacob Champion <[email protected]>
2025-02-07 20:12               ` Re: [PoC] Federated Authn/z with OAUTHBEARER Daniel Gustafsson <[email protected]>
2025-02-08 01:56                 ` Re: [PoC] Federated Authn/z with OAUTHBEARER Jacob Champion <[email protected]>
2025-02-12 14:55                   ` Re: [PoC] Federated Authn/z with OAUTHBEARER Peter Eisentraut <[email protected]>
2025-02-12 14:59                   ` Re: [PoC] Federated Authn/z with OAUTHBEARER Peter Eisentraut <[email protected]>
2025-02-13 21:23                     ` Re: [PoC] Federated Authn/z with OAUTHBEARER Jacob Champion <[email protected]>
2025-02-13 23:01                       ` Re: [PoC] Federated Authn/z with OAUTHBEARER Daniel Gustafsson <[email protected]>
2025-02-13 22:56                   ` Re: [PoC] Federated Authn/z with OAUTHBEARER Daniel Gustafsson <[email protected]>
2025-02-15 01:14                     ` Re: [PoC] Federated Authn/z with OAUTHBEARER Jacob Champion <[email protected]>
2025-02-17 12:03                       ` Re: [PoC] Federated Authn/z with OAUTHBEARER Daniel Gustafsson <[email protected]>
2025-02-17 18:15                         ` Re: [PoC] Federated Authn/z with OAUTHBEARER Jacob Champion <[email protected]>
2025-02-17 23:51                           ` Re: [PoC] Federated Authn/z with OAUTHBEARER Jacob Champion <[email protected]>
2025-02-19 14:13                             ` Re: [PoC] Federated Authn/z with OAUTHBEARER Daniel Gustafsson <[email protected]>
2025-02-19 19:54                               ` Re: [PoC] Federated Authn/z with OAUTHBEARER Jacob Champion <[email protected]>
2025-02-20 16:29                               ` Re: [PoC] Federated Authn/z with OAUTHBEARER Daniel Gustafsson <[email protected]>
2025-02-20 17:28                                 ` Re: [PoC] Federated Authn/z with OAUTHBEARER Jacob Champion <[email protected]>
2025-02-21 17:18                                   ` Re: [PoC] Federated Authn/z with OAUTHBEARER Andres Freund <[email protected]>
2025-02-24 17:39                                     ` Re: [PoC] Federated Authn/z with OAUTHBEARER Jacob Champion <[email protected]>
2025-02-24 20:30                                       ` Re: [PoC] Federated Authn/z with OAUTHBEARER Andres Freund <[email protected]>
2025-02-24 22:02                                         ` Re: [PoC] Federated Authn/z with OAUTHBEARER Jacob Champion <[email protected]>
2025-02-25 17:22                                           ` Re: [PoC] Federated Authn/z with OAUTHBEARER Jacob Champion <[email protected]>
2025-02-28 13:43                                             ` Re: [PoC] Federated Authn/z with OAUTHBEARER Thomas Munro <[email protected]>
2025-02-28 17:37                                               ` Re: [PoC] Federated Authn/z with OAUTHBEARER Jacob Champion <[email protected]>
2025-02-28 21:57                                                 ` Re: [PoC] Federated Authn/z with OAUTHBEARER Jacob Champion <[email protected]>
2025-03-01 00:36                                                   ` Re: [PoC] Federated Authn/z with OAUTHBEARER Thomas Munro <[email protected]>
2025-03-04 00:07                                                     ` Re: [PoC] Federated Authn/z with OAUTHBEARER Jacob Champion <[email protected]>
2025-03-04 04:10                                                       ` Re: [PoC] Federated Authn/z with OAUTHBEARER Thomas Munro <[email protected]>
2025-03-04 17:08                                                         ` Re: [PoC] Federated Authn/z with OAUTHBEARER Jacob Champion <[email protected]>
2025-03-04 22:37                                                           ` Re: [PoC] Federated Authn/z with OAUTHBEARER Thomas Munro <[email protected]>
2025-03-04 22:44                                                             ` Re: [PoC] Federated Authn/z with OAUTHBEARER Jacob Champion <[email protected]>
2025-03-06 20:57                                                               ` Re: [PoC] Federated Authn/z with OAUTHBEARER Jacob Champion <[email protected]>
2025-03-07 00:35                                                                 ` Re: [PoC] Federated Authn/z with OAUTHBEARER Jacob Champion <[email protected]>
2025-03-07 05:12                                                                 ` Re: [PoC] Federated Authn/z with OAUTHBEARER Thomas Munro <[email protected]>
2025-03-07 17:30                                                                   ` Re: [PoC] Federated Authn/z with OAUTHBEARER Jacob Champion <[email protected]>
2025-03-07 21:51                                                                     ` Re: [PoC] Federated Authn/z with OAUTHBEARER Thomas Munro <[email protected]>
2025-03-07 23:02                                                                       ` Re: [PoC] Federated Authn/z with OAUTHBEARER Jacob Champion <[email protected]>
2025-03-17 11:36                                                                         ` Re: [PoC] Federated Authn/z with OAUTHBEARER Nazir Bilal Yavuz <[email protected]>
2025-03-17 15:08                                                                           ` Re: [PoC] Federated Authn/z with OAUTHBEARER Jacob Champion <[email protected]>
2025-03-19 04:09                                                                             ` Re: [PoC] Federated Authn/z with OAUTHBEARER Thomas Munro <[email protected]>
2025-04-03 18:02                                                                               ` Re: [PoC] Federated Authn/z with OAUTHBEARER Jacob Champion <[email protected]>
2025-04-03 19:50                                                                                 ` Re: [PoC] Federated Authn/z with OAUTHBEARER Daniel Gustafsson <[email protected]>
2025-03-19 04:17                                                                             ` Re: [PoC] Federated Authn/z with OAUTHBEARER Tom Lane <[email protected]>
2025-03-19 04:34                                                                               ` Re: [PoC] Federated Authn/z with OAUTHBEARER Thomas Munro <[email protected]>
2025-03-19 13:46                                                                                 ` Re: [PoC] Federated Authn/z with OAUTHBEARER Andres Freund <[email protected]>
2025-03-19 04:57                                                                               ` Re: [PoC] Federated Authn/z with OAUTHBEARER Tom Lane <[email protected]>
2025-03-19 13:31                                                                                 ` Re: [PoC] Federated Authn/z with OAUTHBEARER Bruce Momjian <[email protected]>
2025-03-19 13:38                                                                                 ` Re: [PoC] Federated Authn/z with OAUTHBEARER Daniel Gustafsson <[email protected]>
2025-03-19 20:11                                                                                   ` Re: [PoC] Federated Authn/z with OAUTHBEARER Thomas Munro <[email protected]>
2025-03-19 21:03                                                                                   ` Re: [PoC] Federated Authn/z with OAUTHBEARER Tom Lane <[email protected]>
2025-03-19 22:02                                                                                     ` Re: [PoC] Federated Authn/z with OAUTHBEARER Thomas Munro <[email protected]>
2025-03-19 22:14                                                                                       ` Re: [PoC] Federated Authn/z with OAUTHBEARER Thomas Munro <[email protected]>
2025-03-19 22:19                                                                                       ` Re: [PoC] Federated Authn/z with OAUTHBEARER Tom Lane <[email protected]>
2025-03-19 22:28                                                                                         ` Re: [PoC] Federated Authn/z with OAUTHBEARER Thomas Munro <[email protected]>
2025-03-19 23:11                                                                                           ` Re: [PoC] Federated Authn/z with OAUTHBEARER Bruce Momjian <[email protected]>
2025-03-19 23:59                                                                                   ` Re: [PoC] Federated Authn/z with OAUTHBEARER Bruce Momjian <[email protected]>
2025-03-20 20:33                                                                                     ` Re: [PoC] Federated Authn/z with OAUTHBEARER Jacob Champion <[email protected]>
2025-03-20 20:50                                                                                       ` Re: [PoC] Federated Authn/z with OAUTHBEARER Bruce Momjian <[email protected]>
2025-03-20 21:08                                                                                         ` Re: [PoC] Federated Authn/z with OAUTHBEARER Tom Lane <[email protected]>
2025-03-20 23:26                                                                                           ` Re: [PoC] Federated Authn/z with OAUTHBEARER Andres Freund <[email protected]>
2025-03-21 12:40                                                                                             ` Re: [PoC] Federated Authn/z with OAUTHBEARER Andrew Dunstan <[email protected]>
2025-03-21 18:02                                                                                               ` Re: [PoC] Federated Authn/z with OAUTHBEARER Daniel Gustafsson <[email protected]>
2025-03-26 19:09                                                                                                 ` Re: [PoC] Federated Authn/z with OAUTHBEARER Jacob Champion <[email protected]>
2025-03-31 14:06                                                                                             ` Re: [PoC] Federated Authn/z with OAUTHBEARER Christoph Berg <[email protected]>
2025-06-12 19:58                                                                 ` Re: [PoC] Federated Authn/z with OAUTHBEARER Jacob Champion <[email protected]>
2025-06-20 10:08                                                                   ` Re: [PoC] Federated Authn/z with OAUTHBEARER Ivan Kush <[email protected]>
2025-06-23 15:32                                                                     ` Re: [PoC] Federated Authn/z with OAUTHBEARER Jacob Champion <[email protected]>
2025-07-02 12:45                                                                       ` Re: [PoC] Federated Authn/z with OAUTHBEARER Ivan Kush <[email protected]>
2025-07-02 14:46                                                                         ` Re: [PoC] Federated Authn/z with OAUTHBEARER Jacob Champion <[email protected]>
2025-07-09 17:36                                                                           ` Re: [PoC] Federated Authn/z with OAUTHBEARER Tom Lane <[email protected]>
2025-07-09 17:46                                                                             ` Re: [PoC] Federated Authn/z with OAUTHBEARER Andres Freund <[email protected]>
2025-07-09 17:59                                                                             ` Re: [PoC] Federated Authn/z with OAUTHBEARER Jacob Champion <[email protected]>
2025-07-09 18:13                                                                               ` Re: [PoC] Federated Authn/z with OAUTHBEARER Tom Lane <[email protected]>
2025-07-09 18:39                                                                                 ` Re: [PoC] Federated Authn/z with OAUTHBEARER Jacob Champion <[email protected]>
2025-07-09 19:07                                                                                   ` Re: [PoC] Federated Authn/z with OAUTHBEARER Tom Lane <[email protected]>
2025-07-09 19:28                                                                                     ` Re: [PoC] Federated Authn/z with OAUTHBEARER Jacob Champion <[email protected]>
2025-07-09 19:42                                                                                       ` Re: [PoC] Federated Authn/z with OAUTHBEARER Tom Lane <[email protected]>
2025-07-09 23:54                                                                                         ` Re: [PoC] Federated Authn/z with OAUTHBEARER Jacob Champion <[email protected]>
2025-07-10 14:41                                                                                           ` Re: [PoC] Federated Authn/z with OAUTHBEARER [email protected]
2025-07-11 17:33                                                                                             ` Re: [PoC] Federated Authn/z with OAUTHBEARER Jacob Champion <[email protected]>
2025-02-28 23:38                                                 ` Re: [PoC] Federated Authn/z with OAUTHBEARER Thomas Munro <[email protected]>
2025-02-20 20:07                                 ` Re: [PoC] Federated Authn/z with OAUTHBEARER Tom Lane <[email protected]>
2025-02-20 20:21                                   ` Re: [PoC] Federated Authn/z with OAUTHBEARER Jacob Champion <[email protected]>
2025-02-20 20:37                                     ` Re: [PoC] Federated Authn/z with OAUTHBEARER Daniel Gustafsson <[email protected]>
2025-02-23 16:49                                 ` Re: [PoC] Federated Authn/z with OAUTHBEARER Tom Lane <[email protected]>
2025-02-23 17:08                                   ` Re: [PoC] Federated Authn/z with OAUTHBEARER Daniel Gustafsson <[email protected]>
2025-02-23 23:45                                     ` Re: [PoC] Federated Authn/z with OAUTHBEARER Daniel Gustafsson <[email protected]>
2025-02-24 09:00                                       ` Re: [PoC] Federated Authn/z with OAUTHBEARER Daniel Gustafsson <[email protected]>
2025-02-24 17:43                                         ` Re: [PoC] Federated Authn/z with OAUTHBEARER Jacob Champion <[email protected]>
2025-02-24 17:39                                   ` Re: [PoC] Federated Authn/z with OAUTHBEARER Jacob Champion <[email protected]>
2025-02-24 14:41                                 ` Re: [PoC] Federated Authn/z with OAUTHBEARER Andrew Dunstan <[email protected]>
2025-02-24 14:48                                   ` Re: [PoC] Federated Authn/z with OAUTHBEARER Daniel Gustafsson <[email protected]>
2025-02-24 15:10                                     ` Re: [PoC] Federated Authn/z with OAUTHBEARER Andres Freund <[email protected]>

This inbox is served by agora; see mirroring instructions
for how to clone and mirror all data and code used for this inbox