From da6c573a346ff702a765fdbcf2da84b0b6a4b563 Mon Sep 17 00:00:00 2001
From: Jacob Champion <pchampion@vmware.com>
Date: Tue, 13 Apr 2021 10:27:27 -0700
Subject: [PATCH v26 2/5] libpq: 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) on the client side. When speaking to a OAuth-enabled
server, it looks a bit like this:

    $ psql 'host=example.org oauth_client_id=f02c6361-0635-...'
    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; see
below).

The client implementation requires libcurl and its development headers.
Pass `curl` to --with-oauth/-Doauth during configuration.

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

= Debug Mode =

A "dangerous debugging mode" may be enabled in libpq, by setting the
environment variable PGOAUTHDEBUG=UNSAFE. This will do several things
that you will not want in a production system:

- permits the use of plaintext HTTP in the OAuth provider exchange
- sprays HTTP traffic, containing several critical secrets, to stderr
- permits the use of zero-second retry intervals, which can DoS the
  client

= PQauthDataHook =

Clients may override two pieces of OAuth handling using the new
PQsetAuthDataHook():

- PQAUTHDATA_PROMPT_OAUTH_DEVICE: replaces the default user prompt to
  standard error when using the builtin device authorization flow

- PQAUTHDATA_OAUTH_BEARER_TOKEN: replaces the entire OAuth flow with a
  custom asynchronous implementation

In general, a hook implementation should examine the incoming `type` 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
PQgetAuthDataHook()). Otherwise, it should return an integer > 0 and
follow the authdata-specific instructions. Returning an integer < 0
signals an error condition and abandons the connection attempt.

== PQAUTHDATA_PROMPT_OAUTH_DEVICE ==

The hook should display the device prompt (URL + code) using whatever
method it prefers.

== PQAUTHDATA_OAUTH_BEARER_TOKEN ==

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. See the
documentation for PQoauthBearerRequest.

Several TODOs:
- don't retry forever if the server won't accept our token
- perform several sanity checks on the OAuth issuer's responses
- handle cases where the client has been set up with an issuer and
  scope, but the Postgres server wants to use something different
- improve error debuggability during the OAuth handshake
- fix libcurl initialization thread-safety
- harden the libcurl flow implementation
- figure out pgsocket/int difference on Windows
- fix intermittent failure in the cleanup callback tests (race
  condition?)
- support require_auth
- fill in documentation stubs
- support protocol "variants" implemented by major providers
- ...and more.

Co-authored-by: Daniel Gustafsson <daniel@yesql.se>
---
 config/programs.m4                        |   19 +
 configure                                 |  144 ++
 configure.ac                              |   29 +
 doc/src/sgml/libpq.sgml                   |   76 +
 meson.build                               |   31 +
 meson_options.txt                         |    4 +
 src/Makefile.global.in                    |    1 +
 src/include/common/oauth-common.h         |   19 +
 src/include/pg_config.h.in                |    9 +
 src/interfaces/libpq/Makefile             |   14 +-
 src/interfaces/libpq/exports.txt          |    3 +
 src/interfaces/libpq/fe-auth-oauth-curl.c | 2222 +++++++++++++++++++++
 src/interfaces/libpq/fe-auth-oauth.c      |  659 ++++++
 src/interfaces/libpq/fe-auth-oauth.h      |   42 +
 src/interfaces/libpq/fe-auth-sasl.h       |   10 +-
 src/interfaces/libpq/fe-auth-scram.c      |    6 +-
 src/interfaces/libpq/fe-auth.c            |  105 +-
 src/interfaces/libpq/fe-auth.h            |    9 +-
 src/interfaces/libpq/fe-connect.c         |   85 +-
 src/interfaces/libpq/fe-misc.c            |    7 +-
 src/interfaces/libpq/libpq-fe.h           |   75 +
 src/interfaces/libpq/libpq-int.h          |   15 +
 src/interfaces/libpq/meson.build          |    7 +
 src/makefiles/meson.build                 |    1 +
 src/tools/pgindent/typedefs.list          |   10 +
 25 files changed, 3575 insertions(+), 27 deletions(-)
 create mode 100644 src/include/common/oauth-common.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

diff --git a/config/programs.m4 b/config/programs.m4
index 490ec9fe9f..c58ca50ece 100644
--- a/config/programs.m4
+++ b/config/programs.m4
@@ -142,7 +142,26 @@ if test "$pgac_cv_ldap_safe" != yes; then
 *** also uses LDAP will crash on exit.])
 fi])
 
+# PGAC_CHECK_LIBCURL
+# ------------------
+# Check for libcurl 7.61.0 or higher (corresponding to RHEL8 and the ability to
+# explicitly set TLS 1.3 ciphersuites).
 
+AC_DEFUN([PGAC_CHECK_LIBCURL],
+[AC_CACHE_CHECK([for compatible libcurl], [pgac_cv_check_libcurl],
+[AC_COMPILE_IFELSE([AC_LANG_PROGRAM(
+[#include <curl/curlver.h>
+#if LIBCURL_VERSION_MAJOR < 7 || (LIBCURL_VERSION_MAJOR == 7 && LIBCURL_VERSION_MINOR < 61)
+choke me
+#endif], [])],
+[pgac_cv_check_libcurl=yes],
+[pgac_cv_check_libcurl=no])])
+
+if test "$pgac_cv_check_libcurl" != yes; then
+    AC_MSG_ERROR([
+*** The installed version of libcurl is too old to use with PostgreSQL.
+*** libcurl version 7.61.0 or later is required.])
+fi])
 
 # PGAC_CHECK_READLINE
 # -------------------
diff --git a/configure b/configure
index 2abbeb2794..adeb0c1e63 100755
--- a/configure
+++ b/configure
@@ -715,6 +715,7 @@ with_uuid
 with_readline
 with_systemd
 with_selinux
+with_oauth
 with_ldap
 with_krb_srvnam
 krb_srvtab
@@ -860,6 +861,7 @@ with_krb_srvnam
 with_pam
 with_bsd_auth
 with_ldap
+with_oauth
 with_bonjour
 with_selinux
 with_systemd
@@ -1568,6 +1570,7 @@ Optional Packages:
   --with-pam              build with PAM support
   --with-bsd-auth         build with BSD Authentication support
   --with-ldap             build with LDAP support
+  --with-oauth=LIB        use LIB for OAuth 2.0 support (curl)
   --with-bonjour          build with Bonjour support
   --with-selinux          build with SELinux support
   --with-systemd          build with systemd support
@@ -8479,6 +8482,52 @@ $as_echo "$with_ldap" >&6; }
 
 
 
+#
+# OAuth 2.0
+#
+{ $as_echo "$as_me:${as_lineno-$LINENO}: checking whether to build with OAuth support" >&5
+$as_echo_n "checking whether to build with OAuth support... " >&6; }
+
+
+
+# Check whether --with-oauth was given.
+if test "${with_oauth+set}" = set; then :
+  withval=$with_oauth;
+  case $withval in
+    yes)
+      as_fn_error $? "argument required for --with-oauth option" "$LINENO" 5
+      ;;
+    no)
+      as_fn_error $? "argument required for --with-oauth option" "$LINENO" 5
+      ;;
+    *)
+
+      ;;
+  esac
+
+fi
+
+
+if test x"$with_oauth" = x"" ; then
+  with_oauth=no
+fi
+
+if test x"$with_oauth" = x"curl"; then
+
+$as_echo "#define USE_OAUTH 1" >>confdefs.h
+
+
+$as_echo "#define USE_OAUTH_CURL 1" >>confdefs.h
+
+elif test x"$with_oauth" != x"no"; then
+  as_fn_error $? "--with-oauth must specify curl" "$LINENO" 5
+fi
+
+{ $as_echo "$as_me:${as_lineno-$LINENO}: result: $with_oauth" >&5
+$as_echo "$with_oauth" >&6; }
+
+
+
 #
 # Bonjour
 #
@@ -13010,6 +13059,90 @@ fi
 
 
 
+if test "$with_oauth" = curl ; 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-oauth=curl" "$LINENO" 5
+fi
+
+  { $as_echo "$as_me:${as_lineno-$LINENO}: checking for compatible libcurl" >&5
+$as_echo_n "checking for compatible libcurl... " >&6; }
+if ${pgac_cv_check_libcurl+:} false; then :
+  $as_echo_n "(cached) " >&6
+else
+  cat confdefs.h - <<_ACEOF >conftest.$ac_ext
+/* end confdefs.h.  */
+#include <curl/curlver.h>
+#if LIBCURL_VERSION_MAJOR < 7 || (LIBCURL_VERSION_MAJOR == 7 && LIBCURL_VERSION_MINOR < 61)
+choke me
+#endif
+int
+main ()
+{
+
+  ;
+  return 0;
+}
+_ACEOF
+if ac_fn_c_try_compile "$LINENO"; then :
+  pgac_cv_check_libcurl=yes
+else
+  pgac_cv_check_libcurl=no
+fi
+rm -f core conftest.err conftest.$ac_objext conftest.$ac_ext
+fi
+{ $as_echo "$as_me:${as_lineno-$LINENO}: result: $pgac_cv_check_libcurl" >&5
+$as_echo "$pgac_cv_check_libcurl" >&6; }
+
+if test "$pgac_cv_check_libcurl" != yes; then
+    as_fn_error $? "
+*** The installed version of libcurl is too old to use with PostgreSQL.
+*** libcurl version 7.61.0 or later is required." "$LINENO" 5
+fi
+fi
+
 # for contrib/sepgsql
 if test "$with_selinux" = yes; then
   { $as_echo "$as_me:${as_lineno-$LINENO}: checking for security_compute_create_name in -lselinux" >&5
@@ -14035,6 +14168,17 @@ fi
 
 done
 
+fi
+
+if test "$with_oauth" = curl; 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 OAuth" "$LINENO" 5
+fi
+
+
 fi
 
 if test "$PORTNAME" = "win32" ; then
diff --git a/configure.ac b/configure.ac
index c46ed2c591..099198ef12 100644
--- a/configure.ac
+++ b/configure.ac
@@ -924,6 +924,26 @@ AC_MSG_RESULT([$with_ldap])
 AC_SUBST(with_ldap)
 
 
+#
+# OAuth 2.0
+#
+AC_MSG_CHECKING([whether to build with OAuth support])
+PGAC_ARG_REQ(with, oauth, [LIB], [use LIB for OAuth 2.0 support (curl)])
+if test x"$with_oauth" = x"" ; then
+  with_oauth=no
+fi
+
+if test x"$with_oauth" = x"curl"; then
+  AC_DEFINE([USE_OAUTH], 1, [Define to 1 to build with OAuth 2.0 support. (--with-oauth)])
+  AC_DEFINE([USE_OAUTH_CURL], 1, [Define to 1 to use libcurl for OAuth support.])
+elif test x"$with_oauth" != x"no"; then
+  AC_MSG_ERROR([--with-oauth must specify curl])
+fi
+
+AC_MSG_RESULT([$with_oauth])
+AC_SUBST(with_oauth)
+
+
 #
 # Bonjour
 #
@@ -1406,6 +1426,11 @@ fi
 AC_SUBST(LDAP_LIBS_FE)
 AC_SUBST(LDAP_LIBS_BE)
 
+if test "$with_oauth" = curl ; then
+  AC_CHECK_LIB(curl, curl_multi_init, [], [AC_MSG_ERROR([library 'curl' is required for --with-oauth=curl])])
+  PGAC_CHECK_LIBCURL
+fi
+
 # for contrib/sepgsql
 if test "$with_selinux" = yes; then
   AC_CHECK_LIB(selinux, security_compute_create_name, [],
@@ -1596,6 +1621,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_oauth" = curl; then
+  AC_CHECK_HEADER(curl/curl.h, [], [AC_MSG_ERROR([header file <curl/curl.h> is required for OAuth])])
+fi
+
 if test "$PORTNAME" = "win32" ; then
    AC_CHECK_HEADERS(crtdefs.h)
 fi
diff --git a/doc/src/sgml/libpq.sgml b/doc/src/sgml/libpq.sgml
index 068ee60771..6a20247ef9 100644
--- a/doc/src/sgml/libpq.sgml
+++ b/doc/src/sgml/libpq.sgml
@@ -2335,6 +2335,43 @@ postgresql://%2Fvar%2Flib%2Fpostgresql/dbname
        </para>
       </listitem>
      </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-client-id" xreflabel="oauth_client_id">
+      <term><literal>oauth_client_id</literal></term>
+      <listitem>
+       <para>
+        TODO
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-client-secret" xreflabel="oauth_client_secret">
+      <term><literal>oauth_client_secret</literal></term>
+      <listitem>
+       <para>
+        TODO
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-issuer" xreflabel="oauth_issuer">
+      <term><literal>oauth_issuer</literal></term>
+      <listitem>
+       <para>
+        TODO
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-scope" xreflabel="oauth_scope">
+      <term><literal>oauth_scope</literal></term>
+      <listitem>
+       <para>
+        TODO
+       </para>
+      </listitem>
+     </varlistentry>
+
     </variablelist>
    </para>
   </sect2>
@@ -9938,6 +9975,45 @@ void PQinitSSL(int do_ssl);
 
  </sect1>
 
+ <sect1 id="libpq-oauth">
+  <title>OAuth Support</title>
+
+  <para>
+   TODO
+  </para>
+
+  <para>
+   <variablelist>
+    <varlistentry id="libpq-PQsetAuthDataHook">
+     <term><function>PQsetAuthDataHook</function><indexterm><primary>PQsetAuthDataHook</primary></indexterm></term>
+
+     <listitem>
+      <para>
+       TODO
+<synopsis>
+void PQsetAuthDataHook(PQauthDataHook_type hook);
+</synopsis>
+      </para>
+     </listitem>
+    </varlistentry>
+
+    <varlistentry id="libpq-PQgetAuthDataHook">
+     <term><function>PQgetAuthDataHook</function><indexterm><primary>PQgetAuthDataHook</primary></indexterm></term>
+
+     <listitem>
+      <para>
+       TODO
+<synopsis>
+PQauthDataHook_type PQgetAuthDataHook(void);
+</synopsis>
+      </para>
+     </listitem>
+    </varlistentry>
+   </variablelist>
+  </para>
+
+ </sect1>
+
 
  <sect1 id="libpq-threading">
   <title>Behavior in Threaded Programs</title>
diff --git a/meson.build b/meson.build
index cd711c6d01..a40c4dd7f6 100644
--- a/meson.build
+++ b/meson.build
@@ -912,6 +912,35 @@ endif
 
 
 
+###############################################################
+# Library: oauth
+###############################################################
+
+oauth = not_found_dep
+oauth_library = 'none'
+oauthopt = get_option('oauth')
+
+if oauthopt == 'auto' and auto_features.disabled()
+  oauthopt = 'none'
+endif
+
+if oauthopt in ['auto', 'curl']
+  # Check for libcurl 7.61.0 or higher (corresponding to RHEL8 and the ability
+  # to explicitly set TLS 1.3 ciphersuites).
+  oauth = dependency('libcurl', version: '>= 7.61.0', required: (oauthopt == 'curl'))
+
+  if oauth.found()
+    oauth_library = 'curl'
+    cdata.set('USE_OAUTH', 1)
+    cdata.set('USE_OAUTH_CURL', 1)
+  endif
+endif
+
+if oauthopt == 'auto' and auto_features.enabled() and not oauth.found()
+  error('no OAuth implementation library found')
+endif
+
+
 ###############################################################
 # Library: Tcl (for pltcl)
 #
@@ -3076,6 +3105,7 @@ libpq_deps += [
   gssapi,
   ldap_r,
   libintl,
+  oauth,
   ssl,
 ]
 
@@ -3748,6 +3778,7 @@ if meson.version().version_compare('>=0.57')
       'llvm': llvm,
       'lz4': lz4,
       'nls': libintl,
+      'oauth': oauth,
       'openssl': ssl,
       'pam': pam,
       'plperl': perl_dep,
diff --git a/meson_options.txt b/meson_options.txt
index b942155760..ffdfd57751 100644
--- a/meson_options.txt
+++ b/meson_options.txt
@@ -118,6 +118,10 @@ option('lz4', type: 'feature', value: 'auto',
 option('nls', type: 'feature', value: 'auto',
   description: 'Native language support')
 
+option('oauth', type : 'combo', choices : ['auto', 'none', 'curl'],
+  value: 'auto',
+  description: 'use LIB for OAuth 2.0 support (curl)')
+
 option('pam', type: 'feature', value: 'auto',
   description: 'PAM support')
 
diff --git a/src/Makefile.global.in b/src/Makefile.global.in
index 42f50b4976..9b81b6fd58 100644
--- a/src/Makefile.global.in
+++ b/src/Makefile.global.in
@@ -193,6 +193,7 @@ with_ldap	= @with_ldap@
 with_libxml	= @with_libxml@
 with_libxslt	= @with_libxslt@
 with_llvm	= @with_llvm@
+with_oauth	= @with_oauth@
 with_system_tzdata = @with_system_tzdata@
 with_uuid	= @with_uuid@
 with_zlib	= @with_zlib@
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/pg_config.h.in b/src/include/pg_config.h.in
index 979925cc2e..196c96fbb8 100644
--- a/src/include/pg_config.h.in
+++ b/src/include/pg_config.h.in
@@ -244,6 +244,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
 
@@ -727,6 +730,12 @@
 /* Define to select named POSIX semaphores. */
 #undef USE_NAMED_POSIX_SEMAPHORES
 
+/* Define to 1 to build with OAuth 2.0 support. (--with-oauth) */
+#undef USE_OAUTH
+
+/* Define to 1 to use libcurl for OAuth support. */
+#undef USE_OAUTH_CURL
+
 /* Define to 1 to build with OpenSSL support. (--with-ssl=openssl) */
 #undef USE_OPENSSL
 
diff --git a/src/interfaces/libpq/Makefile b/src/interfaces/libpq/Makefile
index 27f8499d8a..7d593778ec 100644
--- a/src/interfaces/libpq/Makefile
+++ b/src/interfaces/libpq/Makefile
@@ -63,6 +63,14 @@ OBJS += \
 	fe-secure-gssapi.o
 endif
 
+ifneq ($(with_oauth),no)
+OBJS += fe-auth-oauth.o
+
+ifeq ($(with_oauth),curl)
+OBJS += fe-auth-oauth-curl.o
+endif
+endif
+
 ifeq ($(PORTNAME), cygwin)
 override shlib = cyg$(NAME)$(DLSUFFIX)
 endif
@@ -81,7 +89,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 +118,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 +127,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 5d8213e0b5..eb8f9d65a1 100644
--- a/src/interfaces/libpq/exports.txt
+++ b/src/interfaces/libpq/exports.txt
@@ -205,3 +205,6 @@ PQcancelFinish            202
 PQsocketPoll              203
 PQsetChunkedRowsMode      204
 PQgetCurrentTimeUSec      205
+PQsetAuthDataHook         206
+PQgetAuthDataHook         207
+PQdefaultAuthDataHook     208
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..435abee56a
--- /dev/null
+++ b/src/interfaces/libpq/fe-auth-oauth-curl.c
@@ -0,0 +1,2222 @@
+/*-------------------------------------------------------------------------
+ *
+ * fe-auth-oauth-curl.c
+ *	   The libcurl implementation of OAuth/OIDC authentication.
+ *
+ * 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"
+
+/*
+ * 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. */
+typedef enum
+{
+	OAUTH_STEP_INIT,
+	OAUTH_STEP_DISCOVERY,
+	OAUTH_STEP_DEVICE_AUTHORIZATION,
+	OAUTH_STEP_TOKEN_REQUEST,
+	OAUTH_STEP_WAIT_INTERVAL,
+} OAuthStep;
+
+/*
+ * 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
+{
+	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		debugging;		/* can we give unsafe developer assistance? */
+};
+
+/*
+ * Frees the async_ctx, which is stored directly on the PGconn. This is called
+ * during pqDropConnection() so that we don't leak resources even if
+ * PQconnectPoll() never calls us back.
+ *
+ * TODO: we should probably call this at the end of a successful authentication,
+ * too, to proactively free up resources.
+ */
+static void
+free_curl_async_ctx(PGconn *conn, void *ctx)
+{
+	struct async_ctx *actx = ctx;
+
+	Assert(actx);				/* oauth_free() shouldn't call us otherwise */
+
+	/*
+	 * 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);
+}
+
+/*
+ * 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 */
+	};
+
+	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;
+		}
+	}
+
+	free(name);
+	return JSON_SUCCESS;
+}
+
+static JsonParseErrorType
+oauth_json_object_end(void *state)
+{
+	struct oauth_parse *ctx = state;
+
+	--ctx->nested;
+	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;
+	JsonParseErrorType result = JSON_SUCCESS;
+
+	if (!ctx->nested)
+	{
+		oauth_parse_set_error(ctx, "top-level element must be an object");
+		result = JSON_SEM_ACTION_FAILED;
+		goto cleanup;
+	}
+
+	if (ctx->active)
+	{
+		JsonTokenType expected;
+
+		/*
+		 * Make sure this matches what the active field expects. Arrays must
+		 * contain only strings with the current implementation.
+		 */
+		if (ctx->active->type == JSON_TOKEN_ARRAY_START)
+			expected = JSON_TOKEN_STRING;
+		else
+			expected = ctx->active->type;
+
+		if (type != expected)
+		{
+			report_type_mismatch(ctx);
+			result = JSON_SEM_ACTION_FAILED;
+			goto cleanup;
+		}
+
+		/*
+		 * FIXME if the JSON field is duplicated, we'll leak the prior value.
+		 * Error out in that case instead.
+		 */
+		if (ctx->active->type != JSON_TOKEN_ARRAY_START)
+		{
+			Assert(ctx->nested == 1);
+
+			*ctx->active->scalar = token;
+			ctx->active = NULL;
+
+			return JSON_SUCCESS;	/* don't free the token */
+		}
+		else					/* ctx->target_array */
+		{
+			struct curl_slist *temp;
+
+			Assert(ctx->nested == 2);
+
+			temp = curl_slist_append(*ctx->active->array, token);
+			if (!temp)
+			{
+				oauth_parse_set_error(ctx, "out of memory");
+				result = JSON_SEM_ACTION_FAILED;
+				goto cleanup;
+			}
+
+			*ctx->active->array = temp;
+
+			/*
+			 * Note that curl_slist_append() makes a copy of the token, so we
+			 * can free it below.
+			 */
+		}
+	}
+	else
+	{
+		/* otherwise we just ignore it */
+	}
+
+cleanup:
+	free(token);
+	return result;
+}
+
+/*
+ * 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! */
+
+			/* HTTP optional whitespace allows only spaces and htabs. */
+			case ' ':
+			case '\t':
+				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;
+	}
+
+	makeJsonLexContextCstringLen(&lex, resp->data, resp->len, PG_UTF8, true);
+
+	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->scalar && !*fields->array)
+		{
+			actx_error(actx, "field \"%s\" is missing", fields->name);
+			goto cleanup;
+		}
+
+		fields++;
+	}
+
+	success = true;
+
+cleanup:
+	freeJsonLexContext(&lex);
+	return success;
+}
+
+/*
+ * JSON Parser Definitions
+ */
+
+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;
+}
+
+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 specify 5 seconds as the default value if the server
+		 * doesn't provide an interval.
+		 */
+		authz->interval = 5;
+	}
+
+	return true;
+}
+
+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;
+}
+
+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, "here's a nickel kid, get yourself a better computer");
+	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.
+	 *
+	 * TODO: handle SIGPIPE via pq_block_sigpipe(), or via a
+	 * CURLOPT_SOCKOPTFUNCTION maybe...
+	 */
+	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);
+	}
+
+	/*
+	 * 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:");	/* TODO: check result */
+	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)
+{
+	PQExpBuffer resp = userdata;
+	size_t		len = size * nmemb;
+
+	/* TODO: cap the maximum size */
+	appendBinaryPQExpBuffer(resp, buf, len);
+	/* TODO: check for broken buffer */
+
+	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->work_data, 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.
+		 *
+		 * TODO: curl_multi_socket_all() is deprecated, presumably because it's
+		 * inefficient and pointless if your event loop has already handed you
+		 * the exact sockets that are ready. But that's not our use case --
+		 * our client has no way to tell us which sockets are ready. (They don't
+		 * even know there are sockets to begin with.)
+		 *
+		 * We can grab the list of triggered events from the multiplexer
+		 * ourselves, but that's effectively what curl_multi_socket_all() is
+		 * going to do... so it appears to be exactly the API we need.
+		 *
+		 * Ignore the deprecation for now. This needs a followup on
+		 * curl-library@, to make sure we're not shooting ourselves in the foot
+		 * in some other way.
+		 */
+		CURL_IGNORE_DEPRECATION(
+			err = curl_multi_socket_all(actx->curlm, &actx->running);
+		)
+
+		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)
+		{
+			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;
+}
+
+/*
+ * 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;
+}
+
+#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 get_discovery_document() */
+
+	/*------
+	 * 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;
+}
+
+/*
+ * 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 get_auth_token() */
+	Assert(device_authz_uri);	/* ensured by check_for_device_flow() */
+
+	/* Construct our request body. TODO: url-encode */
+	resetPQExpBuffer(work_buffer);
+	appendPQExpBuffer(work_buffer, "client_id=%s", conn->oauth_client_id);
+	if (conn->oauth_scope)
+		appendPQExpBuffer(work_buffer, "&scope=%s", conn->oauth_scope);
+	/* TODO check for broken buffer */
+
+	/* Make our request. */
+	CHECK_SETOPT(actx, CURLOPT_URL, device_authz_uri, return false);
+	CHECK_SETOPT(actx, CURLOPT_COPYPOSTFIELDS, work_buffer->data, return false);
+
+	if (conn->oauth_client_secret)
+	{
+		/*----
+		 * Use HTTP Basic auth to send the password. 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).
+		 *
+		 * TODO: should we omit client_id from the body in this case?
+		 */
+		CHECK_SETOPT(actx, CURLOPT_HTTPAUTH, CURLAUTH_BASIC, return false);
+		CHECK_SETOPT(actx, CURLOPT_USERNAME, conn->oauth_client_id, return false);
+		CHECK_SETOPT(actx, CURLOPT_PASSWORD, conn->oauth_client_secret, return false);
+	}
+	else
+		CHECK_SETOPT(actx, CURLOPT_HTTPAUTH, CURLAUTH_NONE, 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);
+
+	/*
+	 * The device authorization endpoint uses the same error response as the
+	 * token endpoint, so we have to handle 400/401 here too.
+	 */
+	if (response_code != 200
+		&& response_code != 400
+		 /* && response_code != 401 TODO */ )
+	{
+		actx_error(actx, "unexpected response code %ld", response_code);
+		return false;
+	}
+
+	if (response_code != 200)
+	{
+		struct token_error err = {0};
+
+		if (!parse_token_error(actx, &err))
+		{
+			free_token_error(&err);
+			return false;
+		}
+
+		if (err.error_description)
+			appendPQExpBuffer(&actx->errbuf, "%s ", err.error_description);
+
+		appendPQExpBuffer(&actx->errbuf, "(%s)", err.error);
+
+		free_token_error(&err);
+		return false;
+	}
+
+	/*
+	 * Pull the fields we care about from the document.
+	 */
+	actx->errctx = "failed to parse device authorization";
+	if (!parse_device_authz(actx, &actx->authz))
+		return false;			/* error message already set */
+
+	return true;
+}
+
+/*
+ * 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 get_auth_token() */
+	Assert(token_uri);			/* ensured by get_discovery_document() */
+	Assert(device_code);		/* ensured by run_device_authz() */
+
+	/* Construct our request body. TODO: url-encode */
+	resetPQExpBuffer(work_buffer);
+	appendPQExpBuffer(work_buffer, "client_id=%s", conn->oauth_client_id);
+	appendPQExpBuffer(work_buffer, "&device_code=%s", device_code);
+	appendPQExpBuffer(work_buffer, "&grant_type=%s",
+					  OAUTH_GRANT_TYPE_DEVICE_CODE);
+	/* TODO check for broken buffer */
+
+	/* Make our request. */
+	CHECK_SETOPT(actx, CURLOPT_URL, token_uri, return false);
+	CHECK_SETOPT(actx, CURLOPT_COPYPOSTFIELDS, work_buffer->data, return false);
+
+	if (conn->oauth_client_secret)
+	{
+		/*----
+		 * Use HTTP Basic auth to send the password. 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).
+		 *
+		 * TODO: should we omit client_id from the body in this case?
+		 */
+		CHECK_SETOPT(actx, CURLOPT_HTTPAUTH, CURLAUTH_BASIC, return false);
+		CHECK_SETOPT(actx, CURLOPT_USERNAME, conn->oauth_client_id, return false);
+		CHECK_SETOPT(actx, CURLOPT_PASSWORD, conn->oauth_client_secret, return false);
+	}
+	else
+		CHECK_SETOPT(actx, CURLOPT_HTTPAUTH, CURLAUTH_NONE, return false);
+
+	resetPQExpBuffer(work_buffer);
+	CHECK_SETOPT(actx, CURLOPT_WRITEFUNCTION, append_data, return false);
+	CHECK_SETOPT(actx, CURLOPT_WRITEDATA, work_buffer, 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. An error
+	 * response uses either 400 Bad Request or 401 Unauthorized.
+	 *
+	 * TODO: there are references online to 403 appearing in the wild...
+	 */
+	if (response_code != 200
+		&& response_code != 400
+		 /* && response_code != 401 TODO */ )
+	{
+		actx_error(actx, "unexpected response code %ld", response_code);
+		return false;
+	}
+
+	/*
+	 * Pull the fields we care about from the document.
+	 */
+	if (response_code == 200)
+	{
+		actx->errctx = "failed to parse access token response";
+		if (!parse_access_token(actx, tok))
+			return false;		/* error message already set */
+	}
+	else if (!parse_token_error(actx, &tok->err))
+		return false;
+
+	return true;
+}
+
+/*
+ * 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;
+
+	if (tok.access_token)
+	{
+		/* Construct our Bearer token. */
+		resetPQExpBuffer(&actx->work_data);
+		appendPQExpBuffer(&actx->work_data, "Bearer %s", tok.access_token);
+
+		if (PQExpBufferDataBroken(actx->work_data))
+		{
+			actx_error(actx, "out of memory");
+			goto token_cleanup;
+		}
+
+		*token = strdup(actx->work_data.data);
+		if (!*token)
+		{
+			actx_error(actx, "out of memory");
+			goto token_cleanup;
+		}
+
+		success = true;
+		goto token_cleanup;
+	}
+
+	/*
+	 * authorization_pending and slow_down are the only
+	 * acceptable errors; anything else and we bail.
+	 */
+	err = &tok.err;
+	if (!err->error)
+	{
+		/* TODO test */
+		actx_error(actx, "unknown error");
+		goto token_cleanup;
+	}
+
+	if (strcmp(err->error, "authorization_pending") != 0 &&
+		strcmp(err->error, "slow_down") != 0)
+	{
+		if (err->error_description)
+			appendPQExpBuffer(&actx->errbuf, "%s ", err->error_description);
+
+		appendPQExpBuffer(&actx->errbuf, "(%s)", err->error);
+		goto token_cleanup;
+	}
+
+	/*
+	 * A slow_down error requires us to permanently increase
+	 * our retry interval by five seconds. RFC 8628, Sec. 3.5.
+	 */
+	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;
+	PQpromptOAuthDevice 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)
+	{
+		fprintf(stderr, "Visit %s and enter the code: %s",
+				prompt.verification_uri, prompt.user_code);
+	}
+	else if (res < 0)
+	{
+		actx_error(actx, "device prompt failed");
+		return false;
+	}
+
+	return true;
+}
+
+
+/*
+ * The top-level, nonblocking entry point for the 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.
+ */
+PostgresPollingStatusType
+pg_fe_run_oauth_flow(PGconn *conn, pgsocket *altsock)
+{
+	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)
+	{
+		const char *env;
+
+		/*
+		 * Create our asynchronous state, and hook it into the upper-level
+		 * OAuth state immediately, so any failures below won't leak the
+		 * 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? */
+		env = getenv("PGOAUTHDEBUG");
+		if (env && strcmp(env, "UNSAFE") == 0)
+			actx->debugging = true;
+
+		state->async_ctx = actx;
+		state->free_async_ctx = free_curl_async_ctx;
+
+		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. */
+		*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;
+
+				/* TODO: check issuer */
+
+				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, &state->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 (state->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.)
+				 */
+				*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 (!state->token && !actx->running);
+
+	/* If we've stored a token, we're done. Otherwise come back later. */
+	return state->token ? PGRES_POLLING_OK : PGRES_POLLING_READING;
+
+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;
+}
diff --git a/src/interfaces/libpq/fe-auth-oauth.c b/src/interfaces/libpq/fe-auth-oauth.c
new file mode 100644
index 0000000000..f943a31cc0
--- /dev/null
+++ b/src/interfaces/libpq/fe-auth-oauth.c
@@ -0,0 +1,659 @@
+/*-------------------------------------------------------------------------
+ *
+ * fe-auth-oauth.c
+ *	   The front-end (client) implementation of OAuth/OIDC authentication.
+ *
+ * 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,
+};
+
+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->state = FE_OAUTH_INIT;
+	state->conn = conn;
+
+	return state;
+}
+
+#define kvsep "\x01"
+
+static char *
+client_initial_response(PGconn *conn, const char *token)
+{
+	static const char *const resp_format = "n,," kvsep "auth=%s" kvsep kvsep;
+
+	PQExpBufferData buf;
+	char	   *response = NULL;
+
+	if (!token)
+	{
+		/*
+		 * Either programmer error, or something went badly wrong during the
+		 * asynchronous fetch.
+		 *
+		 * TODO: users shouldn't see this; what action should they take if
+		 * they do?
+		 */
+		libpq_append_conn_error(conn, "no OAuth token was set for the connection");
+		return NULL;
+	}
+
+	initPQExpBuffer(&buf);
+	appendPQExpBuffer(&buf, resp_format, token);
+
+	if (!PQExpBufferDataBroken(buf))
+		response = strdup(buf.data);
+
+	termPQExpBuffer(&buf);
+	return response;
+}
+
+#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;
+		}
+	}
+
+	free(name);
+	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");
+	}
+	else if (ctx->target_field)
+	{
+		Assert(ctx->nested == 1);
+
+		if (type == JSON_TOKEN_STRING)
+		{
+			*ctx->target_field = token;
+
+			ctx->target_field = NULL;
+			ctx->target_field_name = NULL;
+
+			return JSON_SUCCESS;	/* don't free the token we're using */
+		}
+
+		oauth_json_set_error(ctx,
+							 libpq_gettext("field \"%s\" must be a string"),
+							 ctx->target_field_name);
+	}
+
+	free(token);
+	return oauth_json_has_error(ctx) ? JSON_SEM_ACTION_FAILED : JSON_SUCCESS;
+}
+
+static bool
+handle_oauth_sasl_error(PGconn *conn, char *msg, int msglen)
+{
+	JsonLexContext lex = {0};
+	JsonSemAction sem = {0};
+	JsonParseErrorType err;
+	struct json_ctx ctx = {0};
+	char	   *errmsg = NULL;
+
+	/* Sanity check. */
+	if (strlen(msg) != msglen)
+	{
+		appendPQExpBufferStr(&conn->errorMessage,
+							 libpq_gettext("server's error message contained an embedded NULL, and was discarded"));
+		return false;
+	}
+
+	makeJsonLexContextCstringLen(&lex, msg, msglen, PG_UTF8, true);
+
+	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)
+		appendPQExpBuffer(&conn->errorMessage,
+						  libpq_gettext("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)
+		return false;
+
+	/* TODO: what if these override what the user already specified? */
+	if (ctx.discovery_uri)
+	{
+		if (conn->oauth_discovery_uri)
+			free(conn->oauth_discovery_uri);
+
+		conn->oauth_discovery_uri = ctx.discovery_uri;
+	}
+
+	if (ctx.scope)
+	{
+		if (conn->oauth_scope)
+			free(conn->oauth_scope);
+
+		conn->oauth_scope = ctx.scope;
+	}
+	/* TODO: missing error scope should clear any existing connection scope */
+
+	if (!ctx.status)
+	{
+		appendPQExpBuffer(&conn->errorMessage,
+						  libpq_gettext("server sent error response without a status"));
+		return false;
+	}
+
+	if (strcmp(ctx.status, "invalid_token") == 0)
+	{
+		/*
+		 * invalid_token is the only error code we'll automatically retry for,
+		 * but only if we have enough information to do so.
+		 */
+		if (conn->oauth_discovery_uri)
+			conn->oauth_want_retry = true;
+	}
+	/* TODO: include status in hard failure message */
+
+	return true;
+}
+
+static void
+free_request(PGconn *conn, void *vreq)
+{
+	PQoauthBearerRequest *request = vreq;
+
+	if (request->cleanup)
+		request->cleanup(conn, request);
+
+	free(request);
+}
+
+static PostgresPollingStatusType
+run_user_oauth_flow(PGconn *conn, pgsocket *altsock)
+{
+	fe_oauth_state *state = conn->sasl_state;
+	PQoauthBearerRequest *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, 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 state. (We can't hold
+		 * onto the original string, since it may not be safe for us to free()
+		 * it.)
+		 */
+		PQExpBufferData token;
+
+		if (!request->token)
+		{
+			libpq_append_conn_error(conn, "user-defined OAuth flow did not provide a token");
+			return PGRES_POLLING_FAILED;
+		}
+
+		initPQExpBuffer(&token);
+		appendPQExpBuffer(&token, "Bearer %s", request->token);
+
+		if (PQExpBufferDataBroken(token))
+		{
+			libpq_append_conn_error(conn, "out of memory");
+			return PGRES_POLLING_FAILED;
+		}
+
+		state->token = token.data;
+		return PGRES_POLLING_OK;
+	}
+
+	/* TODO: what if no altsock was set? */
+	return status;
+}
+
+static bool
+setup_token_request(PGconn *conn, fe_oauth_state *state)
+{
+	int			res;
+	PQoauthBearerRequest 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)
+	{
+		PQoauthBearerRequest *request_copy;
+
+		if (request.token)
+		{
+			/*
+			 * We already have a token, so copy it into the state. (We can't
+			 * hold onto the original string, since it may not be safe for us
+			 * to free() it.)
+			 */
+			PQExpBufferData token;
+
+			initPQExpBuffer(&token);
+			appendPQExpBuffer(&token, "Bearer %s", request.token);
+
+			if (PQExpBufferDataBroken(token))
+			{
+				libpq_append_conn_error(conn, "out of memory");
+				goto fail;
+			}
+
+			state->token = token.data;
+
+			/* 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;
+		state->async_ctx = request_copy;
+		state->free_async_ctx = free_request;
+	}
+	else if (res < 0)
+	{
+		libpq_append_conn_error(conn, "user-defined OAuth flow failed");
+		goto fail;
+	}
+	else
+	{
+		/* Use our built-in OAuth flow. */
+		conn->async_auth = pg_fe_run_oauth_flow;
+	}
+
+	return true;
+
+fail:
+	if (request.cleanup)
+		request.cleanup(conn, &request);
+	return false;
+}
+
+static bool
+derive_discovery_uri(PGconn *conn)
+{
+	PQExpBufferData discovery_buf;
+
+	if (conn->oauth_discovery_uri || !conn->oauth_issuer)
+	{
+		/*
+		 * Either we already have one, or we aren't able to derive one
+		 * ourselves. The latter case is not an error condition; we'll just
+		 * ask the server to provide one for us.
+		 */
+		return true;
+	}
+
+	initPQExpBuffer(&discovery_buf);
+
+	Assert(!conn->oauth_discovery_uri);
+	Assert(conn->oauth_issuer);
+
+	/*
+	 * If we don't yet have a discovery URI, but the user gave us an explicit
+	 * issuer, use the .well-known discovery URI for that issuer.
+	 */
+	appendPQExpBufferStr(&discovery_buf, conn->oauth_issuer);
+	appendPQExpBufferStr(&discovery_buf, "/.well-known/openid-configuration");
+
+	if (PQExpBufferDataBroken(discovery_buf))
+		goto cleanup;
+
+	conn->oauth_discovery_uri = strdup(discovery_buf.data);
+
+cleanup:
+	termPQExpBuffer(&discovery_buf);
+	return (conn->oauth_discovery_uri != NULL);
+}
+
+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;
+
+	*output = NULL;
+	*outputlen = 0;
+
+	switch (state->state)
+	{
+		case FE_OAUTH_INIT:
+			Assert(inputlen == -1);
+
+			if (!derive_discovery_uri(conn))
+				return SASL_FAILED;
+
+			if (conn->oauth_discovery_uri)
+			{
+				if (!conn->oauth_client_id)
+				{
+					/* We can't talk to a server without a client identifier. */
+					libpq_append_conn_error(conn, "no oauth_client_id is set for the connection");
+					return SASL_FAILED;
+				}
+
+				/*
+				 * Decide whether we're using a user-provided OAuth flow, or
+				 * the one we have built in.
+				 */
+				if (!setup_token_request(conn, state))
+					return SASL_FAILED;
+
+				if (state->token)
+				{
+					/*
+					 * A really smart user implementation may have already
+					 * given us the token (e.g. if there was an unexpired copy
+					 * already cached). In that case, we can just fall
+					 * through.
+					 */
+				}
+				else
+				{
+					/*
+					 * Otherwise, we have to hand the connection over to our
+					 * OAuth implementation. This involves a number of HTTP
+					 * connections and timed waits, so we escape the
+					 * synchronous auth processing and tell PQconnectPoll to
+					 * transfer control to our async implementation.
+					 */
+					Assert(conn->async_auth);	/* should have been set
+												 * already */
+					state->state = FE_OAUTH_REQUESTING_TOKEN;
+					return SASL_ASYNC;
+				}
+			}
+			else
+			{
+				/*
+				 * If we don't have a discovery URI to be able to request a
+				 * token, we ask the server for one explicitly with an empty
+				 * token. This doesn't require any asynchronous work.
+				 */
+				state->token = strdup("");
+				if (!state->token)
+				{
+					libpq_append_conn_error(conn, "out of memory");
+					return SASL_FAILED;
+				}
+			}
+
+			/* fall through */
+
+		case FE_OAUTH_REQUESTING_TOKEN:
+			/* We should still be in the initial response phase. */
+			Assert(inputlen == -1);
+
+			*output = client_initial_response(conn, state->token);
+			if (!*output)
+				return SASL_FAILED;
+
+			*outputlen = strlen(*output);
+			state->state = FE_OAUTH_BEARER_SENT;
+
+			return SASL_CONTINUE;
+
+		case FE_OAUTH_BEARER_SENT:
+			if (final)
+			{
+				/* TODO: ensure there is no message content here. */
+				return SASL_COMPLETE;
+			}
+
+			/*
+			 * Error message sent by the server.
+			 */
+			if (!handle_oauth_sasl_error(conn, input, inputlen))
+				return SASL_FAILED;
+
+			/*
+			 * Respond with the required dummy message (RFC 7628, sec. 3.2.3).
+			 */
+			*output = strdup(kvsep);
+			*outputlen = strlen(*output);	/* == 1 */
+
+			state->state = FE_OAUTH_SERVER_ERROR;
+			return SASL_CONTINUE;
+
+		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.
+			 */
+			appendPQExpBufferStr(&conn->errorMessage,
+								 libpq_gettext("server sent additional OAuth data after error\n"));
+			return SASL_FAILED;
+
+		default:
+			appendPQExpBufferStr(&conn->errorMessage,
+								 libpq_gettext("invalid OAuth exchange state\n"));
+			break;
+	}
+
+	return SASL_FAILED;
+}
+
+static bool
+oauth_channel_bound(void *opaq)
+{
+	/* This mechanism does not support channel binding. */
+	return false;
+}
+
+static void
+oauth_free(void *opaq)
+{
+	fe_oauth_state *state = opaq;
+
+	free(state->token);
+	if (state->async_ctx)
+		state->free_async_ctx(state->conn, state->async_ctx);
+
+	free(state);
+}
diff --git a/src/interfaces/libpq/fe-auth-oauth.h b/src/interfaces/libpq/fe-auth-oauth.h
new file mode 100644
index 0000000000..6e5e946364
--- /dev/null
+++ b/src/interfaces/libpq/fe-auth-oauth.h
@@ -0,0 +1,42 @@
+/*-------------------------------------------------------------------------
+ *
+ * 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"
+
+
+typedef enum
+{
+	FE_OAUTH_INIT,
+	FE_OAUTH_REQUESTING_TOKEN,
+	FE_OAUTH_BEARER_SENT,
+	FE_OAUTH_SERVER_ERROR,
+} fe_oauth_state_enum;
+
+typedef struct
+{
+	fe_oauth_state_enum state;
+
+	PGconn	   *conn;
+	char	   *token;
+
+	void	   *async_ctx;
+	void		(*free_async_ctx) (PGconn *conn, void *ctx);
+} fe_oauth_state;
+
+extern PostgresPollingStatusType pg_fe_run_oauth_flow(PGconn *conn, pgsocket *altsock);
+
+#endif							/* FE_AUTH_OAUTH_H */
diff --git a/src/interfaces/libpq/fe-auth-sasl.h b/src/interfaces/libpq/fe-auth-sasl.h
index 4eecf53a15..83bdab1f40 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,17 @@ 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 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 0bb820e0d9..da168eb2f5 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);
@@ -202,7 +203,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 4da07c1f98..a5a2361f85 100644
--- a/src/interfaces/libpq/fe-auth.c
+++ b/src/interfaces/libpq/fe-auth.c
@@ -39,6 +39,7 @@
 #endif
 
 #include "common/md5.h"
+#include "common/oauth-common.h"
 #include "common/scram-common.h"
 #include "fe-auth.h"
 #include "fe-auth-sasl.h"
@@ -421,7 +422,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;
@@ -439,7 +440,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;
@@ -526,6 +527,15 @@ pg_SASL_init(PGconn *conn, int payloadlen)
 			conn->sasl = &pg_scram_mech;
 			conn->password_needed = true;
 		}
+#ifdef USE_OAUTH
+		else if (strcmp(mechanism_buf.data, OAUTHBEARER_NAME) == 0 &&
+				 !selected_mechanism)
+		{
+			selected_mechanism = OAUTHBEARER_NAME;
+			conn->sasl = &pg_oauth_mech;
+			conn->password_needed = false;
+		}
+#endif
 	}
 
 	if (!selected_mechanism)
@@ -565,26 +575,48 @@ 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.
+		 */
+		*async = true;
+		return STATUS_OK;
+	}
+
 	/*
 	 * Build a SASLInitialResponse message, and send it.
 	 */
@@ -629,7 +661,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;
@@ -654,11 +686,21 @@ 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;
+		return STATUS_OK;
+	}
+
 	if (final && status == SASL_CONTINUE)
 	{
 		if (outputlen != 0)
@@ -962,12 +1004,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;
 
@@ -1125,7 +1173,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;
@@ -1142,7 +1190,8 @@ pg_fe_sendauth(AuthRequest areq, int payloadlen, PGconn *conn)
 			}
 			oldmsglen = conn->errorMessage.len;
 			if (pg_SASL_continue(conn, payloadlen,
-								 (areq == AUTH_REQ_SASL_FIN)) != STATUS_OK)
+								 (areq == AUTH_REQ_SASL_FIN),
+								 async) != STATUS_OK)
 			{
 				/* Use this message if pg_SASL_continue didn't supply one */
 				if (conn->errorMessage.len == oldmsglen)
@@ -1458,3 +1507,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 a18c508688..3e2bc1333f 100644
--- a/src/interfaces/libpq/fe-auth.h
+++ b/src/interfaces/libpq/fe-auth.h
@@ -18,8 +18,12 @@
 #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);
+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);
 
@@ -29,4 +33,7 @@ extern char *pg_fe_scram_build_secret(const char *password,
 									  int iterations,
 									  const char **errstr);
 
+/* Mechanisms in fe-auth-oauth.c */
+extern const pg_fe_sasl_mech pg_oauth_mech;
+
 #endif							/* FE_AUTH_H */
diff --git a/src/interfaces/libpq/fe-connect.c b/src/interfaces/libpq/fe-connect.c
index 360d9a4547..97118ce94b 100644
--- a/src/interfaces/libpq/fe-connect.c
+++ b/src/interfaces/libpq/fe-connect.c
@@ -364,6 +364,23 @@ static const internalPQconninfoOption PQconninfoOptions[] = {
 		"Load-Balance-Hosts", "", 8,	/* sizeof("disable") = 8 */
 	offsetof(struct pg_conn, load_balance_hosts)},
 
+	/* 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}
@@ -627,6 +644,7 @@ pqDropServerData(PGconn *conn)
 	conn->write_failed = false;
 	free(conn->write_err_msg);
 	conn->write_err_msg = NULL;
+	/* conn->oauth_want_retry = false; TODO */
 
 	/*
 	 * Cancel connections need to retain their be_pid and be_key across
@@ -2644,6 +2662,7 @@ PQconnectPoll(PGconn *conn)
 		case CONNECTION_NEEDED:
 		case CONNECTION_GSS_STARTUP:
 		case CONNECTION_CHECK_TARGET:
+		case CONNECTION_AUTHENTICATING:
 			break;
 
 		default:
@@ -3697,6 +3716,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
@@ -3852,6 +3872,16 @@ keep_going:						/* We will come back to here until there is
 					/* Check to see if we should mention pgpassfile */
 					pgpassfileWarning(conn);
 
+#ifdef USE_OAUTH
+					if (conn->sasl == &pg_oauth_mech
+						&& conn->oauth_want_retry)
+					{
+						/* TODO: only allow retry once */
+						need_new_connection = true;
+						goto keep_going;
+					}
+#endif
+
 					CONNECTION_FAILED();
 				}
 				else if (beresp == PqMsg_NegotiateProtocolVersion)
@@ -3885,7 +3915,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 */
 				conn->inStart = conn->inCursor;
@@ -3918,6 +3958,41 @@ keep_going:						/* We will come back to here until there is
 				goto keep_going;
 			}
 
+		case CONNECTION_AUTHENTICATING:
+			{
+				PostgresPollingStatusType status;
+				pgsocket	altsock = PGINVALID_SOCKET;
+
+				if (!conn->async_auth)
+				{
+					/* programmer error; should not happen */
+					libpq_append_conn_error(conn, "async authentication has no handler");
+					goto error_return;
+				}
+
+				status = conn->async_auth(conn, &altsock);
+
+				if (status == PGRES_POLLING_FAILED)
+					goto error_return;
+
+				if (status == PGRES_POLLING_OK)
+				{
+					/*
+					 * 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;
+					conn->altsock = PGINVALID_SOCKET;	/* TODO: what frees
+														 * this? */
+					goto keep_going;
+				}
+
+				conn->altsock = altsock;
+				return status;
+			}
+
 		case CONNECTION_AUTH_OK:
 			{
 				/*
@@ -4599,6 +4674,7 @@ pqMakeEmptyPGconn(void)
 	conn->verbosity = PQERRORS_DEFAULT;
 	conn->show_context = PQSHOW_CONTEXT_ERRORS;
 	conn->sock = PGINVALID_SOCKET;
+	conn->altsock = PGINVALID_SOCKET;
 	conn->Pfdebug = NULL;
 
 	/*
@@ -4716,6 +4792,11 @@ freePGconn(PGconn *conn)
 	free(conn->rowBuf);
 	free(conn->target_session_attrs);
 	free(conn->load_balance_hosts);
+	free(conn->oauth_issuer);
+	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);
 
@@ -7198,6 +7279,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 f235bfbb41..aa1fee38c8 100644
--- a/src/interfaces/libpq/fe-misc.c
+++ b/src/interfaces/libpq/fe-misc.c
@@ -1041,10 +1041,13 @@ 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)
+
+	sock = (conn->altsock != PGINVALID_SOCKET) ? conn->altsock : conn->sock;
+	if (sock == PGINVALID_SOCKET)
 	{
 		libpq_append_conn_error(conn, "invalid socket");
 		return -1;
@@ -1061,7 +1064,7 @@ pqSocketCheck(PGconn *conn, int forRead, int forWrite, pg_usec_time_t end_time)
 
 	/* 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 87a6f3df07..25f216afcf 100644
--- a/src/interfaces/libpq/libpq-fe.h
+++ b/src/interfaces/libpq/libpq-fe.h
@@ -38,6 +38,8 @@ extern "C"
 #define LIBPQ_HAS_TRACE_FLAGS 1
 /* Indicates that PQsslAttribute(NULL, "library") is useful */
 #define LIBPQ_HAS_SSL_LIBRARY_DETECTION 1
+/* Indicates presence of the PQAUTHDATA_PROMPT_OAUTH_DEVICE authdata hook */
+#define LIBPQ_HAS_PROMPT_OAUTH_DEVICE 1
 
 /*
  * Option flags for PQcopyResult
@@ -82,6 +84,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
@@ -163,6 +167,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.
  */
@@ -695,10 +706,74 @@ extern int	PQenv2encoding(void);
 
 /* === in fe-auth.c === */
 
+typedef struct _PQpromptOAuthDevice
+{
+	const char *verification_uri;	/* verification URI to visit */
+	const char *user_code;		/* user code to enter */
+} PQpromptOAuthDevice;
+
+typedef struct _PQoauthBearerRequest
+{
+	/* 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 file 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 _PQoauthBearerRequest *request,
+										int *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 _PQoauthBearerRequest *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;
+} PQoauthBearerRequest;
+
 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 03e4da40ba..58edc5016f 100644
--- a/src/interfaces/libpq/libpq-int.h
+++ b/src/interfaces/libpq/libpq-int.h
@@ -369,6 +369,8 @@ typedef struct pg_conn_host
 								 * found in password file. */
 } pg_conn_host;
 
+typedef PostgresPollingStatusType (*AsyncAuthFunc) (PGconn *conn, pgsocket *altsock);
+
 /*
  * PGconn stores all the state data associated with a single connection
  * to a backend.
@@ -432,6 +434,15 @@ 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_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 */
+	bool		oauth_want_retry;	/* should we retry on failure? */
+
 	/* Optional file to write trace info to */
 	FILE	   *Pfdebug;
 	int			traceFlags;
@@ -506,6 +517,10 @@ struct pg_conn
 										 * know which auth response we're
 										 * sending */
 
+	AsyncAuthFunc async_auth;	/* callback for external async authentication */
+	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
diff --git a/src/interfaces/libpq/meson.build b/src/interfaces/libpq/meson.build
index 7623aeadab..cf1da9c1a7 100644
--- a/src/interfaces/libpq/meson.build
+++ b/src/interfaces/libpq/meson.build
@@ -40,6 +40,13 @@ if gssapi.found()
   )
 endif
 
+if oauth.found()
+  libpq_sources += files('fe-auth-oauth.c')
+  if oauth_library == 'curl'
+    libpq_sources += files('fe-auth-oauth-curl.c')
+  endif
+endif
+
 export_file = custom_target('libpq.exports',
   kwargs: gen_export_kwargs,
 )
diff --git a/src/makefiles/meson.build b/src/makefiles/meson.build
index 850e927584..dec7f0d029 100644
--- a/src/makefiles/meson.build
+++ b/src/makefiles/meson.build
@@ -235,6 +235,7 @@ pgxs_deps = {
   'llvm': llvm,
   'lz4': lz4,
   'nls': libintl,
+  'oauth': oauth,
   'pam': pam,
   'perl': perl_dep,
   'python': python3_dep,
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 547d14b3e7..d518fe91e2 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -153,6 +153,7 @@ ArrayMetaState
 ArraySubWorkspace
 ArrayToken
 ArrayType
+AsyncAuthFunc
 AsyncQueueControl
 AsyncQueueEntry
 AsyncRequest
@@ -368,6 +369,8 @@ CState
 CTECycleClause
 CTEMaterialize
 CTESearchClause
+CURL
+CURLM
 CV
 CachedExpression
 CachedPlan
@@ -1714,6 +1717,7 @@ NumericDigit
 NumericSortSupport
 NumericSumAccum
 NumericVar
+OAuthStep
 OM_uint32
 OP
 OSAPerGroupState
@@ -1778,6 +1782,7 @@ PFN
 PGAlignedBlock
 PGAlignedXLogBlock
 PGAsyncStatusType
+PGAuthData
 PGCALL2
 PGChecksummablePage
 PGContextVisibility
@@ -1938,11 +1943,14 @@ PQArgBlock
 PQEnvironmentOption
 PQExpBuffer
 PQExpBufferData
+PQauthDataHook_type
 PQcommMethods
 PQconninfoOption
 PQnoticeProcessor
 PQnoticeReceiver
+PQoauthBearerRequest
 PQprintOpt
+PQpromptOAuthDevice
 PQsslKeyPassHook_OpenSSL_type
 PREDICATELOCK
 PREDICATELOCKTAG
@@ -3451,6 +3459,8 @@ explain_get_index_name_hook_type
 f_smgr
 fasthash_state
 fd_set
+fe_oauth_state
+fe_oauth_state_enum
 fe_scram_state
 fe_scram_state_enum
 fetch_range_request
-- 
2.34.1

