From 16f1b8fc02f84f61ae4e2d0c8a5c5d39b97f05c5 Mon Sep 17 00:00:00 2001
From: Jacob Champion <jacob.champion@enterprisedb.com>
Date: Wed, 23 Oct 2024 09:37:33 -0700
Subject: [PATCH v37 1/2] Add OAUTHBEARER SASL mechanism

DO NOT USE THIS PROOF OF CONCEPT IN PRODUCTION.

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

    $ psql 'host=example.org oauth_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. The server
implementation does not require additional build-time dependencies, but
an external validator module must be supplied (see below).

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.

= Server-Side Validation =

Because OAuth implementations vary so wildly, and bearer token
validation is heavily dependent on the issuing party, authn/z is done by
communicating with an external validator module using callbacks.
The module is responsible for:

1. Validate the bearer token. The correct way to do this depends on the
   issuer, but it generally involves either cryptographic operations to
   prove that the token was issued by a trusted party, or the
   presentation of the bearer token to some other party so that _it_ can
   perform validation.

   The command MUST maintain confidentiality of the bearer token, since
   in most cases it can be used just like a password. (There are ways to
   cryptographically bind tokens to client certificates, but they are
   way beyond the scope of this commit message.)

   If the token cannot be validated, the authorized member of the
   ValidatorModuleResult struct is used to indicate failure.
   Further authentication/authorization is pointless if
   the bearer token wasn't issued by someone you trust.

3. Authenticate the user, authorize the user, or both:

   a. To authenticate the user, use the bearer token to retrieve some
      trusted identifier string for the end user. The exact process for
      this is, again, issuer-dependent. The module wull return the
      authenticated identity in the authn_id member.

   b. To optionally authorize the user, in combination with the HBA
      option trust_validator_authz=1 (see below).

      The hard part is in determining whether the given token truly
      authorizes the client to use the given role, which must
      unfortunately be left as an exercise to the reader.

      This obviously requires some care, as a poorly implemented token
      validator may silently open the entire database to anyone with a
      bearer token. But it may be a more portable approach, since OAuth
      is designed as an authorization framework, not an authentication
      framework. For example, the user's bearer token could carry an
      "allow_superuser_access" claim, which would authorize pseudonymous
      database access as any role. It's then up to the OAuth system
      administrators to ensure that allow_superuser_access is doled out
      only to the proper users.

   c. It's possible that the user can be successfully authenticated but
      isn't authorized to connect. In this case, the validator module may
	  return the authenticated ID and then fail with false authorized
	  member.  (This can make it easier to see what's going on in the
	  Postgres logs.)

= OAuth HBA Method =

The oauth method supports the following HBA options (but note that two
of them are not optional, since we have no way of choosing sensible
defaults):

  issuer: Required. The URL of the OAuth issuing party, which the client
          must contact to receive a bearer token.

          Some real-world examples as of time of writing:
          - https://accounts.google.com
          - https://login.microsoft.com/[tenant-id]/v2.0

  scope:  Required. The OAuth scope(s) required for the server to
          authenticate and/or authorize the user. This is heavily
          deployment-specific, but a simple example is "openid email".

  map:    Optional. Specify a standard PostgreSQL user map; this works
          the same as with other auth methods such as peer. If a map is
          not specified, the user ID returned by the token validator
          must exactly match the role that's being requested (but see
          trust_validator_authz, below).

  trust_validator_authz:
          Optional. When set to 1, this allows the token validator to
          take full control of the authorization process. Standard user
          mapping is skipped: if the validator command succeeds, the
          client is allowed to connect under its desired role and no
          further checks are done.

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
- implement more helpful handling of HBA misconfigurations
- use logdetail during auth failures
- allow passing the configured issuer to the oauth_validator_command, to
  deal with multi-issuer setups
- fill in documentation stubs
- ...and more.

Co-authored-by: Daniel Gustafsson <daniel@yesql.se>
---
 .cirrus.tasks.yml                             |   17 +-
 config/programs.m4                            |   19 +
 configure                                     |  153 ++
 configure.ac                                  |   36 +
 doc/src/sgml/client-auth.sgml                 |  177 ++
 doc/src/sgml/config.sgml                      |   21 +
 doc/src/sgml/filelist.sgml                    |    1 +
 doc/src/sgml/installation.sgml                |   29 +
 doc/src/sgml/libpq.sgml                       |  134 +
 doc/src/sgml/oauth-validators.sgml            |  140 +
 doc/src/sgml/postgres.sgml                    |    1 +
 doc/src/sgml/regress.sgml                     |   10 +
 meson.build                                   |   34 +
 meson_options.txt                             |    4 +
 src/Makefile.global.in                        |    1 +
 src/backend/libpq/Makefile                    |    1 +
 src/backend/libpq/auth-oauth.c                |  812 ++++++
 src/backend/libpq/auth.c                      |   26 +-
 src/backend/libpq/hba.c                       |   64 +-
 src/backend/libpq/meson.build                 |    1 +
 src/backend/libpq/pg_hba.conf.sample          |    4 +-
 src/backend/utils/misc/guc_tables.c           |   12 +
 src/backend/utils/misc/postgresql.conf.sample |    3 +
 src/include/common/oauth-common.h             |   19 +
 src/include/libpq/auth.h                      |   17 +
 src/include/libpq/hba.h                       |    7 +-
 src/include/libpq/oauth.h                     |   54 +
 src/include/pg_config.h.in                    |    9 +
 src/interfaces/libpq/Makefile                 |   11 +-
 src/interfaces/libpq/exports.txt              |    3 +
 src/interfaces/libpq/fe-auth-oauth-curl.c     | 2440 +++++++++++++++++
 src/interfaces/libpq/fe-auth-oauth.c          |  900 ++++++
 src/interfaces/libpq/fe-auth-oauth.h          |   43 +
 src/interfaces/libpq/fe-auth-sasl.h           |   10 +-
 src/interfaces/libpq/fe-auth-scram.c          |    6 +-
 src/interfaces/libpq/fe-auth.c                |  103 +-
 src/interfaces/libpq/fe-auth.h                |    9 +-
 src/interfaces/libpq/fe-connect.c             |   89 +-
 src/interfaces/libpq/fe-misc.c                |    7 +-
 src/interfaces/libpq/libpq-fe.h               |   88 +
 src/interfaces/libpq/libpq-int.h              |   16 +
 src/interfaces/libpq/meson.build              |    5 +
 src/interfaces/libpq/pqexpbuffer.c            |    2 +-
 src/interfaces/libpq/pqexpbuffer.h            |    6 +
 src/makefiles/meson.build                     |    2 +
 src/test/modules/Makefile                     |    1 +
 src/test/modules/meson.build                  |    1 +
 src/test/modules/oauth_validator/.gitignore   |    4 +
 src/test/modules/oauth_validator/Makefile     |   40 +
 .../modules/oauth_validator/fail_validator.c  |   44 +
 src/test/modules/oauth_validator/meson.build  |   69 +
 .../oauth_validator/oauth_hook_client.c       |  157 ++
 .../modules/oauth_validator/t/001_server.pl   |  428 +++
 .../modules/oauth_validator/t/002_client.pl   |  114 +
 .../modules/oauth_validator/t/oauth_server.py |  370 +++
 src/test/modules/oauth_validator/validator.c  |  100 +
 src/test/perl/PostgreSQL/Test/Cluster.pm      |   20 +-
 src/test/perl/PostgreSQL/Test/OAuthServer.pm  |   65 +
 src/tools/pgindent/pgindent                   |   14 +
 src/tools/pgindent/typedefs.list              |   15 +
 60 files changed, 6929 insertions(+), 59 deletions(-)
 create mode 100644 doc/src/sgml/oauth-validators.sgml
 create mode 100644 src/backend/libpq/auth-oauth.c
 create mode 100644 src/include/common/oauth-common.h
 create mode 100644 src/include/libpq/oauth.h
 create mode 100644 src/interfaces/libpq/fe-auth-oauth-curl.c
 create mode 100644 src/interfaces/libpq/fe-auth-oauth.c
 create mode 100644 src/interfaces/libpq/fe-auth-oauth.h
 create mode 100644 src/test/modules/oauth_validator/.gitignore
 create mode 100644 src/test/modules/oauth_validator/Makefile
 create mode 100644 src/test/modules/oauth_validator/fail_validator.c
 create mode 100644 src/test/modules/oauth_validator/meson.build
 create mode 100644 src/test/modules/oauth_validator/oauth_hook_client.c
 create mode 100644 src/test/modules/oauth_validator/t/001_server.pl
 create mode 100644 src/test/modules/oauth_validator/t/002_client.pl
 create mode 100755 src/test/modules/oauth_validator/t/oauth_server.py
 create mode 100644 src/test/modules/oauth_validator/validator.c
 create mode 100644 src/test/perl/PostgreSQL/Test/OAuthServer.pm

diff --git a/.cirrus.tasks.yml b/.cirrus.tasks.yml
index fc413eb11e..26e747d559 100644
--- a/.cirrus.tasks.yml
+++ b/.cirrus.tasks.yml
@@ -20,7 +20,7 @@ env:
   MTEST_ARGS: --print-errorlogs --no-rebuild -C build
   PGCTLTIMEOUT: 120 # avoids spurious failures during parallel tests
   TEMP_CONFIG: ${CIRRUS_WORKING_DIR}/src/tools/ci/pg_ci_base.conf
-  PG_TEST_EXTRA: kerberos ldap ssl libpq_encryption load_balance
+  PG_TEST_EXTRA: kerberos ldap ssl libpq_encryption load_balance oauth
 
 
 # What files to preserve in case tests fail
@@ -164,7 +164,7 @@ task:
     chown root:postgres /tmp/cores
     sysctl kern.corefile='/tmp/cores/%N.%P.core'
   setup_additional_packages_script: |
-    #pkg install -y ...
+    pkg install -y curl
 
   # NB: Intentionally build without -Dllvm. The freebsd image size is already
   # large enough to make VM startup slow, and even without llvm freebsd
@@ -175,6 +175,7 @@ task:
         --buildtype=debug \
         -Dcassert=true -Dinjection_points=true \
         -Duuid=bsd -Dtcl_version=tcl86 -Ddtrace=auto \
+        -Dbuiltin_oauth=curl \
         -Dextra_lib_dirs=/usr/local/lib -Dextra_include_dirs=/usr/local/include/ \
         build
     EOF
@@ -223,6 +224,7 @@ LINUX_CONFIGURE_FEATURES: &LINUX_CONFIGURE_FEATURES >-
   --with-libxslt
   --with-llvm
   --with-lz4
+  --with-builtin-oauth=curl
   --with-pam
   --with-perl
   --with-python
@@ -235,6 +237,7 @@ LINUX_CONFIGURE_FEATURES: &LINUX_CONFIGURE_FEATURES >-
 
 LINUX_MESON_FEATURES: &LINUX_MESON_FEATURES >-
   -Dllvm=enabled
+  -Dbuiltin_oauth=curl
   -Duuid=e2fs
 
 
@@ -312,8 +315,10 @@ task:
     EOF
 
   setup_additional_packages_script: |
-    #apt-get update
-    #DEBIAN_FRONTEND=noninteractive apt-get -y install ...
+    apt-get update
+    DEBIAN_FRONTEND=noninteractive apt-get -y install \
+      libcurl4-openssl-dev \
+      libcurl4-openssl-dev:i386 \
 
   matrix:
     - name: Linux - Debian Bookworm - Autoconf
@@ -687,8 +692,8 @@ task:
     folder: $CCACHE_DIR
 
   setup_additional_packages_script: |
-    #apt-get update
-    #DEBIAN_FRONTEND=noninteractive apt-get -y install ...
+    apt-get update
+    DEBIAN_FRONTEND=noninteractive apt-get -y install libcurl4-openssl-dev
 
   ###
   # Test that code can be built with gcc/clang without warnings
diff --git a/config/programs.m4 b/config/programs.m4
index 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 28719ed30c..ee6291013f 100755
--- a/configure
+++ b/configure
@@ -712,6 +712,7 @@ with_uuid
 with_readline
 with_systemd
 with_selinux
+with_builtin_oauth
 with_ldap
 with_krb_srvnam
 krb_srvtab
@@ -857,6 +858,7 @@ with_krb_srvnam
 with_pam
 with_bsd_auth
 with_ldap
+with_builtin_oauth
 with_bonjour
 with_selinux
 with_systemd
@@ -1566,6 +1568,8 @@ Optional Packages:
   --with-pam              build with PAM support
   --with-bsd-auth         build with BSD Authentication support
   --with-ldap             build with LDAP support
+  --with-builtin-oauth=LIB
+                          use LIB for built-in OAuth 2.0 client flows (curl)
   --with-bonjour          build with Bonjour support
   --with-selinux          build with SELinux support
   --with-systemd          build with systemd support
@@ -8510,6 +8514,57 @@ $as_echo "$with_ldap" >&6; }
 
 
 
+#
+# OAuth 2.0
+#
+{ $as_echo "$as_me:${as_lineno-$LINENO}: checking whether to build with built-in OAuth client support" >&5
+$as_echo_n "checking whether to build with built-in OAuth client support... " >&6; }
+
+
+
+# Check whether --with-builtin-oauth was given.
+if test "${with_builtin_oauth+set}" = set; then :
+  withval=$with_builtin_oauth;
+  case $withval in
+    yes)
+      as_fn_error $? "argument required for --with-builtin-oauth option" "$LINENO" 5
+      ;;
+    no)
+      as_fn_error $? "argument required for --with-builtin-oauth option" "$LINENO" 5
+      ;;
+    *)
+
+      ;;
+  esac
+
+fi
+
+
+if test x"$with_builtin_oauth" = x"" ; then
+  with_builtin_oauth=no
+fi
+
+if test x"$with_builtin_oauth" = x"curl"; then
+
+$as_echo "#define USE_BUILTIN_OAUTH 1" >>confdefs.h
+
+
+$as_echo "#define USE_OAUTH_CURL 1" >>confdefs.h
+
+  # OAuth requires python for testing
+  if test "$with_python" != yes; then
+    { $as_echo "$as_me:${as_lineno-$LINENO}: WARNING: *** OAuth support tests requires --with-python to run" >&5
+$as_echo "$as_me: WARNING: *** OAuth support tests requires --with-python to run" >&2;}
+  fi
+elif test x"$with_builtin_oauth" != x"no"; then
+  as_fn_error $? "--with-builtin-oauth must specify curl" "$LINENO" 5
+fi
+
+{ $as_echo "$as_me:${as_lineno-$LINENO}: result: $with_builtin_oauth" >&5
+$as_echo "$with_builtin_oauth" >&6; }
+
+
+
 #
 # Bonjour
 #
@@ -12207,6 +12262,93 @@ fi
 
 fi
 
+# XXX libcurl must link after libgssapi_krb5 on FreeBSD to avoid segfaults
+# during gss_acquire_cred(). This is possibly related to Curl's Heimdal
+# dependency on that platform?
+if test "$with_builtin_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-builtin-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
+
 if test "$with_gssapi" = yes ; then
   if test "$PORTNAME" != "win32"; then
     { $as_echo "$as_me:${as_lineno-$LINENO}: checking for library containing gss_store_cred_into" >&5
@@ -14049,6 +14191,17 @@ fi
 
 done
 
+fi
+
+if test "$with_builtin_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 --with-builtin-oauth=curl" "$LINENO" 5
+fi
+
+
 fi
 
 if test "$PORTNAME" = "win32" ; then
diff --git a/configure.ac b/configure.ac
index 533f4ab78a..162250d3dd 100644
--- a/configure.ac
+++ b/configure.ac
@@ -925,6 +925,30 @@ AC_MSG_RESULT([$with_ldap])
 AC_SUBST(with_ldap)
 
 
+#
+# OAuth 2.0
+#
+AC_MSG_CHECKING([whether to build with built-in OAuth client support])
+PGAC_ARG_REQ(with, builtin-oauth, [LIB], [use LIB for built-in OAuth 2.0 client flows (curl)])
+if test x"$with_builtin_oauth" = x"" ; then
+  with_builtin_oauth=no
+fi
+
+if test x"$with_builtin_oauth" = x"curl"; then
+  AC_DEFINE([USE_BUILTIN_OAUTH], 1, [Define to 1 to build with OAuth 2.0 client flows. (--with-builtin-oauth)])
+  AC_DEFINE([USE_OAUTH_CURL], 1, [Define to 1 to use libcurl for OAuth client flows.])
+  # OAuth requires python for testing
+  if test "$with_python" != yes; then
+    AC_MSG_WARN([*** OAuth support tests requires --with-python to run])
+  fi
+elif test x"$with_builtin_oauth" != x"no"; then
+  AC_MSG_ERROR([--with-builtin-oauth must specify curl])
+fi
+
+AC_MSG_RESULT([$with_builtin_oauth])
+AC_SUBST(with_builtin_oauth)
+
+
 #
 # Bonjour
 #
@@ -1294,6 +1318,14 @@ failure.  It is possible the compiler isn't looking in the proper directory.
 Use --without-zlib to disable zlib support.])])
 fi
 
+# XXX libcurl must link after libgssapi_krb5 on FreeBSD to avoid segfaults
+# during gss_acquire_cred(). This is possibly related to Curl's Heimdal
+# dependency on that platform?
+if test "$with_builtin_oauth" = curl ; then
+  AC_CHECK_LIB(curl, curl_multi_init, [], [AC_MSG_ERROR([library 'curl' is required for --with-builtin-oauth=curl])])
+  PGAC_CHECK_LIBCURL
+fi
+
 if test "$with_gssapi" = yes ; then
   if test "$PORTNAME" != "win32"; then
     AC_SEARCH_LIBS(gss_store_cred_into, [gssapi_krb5 gss 'gssapi -lkrb5 -lcrypto'], [],
@@ -1590,6 +1622,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_builtin_oauth" = curl; then
+  AC_CHECK_HEADER(curl/curl.h, [], [AC_MSG_ERROR([header file <curl/curl.h> is required for --with-builtin-oauth=curl])])
+fi
+
 if test "$PORTNAME" = "win32" ; then
    AC_CHECK_HEADERS(crtdefs.h)
 fi
diff --git a/doc/src/sgml/client-auth.sgml b/doc/src/sgml/client-auth.sgml
index 51343de7ca..a567495352 100644
--- a/doc/src/sgml/client-auth.sgml
+++ b/doc/src/sgml/client-auth.sgml
@@ -647,6 +647,16 @@ include_dir         <replaceable>directory</replaceable>
          </para>
         </listitem>
        </varlistentry>
+
+       <varlistentry>
+        <term><literal>oauth</literal></term>
+        <listitem>
+         <para>
+          Authorize and optionally authenticate using a third-party OAuth 2.0
+          identity provider. See <xref linkend="auth-oauth"/> for details.
+         </para>
+        </listitem>
+       </varlistentry>
       </variablelist>
 
       </para>
@@ -1134,6 +1144,12 @@ omicron         bryanh                  guest1
       only on OpenBSD).
      </para>
     </listitem>
+    <listitem>
+     <para>
+      <link linkend="auth-oauth">OAuth authorization/authentication</link>,
+      which relies on an external OAuth 2.0 identity provider.
+     </para>
+    </listitem>
    </itemizedlist>
   </para>
 
@@ -2312,6 +2328,167 @@ host ... radius radiusservers="server1,server2" radiussecrets="""secret one"",""
    </note>
   </sect1>
 
+  <sect1 id="auth-oauth">
+   <title>OAuth Authorization/Authentication</title>
+
+   <indexterm zone="auth-oauth">
+    <primary>OAuth Authorization/Authentication</primary>
+   </indexterm>
+
+   <para>
+    OAuth 2.0 is an industry-standard framework, defined in
+    <ulink url="https://datatracker.ietf.org/doc/html/rfc6749">RFC 6749</ulink>,
+    to enable third-party applications to obtain limited access to a protected
+    resource.
+
+    OAuth client support has to be enabled when <productname>PostgreSQL</productname>
+    is built, see <xref linkend="installation"/> for more information.
+
+    <itemizedlist>
+     <listitem>
+      <para>
+       Resource owner: The user or system who owns protected resources and can
+       grant access to them.
+      </para>
+     </listitem>
+     <listitem>
+      <para>
+       Client: The system which accesses the protected resources using access
+       tokens.  Applications using libpq are the clients in connecting to a
+       <productname>PostgreSQL</productname> cluster.
+      </para>
+     </listitem>
+     <listitem>
+      <para>
+       Authorization server: The system which receives requests from, and
+       issues access tokens to, the client after the authenticated resource
+       owner has given approval.
+      </para>
+     </listitem>
+
+     <listitem>
+      <para>
+       Resource server: The system which hosts the protected resources which are
+       accessed by the client. The <productname>PostgreSQL</productname> cluster
+       being connected to is the resource server.
+      </para>
+     </listitem>
+
+    </itemizedlist>
+   </para>
+
+   <para>
+    <productname>PostgreSQL</productname> supports bearer tokens, defined in
+    <ulink url="https://datatracker.ietf.org/doc/html/rfc6750">RFC 6750</ulink>,
+    which are a type of access token used with OAuth 2.0 where the token is an
+    opaque string.  The format of the access token is implementation specific
+    and is chosen by each authentication server.
+   </para>
+
+   <para>
+    The following configuration options are supported for OAuth:
+    <variablelist>
+     <varlistentry>
+      <term><literal>issuer</literal></term>
+      <listitem>
+       <para>
+        The issuer identifier of the authorization server, as defined by its
+        discovery document, or a well-known URI pointing to that discovery
+        document. This parameter is required.
+       </para>
+       <para>
+        When an OAuth client connects to the server, a discovery document URI
+        will be constructed using the issuer identifier. By default, the URI
+        uses the conventions of OpenID Connect Discovery: the path
+        <literal>/.well-known/openid-configuration</literal> will be appended
+        to the issuer identifier. Alternatively, if the
+        <literal>issuer</literal> contains a <literal>/.well-known/</literal>
+        path segment, the URI will be provided to the client as-is.
+       </para>
+       <warning>
+        <para>
+         The OAuth client in libpq requires the server's issuer setting to
+         exactly match the issuer identifier which is provided in the discovery
+         document, which must in turn match the client's
+         <xref linkend="libpq-connect-oauth-issuer"/> setting. No variations in
+         case or format are permitted.
+        </para>
+       </warning>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term><literal>scope</literal></term>
+      <listitem>
+       <para>
+        A space-separated list of the OAuth scopes needed for the server to
+        both authorize the client and authenticate the user.  Appropriate values
+        are determined by the authorization server and the OAuth validation
+        module used (see <xref linkend="oauth-validators" /> for more
+        information on validators).  This parameter is required.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term><literal>validator</literal></term>
+      <listitem>
+       <para>
+        The library to use for validating bearer tokens. If given, the name must
+        exactly match one of the libraries listed in
+        <xref linkend="guc-oauth-validator-libraries" />.  This parameter is
+        optional unless <literal>oauth_validator_libraries</literal> contains
+        more than one library, in which case it is required.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term><literal>map</literal></term>
+      <listitem>
+       <para>
+        Allows for mapping between OAuth identity provider and database user
+        names.  See <xref linkend="auth-username-maps"/> for details.  If a
+        map is not specified, the user name associated with the token (as
+        determined by the OAuth validator) must exactly match the role name
+        being requested.  This parameter is optional.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry>
+      <term><literal>trust_validator_authz</literal></term>
+      <listitem>
+       <para>
+        An advanced option which is not intended for common use.
+       </para>
+       <para>
+        When set to <literal>1</literal>, standard user mapping is skipped, and
+        the OAuth validator takes full responsibility for mapping end user
+        identities to database roles.  If the validator authorizes the token,
+        the server trusts that the user is allowed to connect under the
+        requested role, and the connection is allowed to proceed regardless of
+        the authentication status of the user.
+       </para>
+       <para>
+        This parameter is incompatible with <literal>map</literal>.
+       </para>
+       <warning>
+        <para>
+         <literal>trust_validator_authz</literal> provides additional
+         flexibility in the design of the authentication system, but it also
+         requires careful implementation of the OAuth validator, which must
+         determine whether the provided token carries sufficient end-user
+         privileges in addition to the <link linkend="oauth-validators">standard
+         checks</link> required of all validators.  Use with caution.
+        </para>
+       </warning>
+      </listitem>
+     </varlistentry>
+    </variablelist>
+   </para>
+  </sect1>
+
   <sect1 id="client-authentication-problems">
    <title>Authentication Problems</title>
 
diff --git a/doc/src/sgml/config.sgml b/doc/src/sgml/config.sgml
index a84e60c09b..7a37b96cea 100644
--- a/doc/src/sgml/config.sgml
+++ b/doc/src/sgml/config.sgml
@@ -1201,6 +1201,27 @@ include_dir 'conf.d'
        </para>
       </listitem>
      </varlistentry>
+
+     <varlistentry id="guc-oauth-validator-libraries" xreflabel="oauth_validator_libraries">
+      <term><varname>oauth_validator_libraries</varname> (<type>string</type>)
+      <indexterm>
+       <primary><varname>oauth_validator_libraries</varname> configuration parameter</primary>
+      </indexterm>
+      </term>
+      <listitem>
+       <para>
+        The library/libraries to use for validating OAuth connection tokens. If
+        only one validator library is provided, it will be used by default for
+        any OAuth connections; otherwise, all
+        <link linkend="auth-oauth"><literal>oauth</literal> HBA entries</link>
+        must explicitly set a <literal>validator</literal> chosen from this
+        list. If set to an empty string (the default), OAuth connections will be
+        refused. For more information on implementing OAuth validators see
+        <xref linkend="oauth-validators" />. This parameter can only be set in
+        the <filename>postgresql.conf</filename> file.
+       </para>
+      </listitem>
+     </varlistentry>
      </variablelist>
      </sect2>
 
diff --git a/doc/src/sgml/filelist.sgml b/doc/src/sgml/filelist.sgml
index 66e6dccd4c..25fb99cee6 100644
--- a/doc/src/sgml/filelist.sgml
+++ b/doc/src/sgml/filelist.sgml
@@ -111,6 +111,7 @@
 <!ENTITY generic-wal SYSTEM "generic-wal.sgml">
 <!ENTITY custom-rmgr SYSTEM "custom-rmgr.sgml">
 <!ENTITY backup-manifest SYSTEM "backup-manifest.sgml">
+<!ENTITY oauth-validators SYSTEM "oauth-validators.sgml">
 
 <!-- contrib information -->
 <!ENTITY contrib         SYSTEM "contrib.sgml">
diff --git a/doc/src/sgml/installation.sgml b/doc/src/sgml/installation.sgml
index 5621606f59..c4e06c53f6 100644
--- a/doc/src/sgml/installation.sgml
+++ b/doc/src/sgml/installation.sgml
@@ -1072,6 +1072,20 @@ build-postgresql:
        </listitem>
       </varlistentry>
 
+      <varlistentry id="configure-option-with-builtin-oauth">
+       <term><option>--with-builtin-oauth=<replaceable>LIBRARY</replaceable></option></term>
+       <listitem>
+        <para>
+         Build with support for OAuth 2.0 client flows.  The only
+         <replaceable>LIBRARY</replaceable> supported is <option>curl</option>.
+         This requires the <productname>curl</productname> package to be
+         installed.  Building with this will check for the required header files
+         and libraries to make sure that your <productname>curl</productname>
+         installation is sufficient before proceeding.
+        </para>
+       </listitem>
+      </varlistentry>
+
       <varlistentry id="configure-option-with-systemd">
        <term><option>--with-systemd</option></term>
        <listitem>
@@ -2516,6 +2530,21 @@ ninja install
       </listitem>
      </varlistentry>
 
+     <varlistentry id="configure-with-builtin-oauth">
+      <term><option>-Dbuiltin_oauth={ auto | <replaceable>LIBRARY</replaceable> | disabled }</option></term>
+      <listitem>
+       <para>
+        Build with support for OAuth 2.0 client flows.  The only
+        <replaceable>LIBRARY</replaceable> supported is <option>curl</option>.
+        This requires the <productname>curl</productname> package to be
+        installed.  Building with this will check for the required header files
+        and libraries to make sure that your <productname>curl</productname>
+        installation is sufficient before proceeding. The default for this
+        option is auto.
+       </para>
+      </listitem>
+     </varlistentry>
+
      <varlistentry id="configure-with-systemd-meson">
       <term><option>-Dsystemd={ auto | enabled | disabled }</option></term>
       <listitem>
diff --git a/doc/src/sgml/libpq.sgml b/doc/src/sgml/libpq.sgml
index bfefb1289e..0a27bfa3d4 100644
--- a/doc/src/sgml/libpq.sgml
+++ b/doc/src/sgml/libpq.sgml
@@ -2336,6 +2336,96 @@ postgresql://%2Fvar%2Flib%2Fpostgresql/dbname
        </para>
       </listitem>
      </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-issuer" xreflabel="oauth_issuer">
+      <term><literal>oauth_issuer</literal></term>
+      <listitem>
+       <para>
+        The HTTPS URL of an issuer to contact if the server requests an OAuth
+        token for the connection. This parameter is required for all OAuth
+        connections; it should exactly match the <literal>issuer</literal>
+        setting in <link linkend="auth-oauth">the server's HBA configuration.</link>
+       </para>
+       <para>
+        As part of the standard authentication handshake, libpq will ask the
+        server for a <emphasis>discovery document:</emphasis> a URI providing a
+        set of OAuth configuration parameters. The server must provide a URI
+        that is directly constructed from the components of the
+        <literal>oauth_issuer</literal>, and this value must exactly match the
+        issuer identifier that is declared in the discovery document itself, or
+        the connection will fail. This is required to prevent a class of "mix-up
+        attacks" on OAuth clients.
+       </para>
+       <para>
+        This standard handshake requires two separate network connections to the
+        server per authentication attempt. To skip asking the server for a
+        discovery document URI, you may set <literal>oauth_issuer</literal> to a
+        <literal>/.well-known/</literal> URI used for OAuth discovery. (In this
+        case, it is recommended that
+        <xref linkend="libpq-connect-oauth-scope"/> be set as well, since the
+        client will not have a chance to ask the server for a correct scope
+        setting, and the default scopes for a token may not be sufficient to
+        connect.) libpq currently supports the following well-known endpoints:
+        <itemizedlist>
+         <listitem><para><literal>/.well-known/openid-configuration</literal></para></listitem>
+         <listitem><para><literal>/.well-known/oauth-authorization-server</literal></para></listitem>
+        </itemizedlist>
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-client-id" xreflabel="oauth_client_id">
+      <term><literal>oauth_client_id</literal></term>
+      <listitem>
+       <para>
+        An OAuth 2.0 client identifier, as issued by the authorization server.
+        If the <productname>PostgreSQL</productname> server
+        <link linkend="auth-oauth">requests an OAuth token</link> for the
+        connection (and if no <link linkend="libpq-oauth">custom OAuth
+        hook</link> is installed to provide one), then this parameter must be
+        set; otherwise, the connection will fail.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-client-secret" xreflabel="oauth_client_secret">
+      <term><literal>oauth_client_secret</literal></term>
+      <listitem>
+       <para>
+        The client password, if any, to use when contacting the OAuth
+        authorization server. Whether this parameter is required or not is
+        determined by the OAuth provider; "public" clients generally do not use
+        a secret, whereas "confidential" clients generally do.
+       </para>
+      </listitem>
+     </varlistentry>
+
+     <varlistentry id="libpq-connect-oauth-scope" xreflabel="oauth_scope">
+      <term><literal>oauth_scope</literal></term>
+      <listitem>
+       <para>
+        The scope of the access request sent to the authorization server,
+        specified as a (possibly empty) space-separated list of OAuth scope
+        identifiers. This parameter is optional and intended for advanced usage.
+       </para>
+       <para>
+        Usually the client will obtain appropriate scope settings from the
+        <productname>PostgreSQL</productname> server. If this parameter is used,
+        the server's requested scope list will be ignored. This can prevent a
+        less-trusted server from requesting inappropriate access scopes from the
+        end user. However, if the client's scope setting does not contain the
+        server's required scopes, the server is likely to reject the issued
+        token, and the connection will fail.
+       </para>
+       <para>
+        The meaning of an empty scope list is provider-dependent. An OAuth
+        authorization server may choose to issue a token with "default scope",
+        whatever that happens to be, or it may reject the token request
+        entirely.
+       </para>
+      </listitem>
+     </varlistentry>
+
     </variablelist>
    </para>
   </sect2>
@@ -9963,6 +10053,50 @@ 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>
+
+      <para>
+       If <replaceable>hook</replaceable> is set to a null pointer instead of
+       a function pointer, the default hook will be installed.
+      </para>
+     </listitem>
+    </varlistentry>
+
+    <varlistentry id="libpq-PQgetAuthDataHook">
+     <term><function>PQgetAuthDataHook</function><indexterm><primary>PQgetAuthDataHook</primary></indexterm></term>
+
+     <listitem>
+      <para>
+       Retrieves the current value of <literal>PGauthDataHook</literal>.
+<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/doc/src/sgml/oauth-validators.sgml b/doc/src/sgml/oauth-validators.sgml
new file mode 100644
index 0000000000..83ea576445
--- /dev/null
+++ b/doc/src/sgml/oauth-validators.sgml
@@ -0,0 +1,140 @@
+<!-- doc/src/sgml/oauth-validators.sgml -->
+
+<chapter id="oauth-validators">
+ <title>OAuth Validator Modules</title>
+ <indexterm zone="oauth-validators">
+  <primary>OAuth Validators</primary>
+ </indexterm>
+ <para>
+  <productname>PostgreSQL</productname> provides infrastructure for creating
+  custom modules to perform server-side validation of OAuth bearer tokens.
+ </para>
+ <para>
+  OAuth validation modules must at least consist of an initialization function
+  (see <xref linkend="oauth-validator-init"/>) and the required callback for
+  performing validation (see <xref linkend="oauth-validator-callback-validate"/>).
+ </para>
+
+ <sect1 id="oauth-validator-init">
+  <title>Initialization Functions</title>
+  <indexterm zone="oauth-validator-init">
+   <primary>_PG_oauth_validator_module_init</primary>
+  </indexterm>
+  <para>
+   An OAuth validator module is loaded by dynamically loading one of the shared
+   libraries listed in <xref linkend="guc-oauth-validator-libraries"/>.
+   The normal library search path is used to locate the library. To
+   provide the validator callbacks and to indicate that the library is an OAuth
+   validator module a function named
+   <function>_PG_oauth_validator_module_init</function> must be provided. The
+   return value of the function must be a pointer to a struct of type
+   <structname>OAuthValidatorCallbacks</structname> which contains all that
+   libpq need to perform token validation using the module. The returned
+   pointer must be of server lifetime, which is typically achieved by defining
+   it as a <literal>static const</literal> variable in global scope.
+<programlisting>
+typedef struct OAuthValidatorCallbacks
+{
+    ValidatorStartupCB startup_cb;
+    ValidatorShutdownCB shutdown_cb;
+    ValidatorValidateCB validate_cb;
+} OAuthValidatorCallbacks;
+
+typedef const OAuthValidatorCallbacks *(*OAuthValidatorModuleInit) (void);
+</programlisting>
+
+   Only the <function>validate_cb</function> callback is required, the others
+   are optional.
+  </para>
+ </sect1>
+
+ <sect1 id="oauth-validator-callbacks">
+  <title>OAuth Validator Callbacks</title>
+  <para>
+   OAuth validator modules implement their functionality by defining a set of
+   callbacks, libpq will call them as required to process the authentication
+   request from the user.
+  </para>
+
+  <sect2 id="oauth-validator-callback-startup">
+   <title>Startup Callback</title>
+   <para>
+    The <function>startup_cb</function> callback is executed directly after
+    loading the module. This callback can be used to set up local state and
+    perform additional initialization if required. If the validator module
+    has state it can use <structfield>state->private_data</structfield> to
+    store it.
+
+<programlisting>
+typedef void (*ValidatorStartupCB) (ValidatorModuleState *state);
+</programlisting>
+   </para>
+  </sect2>
+
+  <sect2 id="oauth-validator-callback-validate">
+   <title>Validate Callback</title>
+   <para>
+    The <function>validate_cb</function> callback is executed during the OAuth
+    exchange when a user attempts to authenticate using OAuth.  Any state set in
+    previous calls will be available in <structfield>state->private_data</structfield>.
+
+<programlisting>
+typedef ValidatorModuleResult *(*ValidatorValidateCB) (ValidatorModuleState *state, const char *token, const char *role);
+</programlisting>
+
+    <replaceable>token</replaceable> will contain the bearer token to validate.
+    The server has ensured that the token is well-formed syntactically, but no
+    other validation has been performed.  <replaceable>role</replaceable> will
+    contain the role the user has requested to log in as.  The callback must
+    return a palloc'd <literal>ValidatorModuleResult</literal> struct, which is
+    defined as below:
+
+<programlisting>
+typedef struct ValidatorModuleResult
+{
+    bool        authorized;
+    char       *authn_id;
+} ValidatorModuleResult;
+</programlisting>
+
+    The connection will only proceed if the module sets
+    <structfield>authorized</structfield> to <literal>true</literal>.  To
+    authenticate the user, the authenticated user name (as determined using the
+    token) shall be palloc'd and returned in the <structfield>authn_id</structfield>
+    field.  Alternatively, <structfield>authn_id</structfield> may be set to
+    NULL if the token is valid but the associated user identity cannot be
+    determined.
+   </para>
+   <para>
+    The caller assumes ownership of the returned memory allocation, the
+    validator module should not in any way access the memory after it has been
+    returned.  A validator may instead return NULL to signal an internal
+    error.
+   </para>
+   <para>
+    The behavior after <function>validate_cb</function> returns depends on the
+    specific HBA setup.  Normally, the <structfield>authn_id</structfield> user
+    name must exactly match the role that the user is logging in as.  (This
+    behavior may be modified with a usermap.)  But when authenticating against
+    an HBA rule with <literal>trust_validator_authz</literal> turned on, the
+    server will not perform any checks on the value of
+    <structfield>authn_id</structfield> at all; in this case it is up to the
+    validator to ensure that the token carries enough privileges for the user to
+    log in under the indicated <replaceable>role</replaceable>.
+   </para>
+  </sect2>
+
+  <sect2 id="oauth-validator-callback-shutdown">
+   <title>Shutdown Callback</title>
+   <para>
+    The <function>shutdown_cb</function> callback is executed when the backend
+    process associated with the connection exits. If the validator module has
+    any state, this callback should free it to avoid resource leaks.
+<programlisting>
+typedef void (*ValidatorShutdownCB) (ValidatorModuleState *state);
+</programlisting>
+   </para>
+  </sect2>
+
+ </sect1>
+</chapter>
diff --git a/doc/src/sgml/postgres.sgml b/doc/src/sgml/postgres.sgml
index 7be25c5850..af476c82fc 100644
--- a/doc/src/sgml/postgres.sgml
+++ b/doc/src/sgml/postgres.sgml
@@ -229,6 +229,7 @@ break is not needed in a wider output rendering.
   &logicaldecoding;
   &replication-origins;
   &archive-modules;
+  &oauth-validators;
 
  </part>
 
diff --git a/doc/src/sgml/regress.sgml b/doc/src/sgml/regress.sgml
index f4cef9e80f..ae4732df65 100644
--- a/doc/src/sgml/regress.sgml
+++ b/doc/src/sgml/regress.sgml
@@ -336,6 +336,16 @@ make check-world PG_TEST_EXTRA='kerberos ldap ssl load_balance libpq_encryption'
       </para>
      </listitem>
     </varlistentry>
+
+    <varlistentry>
+     <term><literal>oauth</literal></term>
+     <listitem>
+      <para>
+       Runs the test suite under <filename>src/test/modules/oauth_validator</filename>.
+       This opens TCP/IP listen sockets for a test-server running HTTPS.
+      </para>
+     </listitem>
+    </varlistentry>
    </variablelist>
 
    Tests for features that are not supported by the current build
diff --git a/meson.build b/meson.build
index b64d253fe4..f912029bb5 100644
--- a/meson.build
+++ b/meson.build
@@ -916,6 +916,35 @@ endif
 
 
 
+###############################################################
+# Library: OAuth (libcurl)
+###############################################################
+
+libcurl = not_found_dep
+oauth_library = 'none'
+oauthopt = get_option('builtin_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).
+  libcurl = dependency('libcurl', version: '>= 7.61.0',
+                       required: (oauthopt == 'curl'))
+  if libcurl.found()
+    oauth_library = 'curl'
+    cdata.set('USE_BUILTIN_OAUTH', 1)
+    cdata.set('USE_OAUTH_CURL', 1)
+  endif
+endif
+
+if oauthopt == 'auto' and auto_features.enabled() and not libcurl.found()
+  error('no OAuth implementation library found')
+endif
+
+
 ###############################################################
 # Library: Tcl (for pltcl)
 #
@@ -3034,6 +3063,10 @@ libpq_deps += [
 
   gssapi,
   ldap_r,
+  # XXX libcurl must link after libgssapi_krb5 on FreeBSD to avoid segfaults
+  # during gss_acquire_cred(). This is possibly related to Curl's Heimdal
+  # dependency on that platform?
+  libcurl,
   libintl,
   ssl,
 ]
@@ -3702,6 +3735,7 @@ if meson.version().version_compare('>=0.57')
       'gss': gssapi,
       'icu': icu,
       'ldap': ldap,
+      'libcurl': libcurl,
       'libxml': libxml,
       'libxslt': libxslt,
       'llvm': llvm,
diff --git a/meson_options.txt b/meson_options.txt
index 3893519639..453429ab86 100644
--- a/meson_options.txt
+++ b/meson_options.txt
@@ -79,6 +79,10 @@ option('bonjour', type: 'feature', value: 'auto',
 option('bsd_auth', type: 'feature', value: 'auto',
   description: 'BSD Authentication support')
 
+option('builtin_oauth', type : 'combo', choices : ['auto', 'none', 'curl'],
+  value: 'auto',
+  description: 'use LIB for built-in OAuth 2.0 client flows (curl)')
+
 option('docs', type: 'feature', value: 'auto',
   description: 'Documentation in HTML and man page format')
 
diff --git a/src/Makefile.global.in b/src/Makefile.global.in
index 0f38d712d1..068c841f32 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_builtin_oauth = @with_builtin_oauth@
 with_system_tzdata = @with_system_tzdata@
 with_uuid	= @with_uuid@
 with_zlib	= @with_zlib@
diff --git a/src/backend/libpq/Makefile b/src/backend/libpq/Makefile
index 6d385fd6a4..98eb2a8242 100644
--- a/src/backend/libpq/Makefile
+++ b/src/backend/libpq/Makefile
@@ -15,6 +15,7 @@ include $(top_builddir)/src/Makefile.global
 # be-fsstubs is here for historical reasons, probably belongs elsewhere
 
 OBJS = \
+	auth-oauth.o \
 	auth-sasl.o \
 	auth-scram.o \
 	auth.o \
diff --git a/src/backend/libpq/auth-oauth.c b/src/backend/libpq/auth-oauth.c
new file mode 100644
index 0000000000..eea5032de8
--- /dev/null
+++ b/src/backend/libpq/auth-oauth.c
@@ -0,0 +1,812 @@
+/*-------------------------------------------------------------------------
+ *
+ * auth-oauth.c
+ *	  Server-side implementation of the SASL OAUTHBEARER mechanism.
+ *
+ * See the following RFC for more details:
+ * - RFC 7628: https://tools.ietf.org/html/rfc7628
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/backend/libpq/auth-oauth.c
+ *
+ *-------------------------------------------------------------------------
+ */
+#include "postgres.h"
+
+#include <unistd.h>
+#include <fcntl.h>
+
+#include "common/oauth-common.h"
+#include "fmgr.h"
+#include "lib/stringinfo.h"
+#include "libpq/auth.h"
+#include "libpq/hba.h"
+#include "libpq/oauth.h"
+#include "libpq/sasl.h"
+#include "storage/fd.h"
+#include "storage/ipc.h"
+#include "utils/json.h"
+#include "utils/varlena.h"
+
+/* GUC */
+char	   *oauth_validator_libraries_string = NULL;
+
+static void oauth_get_mechanisms(Port *port, StringInfo buf);
+static void *oauth_init(Port *port, const char *selected_mech, const char *shadow_pass);
+static int	oauth_exchange(void *opaq, const char *input, int inputlen,
+						   char **output, int *outputlen, const char **logdetail);
+
+static void load_validator_library(const char *libname);
+static void shutdown_validator_library(int code, Datum arg);
+
+static ValidatorModuleState *validator_module_state;
+static const OAuthValidatorCallbacks *ValidatorCallbacks;
+
+/* Mechanism declaration */
+const pg_be_sasl_mech pg_be_oauth_mech = {
+	oauth_get_mechanisms,
+	oauth_init,
+	oauth_exchange,
+
+	PG_MAX_AUTH_TOKEN_LENGTH,
+};
+
+
+typedef enum
+{
+	OAUTH_STATE_INIT = 0,
+	OAUTH_STATE_ERROR,
+	OAUTH_STATE_FINISHED,
+} oauth_state;
+
+struct oauth_ctx
+{
+	oauth_state state;
+	Port	   *port;
+	const char *issuer;
+	const char *scope;
+};
+
+static char *sanitize_char(char c);
+static char *parse_kvpairs_for_auth(char **input);
+static void generate_error_response(struct oauth_ctx *ctx, char **output, int *outputlen);
+static bool validate(Port *port, const char *auth);
+
+#define KVSEP 0x01
+#define AUTH_KEY "auth"
+#define BEARER_SCHEME "Bearer "
+
+static void
+oauth_get_mechanisms(Port *port, StringInfo buf)
+{
+	/* Only OAUTHBEARER is supported. */
+	appendStringInfoString(buf, OAUTHBEARER_NAME);
+	appendStringInfoChar(buf, '\0');
+}
+
+static void *
+oauth_init(Port *port, const char *selected_mech, const char *shadow_pass)
+{
+	struct oauth_ctx *ctx;
+
+	if (strcmp(selected_mech, OAUTHBEARER_NAME) != 0)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("client selected an invalid SASL authentication mechanism"));
+
+	ctx = palloc0(sizeof(*ctx));
+
+	ctx->state = OAUTH_STATE_INIT;
+	ctx->port = port;
+
+	Assert(port->hba);
+	ctx->issuer = port->hba->oauth_issuer;
+	ctx->scope = port->hba->oauth_scope;
+
+	load_validator_library(port->hba->oauth_validator);
+
+	return ctx;
+}
+
+static int
+oauth_exchange(void *opaq, const char *input, int inputlen,
+			   char **output, int *outputlen, const char **logdetail)
+{
+	char	   *input_copy;
+	char	   *p;
+	char		cbind_flag;
+	char	   *auth;
+	int			status;
+
+	struct oauth_ctx *ctx = opaq;
+
+	*output = NULL;
+	*outputlen = -1;
+
+	/*
+	 * If the client didn't include an "Initial Client Response" in the
+	 * SASLInitialResponse message, send an empty challenge, to which the
+	 * client will respond with the same data that usually comes in the
+	 * Initial Client Response.
+	 */
+	if (input == NULL)
+	{
+		Assert(ctx->state == OAUTH_STATE_INIT);
+
+		*output = pstrdup("");
+		*outputlen = 0;
+		return PG_SASL_EXCHANGE_CONTINUE;
+	}
+
+	/*
+	 * Check that the input length agrees with the string length of the input.
+	 */
+	if (inputlen == 0)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("The message is empty."));
+	if (inputlen != strlen(input))
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message length does not match input length."));
+
+	switch (ctx->state)
+	{
+		case OAUTH_STATE_INIT:
+			/* Handle this case below. */
+			break;
+
+		case OAUTH_STATE_ERROR:
+
+			/*
+			 * Only one response is valid for the client during authentication
+			 * failure: a single kvsep.
+			 */
+			if (inputlen != 1 || *input != KVSEP)
+				ereport(ERROR,
+						errcode(ERRCODE_PROTOCOL_VIOLATION),
+						errmsg("malformed OAUTHBEARER message"),
+						errdetail("Client did not send a kvsep response."));
+
+			/* The (failed) handshake is now complete. */
+			ctx->state = OAUTH_STATE_FINISHED;
+			return PG_SASL_EXCHANGE_FAILURE;
+
+		default:
+			elog(ERROR, "invalid OAUTHBEARER exchange state");
+			return PG_SASL_EXCHANGE_FAILURE;
+	}
+
+	/* Handle the client's initial message. */
+	p = input_copy = pstrdup(input);
+
+	/*
+	 * OAUTHBEARER does not currently define a channel binding (so there is no
+	 * OAUTHBEARER-PLUS, and we do not accept a 'p' specifier). We accept a
+	 * 'y' specifier purely for the remote chance that a future specification
+	 * could define one; then future clients can still interoperate with this
+	 * server implementation. 'n' is the expected case.
+	 */
+	cbind_flag = *p;
+	switch (cbind_flag)
+	{
+		case 'p':
+			ereport(ERROR,
+					errcode(ERRCODE_PROTOCOL_VIOLATION),
+					errmsg("malformed OAUTHBEARER message"),
+					errdetail("The server does not support channel binding for OAuth, but the client message includes channel binding data."));
+			break;
+
+		case 'y':				/* fall through */
+		case 'n':
+			p++;
+			if (*p != ',')
+				ereport(ERROR,
+						errcode(ERRCODE_PROTOCOL_VIOLATION),
+						errmsg("malformed OAUTHBEARER message"),
+						errdetail("Comma expected, but found character \"%s\".",
+								  sanitize_char(*p)));
+			p++;
+			break;
+
+		default:
+			ereport(ERROR,
+					errcode(ERRCODE_PROTOCOL_VIOLATION),
+					errmsg("malformed OAUTHBEARER message"),
+					errdetail("Unexpected channel-binding flag \"%s\".",
+							  sanitize_char(cbind_flag)));
+	}
+
+	/*
+	 * Forbid optional authzid (authorization identity).  We don't support it.
+	 */
+	if (*p == 'a')
+		ereport(ERROR,
+				errcode(ERRCODE_FEATURE_NOT_SUPPORTED),
+				errmsg("client uses authorization identity, but it is not supported"));
+	if (*p != ',')
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Unexpected attribute \"%s\" in client-first-message.",
+						  sanitize_char(*p)));
+	p++;
+
+	/* All remaining fields are separated by the RFC's kvsep (\x01). */
+	if (*p != KVSEP)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Key-value separator expected, but found character \"%s\".",
+						  sanitize_char(*p)));
+	p++;
+
+	auth = parse_kvpairs_for_auth(&p);
+	if (!auth)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message does not contain an auth value."));
+
+	/* We should be at the end of our message. */
+	if (*p)
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message contains additional data after the final terminator."));
+
+	if (!validate(ctx->port, auth))
+	{
+		generate_error_response(ctx, output, outputlen);
+
+		ctx->state = OAUTH_STATE_ERROR;
+		status = PG_SASL_EXCHANGE_CONTINUE;
+	}
+	else
+	{
+		ctx->state = OAUTH_STATE_FINISHED;
+		status = PG_SASL_EXCHANGE_SUCCESS;
+	}
+
+	/* Don't let extra copies of the bearer token hang around. */
+	explicit_bzero(input_copy, inputlen);
+
+	return status;
+}
+
+/*
+ * Convert an arbitrary byte to printable form.  For error messages.
+ *
+ * If it's a printable ASCII character, print it as a single character.
+ * otherwise, print it in hex.
+ *
+ * The returned pointer points to a static buffer.
+ */
+static char *
+sanitize_char(char c)
+{
+	static char buf[5];
+
+	if (c >= 0x21 && c <= 0x7E)
+		snprintf(buf, sizeof(buf), "'%c'", c);
+	else
+		snprintf(buf, sizeof(buf), "0x%02x", (unsigned char) c);
+	return buf;
+}
+
+/*
+ * Performs syntactic validation of a key and value from the initial client
+ * response. (Semantic validation of interesting values must be performed
+ * later.)
+ */
+static void
+validate_kvpair(const char *key, const char *val)
+{
+	/*-----
+	 * From Sec 3.1:
+	 *     key            = 1*(ALPHA)
+	 */
+	static const char *key_allowed_set =
+		"abcdefghijklmnopqrstuvwxyz"
+		"ABCDEFGHIJKLMNOPQRSTUVWXYZ";
+
+	size_t		span;
+
+	if (!key[0])
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message contains an empty key name."));
+
+	span = strspn(key, key_allowed_set);
+	if (key[span] != '\0')
+		ereport(ERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAUTHBEARER message"),
+				errdetail("Message contains an invalid key name."));
+
+	/*-----
+	 * From Sec 3.1:
+	 *     value          = *(VCHAR / SP / HTAB / CR / LF )
+	 *
+	 * The VCHAR (visible character) class is large; a loop is more
+	 * straightforward than strspn().
+	 */
+	for (; *val; ++val)
+	{
+		if (0x21 <= *val && *val <= 0x7E)
+			continue;			/* VCHAR */
+
+		switch (*val)
+		{
+			case ' ':
+			case '\t':
+			case '\r':
+			case '\n':
+				continue;		/* SP, HTAB, CR, LF */
+
+			default:
+				ereport(ERROR,
+						errcode(ERRCODE_PROTOCOL_VIOLATION),
+						errmsg("malformed OAUTHBEARER message"),
+						errdetail("Message contains an invalid value."));
+		}
+	}
+}
+
+/*
+ * Consumes all kvpairs in an OAUTHBEARER exchange message. If the "auth" key is
+ * found, its value is returned.
+ */
+static char *
+parse_kvpairs_for_auth(char **input)
+{
+	char	   *pos = *input;
+	char	   *auth = NULL;
+
+	/*----
+	 * The relevant ABNF, from Sec. 3.1:
+	 *
+	 *     kvsep          = %x01
+	 *     key            = 1*(ALPHA)
+	 *     value          = *(VCHAR / SP / HTAB / CR / LF )
+	 *     kvpair         = key "=" value kvsep
+	 *   ;;gs2-header     = See RFC 5801
+	 *     client-resp    = (gs2-header kvsep *kvpair kvsep) / kvsep
+	 *
+	 * By the time we reach this code, the gs2-header and initial kvsep have
+	 * already been validated. We start at the beginning of the first kvpair.
+	 */
+
+	while (*pos)
+	{
+		char	   *end;
+		char	   *sep;
+		char	   *key;
+		char	   *value;
+
+		/*
+		 * Find the end of this kvpair. Note that input is null-terminated by
+		 * the SASL code, so the strchr() is bounded.
+		 */
+		end = strchr(pos, KVSEP);
+		if (!end)
+			ereport(ERROR,
+					errcode(ERRCODE_PROTOCOL_VIOLATION),
+					errmsg("malformed OAUTHBEARER message"),
+					errdetail("Message contains an unterminated key/value pair."));
+		*end = '\0';
+
+		if (pos == end)
+		{
+			/* Empty kvpair, signifying the end of the list. */
+			*input = pos + 1;
+			return auth;
+		}
+
+		/*
+		 * Find the end of the key name.
+		 */
+		sep = strchr(pos, '=');
+		if (!sep)
+			ereport(ERROR,
+					errcode(ERRCODE_PROTOCOL_VIOLATION),
+					errmsg("malformed OAUTHBEARER message"),
+					errdetail("Message contains a key without a value."));
+		*sep = '\0';
+
+		/* Both key and value are now safely terminated. */
+		key = pos;
+		value = sep + 1;
+		validate_kvpair(key, value);
+
+		if (!strcmp(key, AUTH_KEY))
+		{
+			if (auth)
+				ereport(ERROR,
+						errcode(ERRCODE_PROTOCOL_VIOLATION),
+						errmsg("malformed OAUTHBEARER message"),
+						errdetail("Message contains multiple auth values."));
+
+			auth = value;
+		}
+		else
+		{
+			/*
+			 * The RFC also defines the host and port keys, but they are not
+			 * required for OAUTHBEARER and we do not use them. Also, per Sec.
+			 * 3.1, any key/value pairs we don't recognize must be ignored.
+			 */
+		}
+
+		/* Move to the next pair. */
+		pos = end + 1;
+	}
+
+	ereport(ERROR,
+			errcode(ERRCODE_PROTOCOL_VIOLATION),
+			errmsg("malformed OAUTHBEARER message"),
+			errdetail("Message did not contain a final terminator."));
+
+	pg_unreachable();
+	return NULL;
+}
+
+static void
+generate_error_response(struct oauth_ctx *ctx, char **output, int *outputlen)
+{
+	StringInfoData buf;
+	StringInfoData issuer;
+
+	/*
+	 * The admin needs to set an issuer and scope for OAuth to work. There's
+	 * not really a way to hide this from the user, either, because we can't
+	 * choose a "default" issuer, so be honest in the failure message.
+	 *
+	 * TODO: see if there's a better place to fail, earlier than this.
+	 */
+	if (!ctx->issuer || !ctx->scope)
+		ereport(FATAL,
+				errcode(ERRCODE_INTERNAL_ERROR),
+				errmsg("OAuth is not properly configured for this user"),
+				errdetail_log("The issuer and scope parameters must be set in pg_hba.conf."));
+
+	/*
+	 * Build a default .well-known URI based on our issuer, unless the HBA has
+	 * already provided one.
+	 */
+	initStringInfo(&issuer);
+	appendStringInfoString(&issuer, ctx->issuer);
+	if (strstr(ctx->issuer, "/.well-known/") == NULL)
+		appendStringInfoString(&issuer, "/.well-known/openid-configuration");
+
+	initStringInfo(&buf);
+
+	/*
+	 * Escaping the string here is belt-and-suspenders defensive programming
+	 * since escapable characters aren't valid in either the issuer URI or the
+	 * scope list, but the HBA doesn't enforce that yet.
+	 */
+	appendStringInfoString(&buf, "{ \"status\": \"invalid_token\", ");
+
+	appendStringInfoString(&buf, "\"openid-configuration\": ");
+	escape_json(&buf, issuer.data);
+	pfree(issuer.data);
+
+	appendStringInfoString(&buf, ", \"scope\": ");
+	escape_json(&buf, ctx->scope);
+
+	appendStringInfoString(&buf, " }");
+
+	*output = buf.data;
+	*outputlen = buf.len;
+}
+
+/*-----
+ * Validates the provided Authorization header and returns the token from
+ * within it. NULL is returned on validation failure.
+ *
+ * Only Bearer tokens are accepted. The ABNF is defined in RFC 6750, Sec.
+ * 2.1:
+ *
+ *      b64token    = 1*( ALPHA / DIGIT /
+ *                        "-" / "." / "_" / "~" / "+" / "/" ) *"="
+ *      credentials = "Bearer" 1*SP b64token
+ *
+ * The "credentials" construction is what we receive in our auth value.
+ *
+ * Since that spec is subordinate to HTTP (i.e. the HTTP Authorization
+ * header format; RFC 7235 Sec. 2), the "Bearer" scheme string must be
+ * compared case-insensitively. (This is not mentioned in RFC 6750, but
+ * it's pointed out in RFC 7628 Sec. 4.)
+ *
+ * Invalid formats are technically a protocol violation, but we shouldn't
+ * reflect any information about the sensitive Bearer token back to the
+ * client; log at COMMERROR instead.
+ *
+ * TODO: handle the Authorization spec, RFC 7235 Sec. 2.1.
+ */
+static const char *
+validate_token_format(const char *header)
+{
+	size_t		span;
+	const char *token;
+	static const char *const b64token_allowed_set =
+		"abcdefghijklmnopqrstuvwxyz"
+		"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+		"0123456789-._~+/";
+
+	/* Missing auth headers should be handled by the caller. */
+	Assert(header);
+
+	if (header[0] == '\0')
+	{
+		/*
+		 * A completely empty auth header represents a query for
+		 * authentication parameters. The client expects it to fail; there's
+		 * no need to make any extra noise in the logs.
+		 *
+		 * TODO: should we find a way to return STATUS_EOF at the top level,
+		 * to suppress the authentication error entirely?
+		 */
+		return NULL;
+	}
+
+	if (pg_strncasecmp(header, BEARER_SCHEME, strlen(BEARER_SCHEME)))
+	{
+		ereport(COMMERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAuth bearer token"),
+				errdetail_log("Client response indicated a non-Bearer authentication scheme."));
+		return NULL;
+	}
+
+	/* Pull the bearer token out of the auth value. */
+	token = header + strlen(BEARER_SCHEME);
+
+	/* Swallow any additional spaces. */
+	while (*token == ' ')
+		token++;
+
+	/* Tokens must not be empty. */
+	if (!*token)
+	{
+		ereport(COMMERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAuth bearer token"),
+				errdetail_log("Bearer token is empty."));
+		return NULL;
+	}
+
+	/*
+	 * Make sure the token contains only allowed characters. Tokens may end
+	 * with any number of '=' characters.
+	 */
+	span = strspn(token, b64token_allowed_set);
+	while (token[span] == '=')
+		span++;
+
+	if (token[span] != '\0')
+	{
+		/*
+		 * This error message could be more helpful by printing the
+		 * problematic character(s), but that'd be a bit like printing a piece
+		 * of someone's password into the logs.
+		 */
+		ereport(COMMERROR,
+				errcode(ERRCODE_PROTOCOL_VIOLATION),
+				errmsg("malformed OAuth bearer token"),
+				errdetail_log("Bearer token is not in the correct format."));
+		return NULL;
+	}
+
+	return token;
+}
+
+static bool
+validate(Port *port, const char *auth)
+{
+	int			map_status;
+	ValidatorModuleResult *ret;
+	const char *token;
+	bool		status;
+
+	/* Ensure that we have a correct token to validate */
+	if (!(token = validate_token_format(auth)))
+		return false;
+
+	/* Call the validation function from the validator module */
+	ret = ValidatorCallbacks->validate_cb(validator_module_state,
+										  token, port->user_name);
+	if (ret == NULL)
+	{
+		ereport(LOG, errmsg("Internal error in OAuth validator module"));
+		return false;
+	}
+
+	if (!ret->authorized)
+	{
+		status = false;
+		goto cleanup;
+	}
+
+	if (ret->authn_id)
+		set_authn_id(port, ret->authn_id);
+
+	if (port->hba->oauth_skip_usermap)
+	{
+		/*
+		 * If the validator is our authorization authority, we're done.
+		 * Authentication may or may not have been performed depending on the
+		 * validator implementation; all that matters is that the validator
+		 * says the user can log in with the target role.
+		 */
+		status = true;
+		goto cleanup;
+	}
+
+	/* Make sure the validator authenticated the user. */
+	if (ret->authn_id == NULL || ret->authn_id[0] == '\0')
+	{
+		ereport(LOG,
+				errmsg("OAuth bearer authentication failed for user \"%s\"",
+					   port->user_name),
+				errdetail_log("Validator provided no identity."));
+
+		status = false;
+		goto cleanup;
+	}
+
+	/* Finally, check the user map. */
+	map_status = check_usermap(port->hba->usermap, port->user_name,
+							   MyClientConnectionInfo.authn_id, false);
+	status = (map_status == STATUS_OK);
+
+cleanup:
+
+	/*
+	 * Clear and free the validation result from the validator module once
+	 * we're done with it.
+	 */
+	if (ret->authn_id != NULL)
+		pfree(ret->authn_id);
+	pfree(ret);
+
+	return status;
+}
+
+/*
+ * load_validator_library
+ *
+ * Load the configured validator library in order to perform token validation.
+ * There is no built-in fallback since validation is implementation specific. If
+ * no validator library is configured, or if it fails to load, then error out
+ * since token validation won't be possible.
+ */
+static void
+load_validator_library(const char *libname)
+{
+	OAuthValidatorModuleInit validator_init;
+
+	Assert(libname && *libname);
+
+	validator_init = (OAuthValidatorModuleInit)
+		load_external_function(libname, "_PG_oauth_validator_module_init",
+							   false, NULL);
+
+	/*
+	 * The validator init function is required since it will set the callbacks
+	 * for the validator library.
+	 */
+	if (validator_init == NULL)
+		ereport(ERROR,
+				errmsg("%s module \"%s\" must define the symbol %s",
+					   "OAuth validator", libname, "_PG_oauth_validator_module_init"));
+
+	ValidatorCallbacks = (*validator_init) ();
+
+	/* Allocate memory for validator library private state data */
+	validator_module_state = (ValidatorModuleState *) palloc0(sizeof(ValidatorModuleState));
+	if (ValidatorCallbacks->startup_cb != NULL)
+		ValidatorCallbacks->startup_cb(validator_module_state);
+
+	before_shmem_exit(shutdown_validator_library, 0);
+}
+
+static void
+shutdown_validator_library(int code, Datum arg)
+{
+	if (ValidatorCallbacks->shutdown_cb != NULL)
+		ValidatorCallbacks->shutdown_cb(validator_module_state);
+}
+
+/*
+ * Ensure an OAuth validator named in the HBA is permitted by the configuration.
+ *
+ * If the validator is currently unset and exactly one library is declared in
+ * oauth_validator_libraries, then that library will be used as the validator.
+ * Otherwise the name must be present in the list of oauth_validator_libraries.
+ */
+bool
+check_oauth_validator(HbaLine *hbaline, int elevel, char **err_msg)
+{
+	int			line_num = hbaline->linenumber;
+	char	   *file_name = hbaline->sourcefile;
+	char	   *rawstring;
+	List	   *elemlist = NIL;
+	ListCell   *l;
+
+	*err_msg = NULL;
+
+	if (oauth_validator_libraries_string[0] == '\0')
+	{
+		ereport(elevel,
+				errcode(ERRCODE_CONFIG_FILE_ERROR),
+				errmsg("oauth_validator_libraries must be set for authentication method %s",
+					   "oauth"),
+				errcontext("line %d of configuration file \"%s\"",
+						   line_num, file_name));
+		*err_msg = psprintf("oauth_validator_libraries must be set for authentication method %s",
+							"oauth");
+		return false;
+	}
+
+	/* SplitDirectoriesString needs a modifiable copy */
+	rawstring = pstrdup(oauth_validator_libraries_string);
+
+	if (!SplitDirectoriesString(rawstring, ',', &elemlist))
+	{
+		/* syntax error in list */
+		ereport(elevel,
+				errcode(ERRCODE_CONFIG_FILE_ERROR),
+				errmsg("invalid list syntax in parameter \"%s\"",
+					   "oauth_validator_libraries"));
+		*err_msg = psprintf("invalid list syntax in parameter \"%s\"",
+							"oauth_validator_libraries");
+		goto done;
+	}
+
+	if (!hbaline->oauth_validator)
+	{
+		if (elemlist->length == 1)
+		{
+			hbaline->oauth_validator = pstrdup(linitial(elemlist));
+			goto done;
+		}
+
+		ereport(elevel,
+				errcode(ERRCODE_CONFIG_FILE_ERROR),
+				errmsg("authentication method \"oauth\" requires argument \"validator\" to be set when oauth_validator_libraries contains multiple options"),
+				errcontext("line %d of configuration file \"%s\"",
+						   line_num, file_name));
+		*err_msg = "authentication method \"oauth\" requires argument \"validator\" to be set when oauth_validator_libraries contains multiple options";
+		goto done;
+	}
+
+	foreach(l, elemlist)
+	{
+		char	   *allowed = lfirst(l);
+
+		if (strcmp(allowed, hbaline->oauth_validator) == 0)
+			goto done;
+	}
+
+	ereport(elevel,
+			errcode(ERRCODE_INVALID_PARAMETER_VALUE),
+			errmsg("validator \"%s\" is not permitted by %s",
+				   hbaline->oauth_validator, "oauth_validator_libraries"),
+			errcontext("line %d of configuration file \"%s\"",
+					   line_num, file_name));
+	*err_msg = psprintf("validator \"%s\" is not permitted by %s",
+						hbaline->oauth_validator, "oauth_validator_libraries");
+
+done:
+	list_free_deep(elemlist);
+	pfree(rawstring);
+
+	return (*err_msg == NULL);
+}
diff --git a/src/backend/libpq/auth.c b/src/backend/libpq/auth.c
index 47e8c91606..0cf3e31c9f 100644
--- a/src/backend/libpq/auth.c
+++ b/src/backend/libpq/auth.c
@@ -29,6 +29,7 @@
 #include "libpq/auth.h"
 #include "libpq/crypt.h"
 #include "libpq/libpq.h"
+#include "libpq/oauth.h"
 #include "libpq/pqformat.h"
 #include "libpq/sasl.h"
 #include "libpq/scram.h"
@@ -45,7 +46,6 @@
  */
 static void auth_failed(Port *port, int status, const char *logdetail);
 static char *recv_password_packet(Port *port);
-static void set_authn_id(Port *port, const char *id);
 
 
 /*----------------------------------------------------------------
@@ -201,22 +201,6 @@ static int	CheckRADIUSAuth(Port *port);
 static int	PerformRadiusTransaction(const char *server, const char *secret, const char *portstr, const char *identifier, const char *user_name, const char *passwd);
 
 
-/*
- * Maximum accepted size of GSS and SSPI authentication tokens.
- * We also use this as a limit on ordinary password packet lengths.
- *
- * Kerberos tickets are usually quite small, but the TGTs issued by Windows
- * domain controllers include an authorization field known as the Privilege
- * Attribute Certificate (PAC), which contains the user's Windows permissions
- * (group memberships etc.). The PAC is copied into all tickets obtained on
- * the basis of this TGT (even those issued by Unix realms which the Windows
- * realm trusts), and can be several kB in size. The maximum token size
- * accepted by Windows systems is determined by the MaxAuthToken Windows
- * registry setting. Microsoft recommends that it is not set higher than
- * 65535 bytes, so that seems like a reasonable limit for us as well.
- */
-#define PG_MAX_AUTH_TOKEN_LENGTH	65535
-
 /*----------------------------------------------------------------
  * Global authentication functions
  *----------------------------------------------------------------
@@ -305,6 +289,9 @@ auth_failed(Port *port, int status, const char *logdetail)
 		case uaRADIUS:
 			errstr = gettext_noop("RADIUS authentication failed for user \"%s\"");
 			break;
+		case uaOAuth:
+			errstr = gettext_noop("OAuth bearer authentication failed for user \"%s\"");
+			break;
 		default:
 			errstr = gettext_noop("authentication failed for user \"%s\": invalid authentication method");
 			break;
@@ -340,7 +327,7 @@ auth_failed(Port *port, int status, const char *logdetail)
  * lifetime of MyClientConnectionInfo, so it is safe to pass a string that is
  * managed by an external library.
  */
-static void
+void
 set_authn_id(Port *port, const char *id)
 {
 	Assert(id);
@@ -627,6 +614,9 @@ ClientAuthentication(Port *port)
 		case uaTrust:
 			status = STATUS_OK;
 			break;
+		case uaOAuth:
+			status = CheckSASLAuth(&pg_be_oauth_mech, port, NULL, NULL);
+			break;
 	}
 
 	if ((status == STATUS_OK && port->hba->clientcert == clientCertFull)
diff --git a/src/backend/libpq/hba.c b/src/backend/libpq/hba.c
index 2fd96a7129..180f09d26f 100644
--- a/src/backend/libpq/hba.c
+++ b/src/backend/libpq/hba.c
@@ -32,6 +32,7 @@
 #include "libpq/hba.h"
 #include "libpq/ifaddr.h"
 #include "libpq/libpq-be.h"
+#include "libpq/oauth.h"
 #include "postmaster/postmaster.h"
 #include "regex/regex.h"
 #include "replication/walsender.h"
@@ -114,7 +115,8 @@ static const char *const UserAuthName[] =
 	"ldap",
 	"cert",
 	"radius",
-	"peer"
+	"peer",
+	"oauth",
 };
 
 /*
@@ -1747,6 +1749,8 @@ parse_hba_line(TokenizedAuthLine *tok_line, int elevel)
 #endif
 	else if (strcmp(token->string, "radius") == 0)
 		parsedline->auth_method = uaRADIUS;
+	else if (strcmp(token->string, "oauth") == 0)
+		parsedline->auth_method = uaOAuth;
 	else
 	{
 		ereport(elevel,
@@ -2039,6 +2043,36 @@ parse_hba_line(TokenizedAuthLine *tok_line, int elevel)
 		parsedline->clientcert = clientCertFull;
 	}
 
+	/*
+	 * Enforce proper configuration of OAuth authentication.
+	 */
+	if (parsedline->auth_method == uaOAuth)
+	{
+		MANDATORY_AUTH_ARG(parsedline->oauth_scope, "scope", "oauth");
+		MANDATORY_AUTH_ARG(parsedline->oauth_issuer, "issuer", "oauth");
+
+		/* Ensure a validator library is set and permitted by the config. */
+		if (!check_oauth_validator(parsedline, elevel, err_msg))
+			return NULL;
+
+		/*
+		 * Supplying a usermap combined with the option to skip usermapping is
+		 * nonsensical and indicates a configuration error.
+		 */
+		if (parsedline->oauth_skip_usermap && parsedline->usermap != NULL)
+		{
+			ereport(elevel,
+					errcode(ERRCODE_CONFIG_FILE_ERROR),
+			/* translator: strings are replaced with hba options */
+					errmsg("%s cannot be used in combination with %s",
+						   "map", "trust_validator_authz"),
+					errcontext("line %d of configuration file \"%s\"",
+							   line_num, file_name));
+			*err_msg = "map cannot be used in combination with trust_validator_authz";
+			return NULL;
+		}
+	}
+
 	return parsedline;
 }
 
@@ -2066,8 +2100,9 @@ parse_hba_auth_opt(char *name, char *val, HbaLine *hbaline,
 			hbaline->auth_method != uaPeer &&
 			hbaline->auth_method != uaGSS &&
 			hbaline->auth_method != uaSSPI &&
-			hbaline->auth_method != uaCert)
-			INVALID_AUTH_OPTION("map", gettext_noop("ident, peer, gssapi, sspi, and cert"));
+			hbaline->auth_method != uaCert &&
+			hbaline->auth_method != uaOAuth)
+			INVALID_AUTH_OPTION("map", gettext_noop("ident, peer, gssapi, sspi, cert, and oauth"));
 		hbaline->usermap = pstrdup(val);
 	}
 	else if (strcmp(name, "clientcert") == 0)
@@ -2450,6 +2485,29 @@ parse_hba_auth_opt(char *name, char *val, HbaLine *hbaline,
 		hbaline->radiusidentifiers = parsed_identifiers;
 		hbaline->radiusidentifiers_s = pstrdup(val);
 	}
+	else if (strcmp(name, "issuer") == 0)
+	{
+		REQUIRE_AUTH_OPTION(uaOAuth, "issuer", "oauth");
+		hbaline->oauth_issuer = pstrdup(val);
+	}
+	else if (strcmp(name, "scope") == 0)
+	{
+		REQUIRE_AUTH_OPTION(uaOAuth, "scope", "oauth");
+		hbaline->oauth_scope = pstrdup(val);
+	}
+	else if (strcmp(name, "validator") == 0)
+	{
+		REQUIRE_AUTH_OPTION(uaOAuth, "validator", "oauth");
+		hbaline->oauth_validator = pstrdup(val);
+	}
+	else if (strcmp(name, "trust_validator_authz") == 0)
+	{
+		REQUIRE_AUTH_OPTION(uaOAuth, "trust_validator_authz", "oauth");
+		if (strcmp(val, "1") == 0)
+			hbaline->oauth_skip_usermap = true;
+		else
+			hbaline->oauth_skip_usermap = false;
+	}
 	else
 	{
 		ereport(elevel,
diff --git a/src/backend/libpq/meson.build b/src/backend/libpq/meson.build
index 7c65314512..c85527fb01 100644
--- a/src/backend/libpq/meson.build
+++ b/src/backend/libpq/meson.build
@@ -1,6 +1,7 @@
 # Copyright (c) 2022-2024, PostgreSQL Global Development Group
 
 backend_sources += files(
+  'auth-oauth.c',
   'auth-sasl.c',
   'auth-scram.c',
   'auth.c',
diff --git a/src/backend/libpq/pg_hba.conf.sample b/src/backend/libpq/pg_hba.conf.sample
index bad13497a3..b64c8dea97 100644
--- a/src/backend/libpq/pg_hba.conf.sample
+++ b/src/backend/libpq/pg_hba.conf.sample
@@ -53,8 +53,8 @@
 # directly connected to.
 #
 # METHOD can be "trust", "reject", "md5", "password", "scram-sha-256",
-# "gss", "sspi", "ident", "peer", "pam", "ldap", "radius" or "cert".
-# Note that "password" sends passwords in clear text; "md5" or
+# "gss", "sspi", "ident", "peer", "pam", "oauth", "ldap", "radius" or
+# "cert".  Note that "password" sends passwords in clear text; "md5" or
 # "scram-sha-256" are preferred since they send encrypted passwords.
 #
 # OPTIONS are a set of options for the authentication in the format
diff --git a/src/backend/utils/misc/guc_tables.c b/src/backend/utils/misc/guc_tables.c
index 8a67f01200..1295a94ec3 100644
--- a/src/backend/utils/misc/guc_tables.c
+++ b/src/backend/utils/misc/guc_tables.c
@@ -49,6 +49,7 @@
 #include "jit/jit.h"
 #include "libpq/auth.h"
 #include "libpq/libpq.h"
+#include "libpq/oauth.h"
 #include "libpq/scram.h"
 #include "nodes/queryjumble.h"
 #include "optimizer/cost.h"
@@ -4794,6 +4795,17 @@ struct config_string ConfigureNamesString[] =
 		check_restrict_nonsystem_relation_kind, assign_restrict_nonsystem_relation_kind, NULL
 	},
 
+	{
+		{"oauth_validator_libraries", PGC_SIGHUP, CONN_AUTH_AUTH,
+			gettext_noop("Lists libraries that may be called to validate OAuth v2 bearer tokens."),
+			NULL,
+			GUC_LIST_INPUT | GUC_LIST_QUOTE | GUC_SUPERUSER_ONLY
+		},
+		&oauth_validator_libraries_string,
+		"",
+		NULL, NULL, NULL
+	},
+
 	/* End-of-list marker */
 	{
 		{NULL, 0, 0, NULL, NULL}, NULL, NULL, NULL, NULL, NULL
diff --git a/src/backend/utils/misc/postgresql.conf.sample b/src/backend/utils/misc/postgresql.conf.sample
index 39a3ac2312..609f057fe7 100644
--- a/src/backend/utils/misc/postgresql.conf.sample
+++ b/src/backend/utils/misc/postgresql.conf.sample
@@ -120,6 +120,9 @@
 #ssl_passphrase_command = ''
 #ssl_passphrase_command_supports_reload = off
 
+# OAuth
+#oauth_validator_libraries = ''
+
 
 #------------------------------------------------------------------------------
 # RESOURCE USAGE (except WAL)
diff --git a/src/include/common/oauth-common.h b/src/include/common/oauth-common.h
new file mode 100644
index 0000000000..8fe5626778
--- /dev/null
+++ b/src/include/common/oauth-common.h
@@ -0,0 +1,19 @@
+/*-------------------------------------------------------------------------
+ *
+ * oauth-common.h
+ *		Declarations for helper functions used for OAuth/OIDC authentication
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/include/common/oauth-common.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef OAUTH_COMMON_H
+#define OAUTH_COMMON_H
+
+/* Name of SASL mechanism per IANA */
+#define OAUTHBEARER_NAME "OAUTHBEARER"
+
+#endif							/* OAUTH_COMMON_H */
diff --git a/src/include/libpq/auth.h b/src/include/libpq/auth.h
index 227b41daf6..22f6ab9f1d 100644
--- a/src/include/libpq/auth.h
+++ b/src/include/libpq/auth.h
@@ -16,6 +16,22 @@
 
 #include "libpq/libpq-be.h"
 
+/*
+ * Maximum accepted size of GSS and SSPI authentication tokens.
+ * We also use this as a limit on ordinary password packet lengths.
+ *
+ * Kerberos tickets are usually quite small, but the TGTs issued by Windows
+ * domain controllers include an authorization field known as the Privilege
+ * Attribute Certificate (PAC), which contains the user's Windows permissions
+ * (group memberships etc.). The PAC is copied into all tickets obtained on
+ * the basis of this TGT (even those issued by Unix realms which the Windows
+ * realm trusts), and can be several kB in size. The maximum token size
+ * accepted by Windows systems is determined by the MaxAuthToken Windows
+ * registry setting. Microsoft recommends that it is not set higher than
+ * 65535 bytes, so that seems like a reasonable limit for us as well.
+ */
+#define PG_MAX_AUTH_TOKEN_LENGTH	65535
+
 extern PGDLLIMPORT char *pg_krb_server_keyfile;
 extern PGDLLIMPORT bool pg_krb_caseins_users;
 extern PGDLLIMPORT bool pg_gss_accept_delegation;
@@ -23,6 +39,7 @@ extern PGDLLIMPORT bool pg_gss_accept_delegation;
 extern void ClientAuthentication(Port *port);
 extern void sendAuthRequest(Port *port, AuthRequest areq, const char *extradata,
 							int extralen);
+extern void set_authn_id(Port *port, const char *id);
 
 /* Hook for plugins to get control in ClientAuthentication() */
 typedef void (*ClientAuthentication_hook_type) (Port *, int);
diff --git a/src/include/libpq/hba.h b/src/include/libpq/hba.h
index 8ea837ae82..fb333a1578 100644
--- a/src/include/libpq/hba.h
+++ b/src/include/libpq/hba.h
@@ -39,7 +39,8 @@ typedef enum UserAuth
 	uaCert,
 	uaRADIUS,
 	uaPeer,
-#define USER_AUTH_LAST uaPeer	/* Must be last value of this enum */
+	uaOAuth,
+#define USER_AUTH_LAST uaOAuth	/* Must be last value of this enum */
 } UserAuth;
 
 /*
@@ -135,6 +136,10 @@ typedef struct HbaLine
 	char	   *radiusidentifiers_s;
 	List	   *radiusports;
 	char	   *radiusports_s;
+	char	   *oauth_issuer;
+	char	   *oauth_scope;
+	char	   *oauth_validator;
+	bool		oauth_skip_usermap;
 } HbaLine;
 
 typedef struct IdentLine
diff --git a/src/include/libpq/oauth.h b/src/include/libpq/oauth.h
new file mode 100644
index 0000000000..4fcdda7430
--- /dev/null
+++ b/src/include/libpq/oauth.h
@@ -0,0 +1,54 @@
+/*-------------------------------------------------------------------------
+ *
+ * oauth.h
+ *	  Interface to libpq/auth-oauth.c
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/include/libpq/oauth.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef PG_OAUTH_H
+#define PG_OAUTH_H
+
+#include "libpq/libpq-be.h"
+#include "libpq/sasl.h"
+
+extern PGDLLIMPORT char *oauth_validator_libraries_string;
+
+typedef struct ValidatorModuleState
+{
+	void	   *private_data;
+} ValidatorModuleState;
+
+typedef struct ValidatorModuleResult
+{
+	bool		authorized;
+	char	   *authn_id;
+} ValidatorModuleResult;
+
+typedef void (*ValidatorStartupCB) (ValidatorModuleState *state);
+typedef void (*ValidatorShutdownCB) (ValidatorModuleState *state);
+typedef ValidatorModuleResult *(*ValidatorValidateCB) (ValidatorModuleState *state, const char *token, const char *role);
+
+typedef struct OAuthValidatorCallbacks
+{
+	ValidatorStartupCB startup_cb;
+	ValidatorShutdownCB shutdown_cb;
+	ValidatorValidateCB validate_cb;
+} OAuthValidatorCallbacks;
+
+typedef const OAuthValidatorCallbacks *(*OAuthValidatorModuleInit) (void);
+extern PGDLLEXPORT const OAuthValidatorCallbacks *_PG_oauth_validator_module_init(void);
+
+/* Implementation */
+extern const pg_be_sasl_mech pg_be_oauth_mech;
+
+/*
+ * Ensure a validator named in the HBA is permitted by the configuration.
+ */
+extern bool check_oauth_validator(HbaLine *hba, int elevel, char **err_msg);
+
+#endif							/* PG_OAUTH_H */
diff --git a/src/include/pg_config.h.in b/src/include/pg_config.h.in
index a903c60a3a..9fe47091d2 100644
--- a/src/include/pg_config.h.in
+++ b/src/include/pg_config.h.in
@@ -229,6 +229,9 @@
 /* Define to 1 if you have the `crypto' library (-lcrypto). */
 #undef HAVE_LIBCRYPTO
 
+/* Define to 1 if you have the `curl' library (-lcurl). */
+#undef HAVE_LIBCURL
+
 /* Define to 1 if you have the `ldap' library (-lldap). */
 #undef HAVE_LIBLDAP
 
@@ -678,6 +681,9 @@
 /* Define to 1 to build with BSD Authentication support. (--with-bsd-auth) */
 #undef USE_BSD_AUTH
 
+/* Define to 1 to build with OAuth 2.0 client flows. (--with-builtin-oauth) */
+#undef USE_BUILTIN_OAUTH
+
 /* Define to build with ICU support. (--with-icu) */
 #undef USE_ICU
 
@@ -706,6 +712,9 @@
 /* Define to select named POSIX semaphores. */
 #undef USE_NAMED_POSIX_SEMAPHORES
 
+/* Define to 1 to use libcurl for OAuth client flows. */
+#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 c1bf33dbdc..6502059d16 100644
--- a/src/interfaces/libpq/Makefile
+++ b/src/interfaces/libpq/Makefile
@@ -31,6 +31,7 @@ endif
 
 OBJS = \
 	$(WIN32RES) \
+	fe-auth-oauth.o \
 	fe-auth-scram.o \
 	fe-cancel.o \
 	fe-connect.o \
@@ -63,6 +64,10 @@ OBJS += \
 	fe-secure-gssapi.o
 endif
 
+ifeq ($(with_builtin_oauth),curl)
+OBJS += fe-auth-oauth-curl.o
+endif
+
 ifeq ($(PORTNAME), cygwin)
 override shlib = cyg$(NAME)$(DLSUFFIX)
 endif
@@ -81,7 +86,7 @@ endif
 # that are built correctly for use in a shlib.
 SHLIB_LINK_INTERNAL = -lpgcommon_shlib -lpgport_shlib
 ifneq ($(PORTNAME), win32)
-SHLIB_LINK += $(filter -lcrypt -ldes -lcom_err -lcrypto -lk5crypto -lkrb5 -lgssapi_krb5 -lgss -lgssapi -lssl -lsocket -lnsl -lresolv -lintl -lm, $(LIBS)) $(LDAP_LIBS_FE) $(PTHREAD_LIBS)
+SHLIB_LINK += $(filter -lcrypt -ldes -lcom_err -lcrypto -lk5crypto -lkrb5 -lgssapi_krb5 -lgss -lgssapi -lssl -lcurl -lsocket -lnsl -lresolv -lintl -lm, $(LIBS)) $(LDAP_LIBS_FE) $(PTHREAD_LIBS)
 else
 SHLIB_LINK += $(filter -lcrypt -ldes -lcom_err -lcrypto -lk5crypto -lkrb5 -lgssapi32 -lssl -lsocket -lnsl -lresolv -lintl -lm $(PTHREAD_LIBS), $(LIBS)) $(LDAP_LIBS_FE)
 endif
@@ -110,6 +115,8 @@ backend_src = $(top_srcdir)/src/backend
 # which seems to insert references to that even in pure C code. Excluding
 # __tsan_func_exit is necessary when using ThreadSanitizer data race detector
 # which use this function for instrumentation of function exit.
+# libcurl registers an exit handler in the memory debugging code when running
+# with LeakSanitizer.
 # Skip the test when profiling, as gcc may insert exit() calls for that.
 # Also skip the test on platforms where libpq infrastructure may be provided
 # by statically-linked libraries, as we can't expect them to honor this
@@ -117,7 +124,7 @@ backend_src = $(top_srcdir)/src/backend
 libpq-refs-stamp: $(shlib)
 ifneq ($(enable_coverage), yes)
 ifeq (,$(filter solaris,$(PORTNAME)))
-	@if nm -A -u $< 2>/dev/null | grep -v -e __cxa_atexit -e __tsan_func_exit | grep exit; then \
+	@if nm -A -u $< 2>/dev/null | grep -v -e __cxa_atexit -e __tsan_func_exit -e _atexit | grep exit; then \
 		echo 'libpq must not be calling any function which invokes exit'; exit 1; \
 	fi
 endif
diff --git a/src/interfaces/libpq/exports.txt b/src/interfaces/libpq/exports.txt
index 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..e8804ce6d1
--- /dev/null
+++ b/src/interfaces/libpq/fe-auth-oauth-curl.c
@@ -0,0 +1,2440 @@
+/*-------------------------------------------------------------------------
+ *
+ * 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"
+
+#define MAX_OAUTH_RESPONSE_SIZE (1024 * 1024)
+
+/*
+ * Parsed JSON Representations
+ *
+ * As a general rule, we parse and cache only the fields we're currently using.
+ * When adding new fields, ensure the corresponding free_*() function is updated
+ * too.
+ */
+
+/*
+ * The OpenID Provider configuration (alternatively named "authorization server
+ * metadata") jointly described by OpenID Connect Discovery 1.0 and RFC 8414:
+ *
+ *     https://openid.net/specs/openid-connect-discovery-1_0.html
+ *     https://www.rfc-editor.org/rfc/rfc8414#section-3.2
+ */
+struct provider
+{
+	char	   *issuer;
+	char	   *token_endpoint;
+	char	   *device_authorization_endpoint;
+	struct curl_slist *grant_types_supported;
+};
+
+static void
+free_provider(struct provider *provider)
+{
+	free(provider->issuer);
+	free(provider->token_endpoint);
+	free(provider->device_authorization_endpoint);
+	curl_slist_free_all(provider->grant_types_supported);
+}
+
+/*
+ * The Device Authorization response, described by RFC 8628:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc8628#section-3.2
+ */
+struct device_authz
+{
+	char	   *device_code;
+	char	   *user_code;
+	char	   *verification_uri;
+	char	   *interval_str;
+
+	/* Fields below are parsed from the corresponding string above. */
+	int			interval;
+};
+
+static void
+free_device_authz(struct device_authz *authz)
+{
+	free(authz->device_code);
+	free(authz->user_code);
+	free(authz->verification_uri);
+	free(authz->interval_str);
+}
+
+/*
+ * The Token Endpoint error response, as described by RFC 6749:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc6749#section-5.2
+ *
+ * Note that this response type can also be returned from the Device
+ * Authorization Endpoint.
+ */
+struct token_error
+{
+	char	   *error;
+	char	   *error_description;
+};
+
+static void
+free_token_error(struct token_error *err)
+{
+	free(err->error);
+	free(err->error_description);
+}
+
+/*
+ * The Access Token response, as described by RFC 6749:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc6749#section-4.1.4
+ *
+ * During the Device Authorization flow, several temporary errors are expected
+ * as part of normal operation. To make it easy to handle these in the happy
+ * path, this contains an embedded token_error that is filled in if needed.
+ */
+struct token
+{
+	/* for successful responses */
+	char	   *access_token;
+	char	   *token_type;
+
+	/* for error responses */
+	struct token_error err;
+};
+
+static void
+free_token(struct token *tok)
+{
+	free(tok->access_token);
+	free(tok->token_type);
+	free_token_error(&tok->err);
+}
+
+/*
+ * Asynchronous State
+ */
+
+/* States for the overall async machine. */
+typedef enum
+{
+	OAUTH_STEP_INIT = 0,
+	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		used_basic_auth;	/* did we send a client secret? */
+	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! */
+
+			case ' ':
+			case '\t':
+				/* HTTP optional whitespace allows only spaces and htabs. */
+				break;
+
+			default:
+				goto fail;
+		}
+	}
+
+fail:
+	actx_error(actx, "unexpected content type: \"%s\"", content_type);
+	return false;
+}
+
+/*
+ * A helper function for general JSON parsing. fields is the array of field
+ * definitions with their backing pointers. The response will be parsed from
+ * actx->curl and actx->work_data (as set up by start_request()), and any
+ * parsing errors will be placed into actx->errbuf.
+ */
+static bool
+parse_oauth_json(struct async_ctx *actx, const struct json_field *fields)
+{
+	PQExpBuffer resp = &actx->work_data;
+	JsonLexContext lex = {0};
+	JsonSemAction sem = {0};
+	JsonParseErrorType err;
+	struct oauth_parse ctx = {0};
+	bool		success = false;
+
+	if (!check_content_type(actx, "application/json"))
+		return false;
+
+	if (strlen(resp->data) != resp->len)
+	{
+		actx_error(actx, "response contains embedded NULLs");
+		return false;
+	}
+
+	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 specifies 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;
+}
+
+/*
+ * Constructs a message from the token error response and puts it into
+ * actx->errbuf.
+ */
+static void
+record_token_error(struct async_ctx *actx, const struct token_error *err)
+{
+	if (err->error_description)
+		appendPQExpBuffer(&actx->errbuf, "%s ", err->error_description);
+	else
+	{
+		/*
+		 * Try to get some more helpful detail into the error string. A 401
+		 * status in particular implies that the oauth_client_secret is
+		 * missing or wrong.
+		 */
+		long		response_code;
+
+		CHECK_GETINFO(actx, CURLINFO_RESPONSE_CODE, &response_code, response_code = 0);
+
+		if (response_code == 401)
+		{
+			actx_error(actx, actx->used_basic_auth
+					   ? "provider rejected the oauth_client_secret"
+					   : "provider requires client authentication, and no oauth_client_secret is set");
+			actx_error_str(actx, " ");
+		}
+	}
+
+	appendPQExpBuffer(&actx->errbuf, "(%s)", err->error);
+}
+
+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);
+	}
+
+	/* TODO: would anyone use this in "real" situations, or just testing? */
+	if (actx->debugging)
+	{
+		const char *env;
+
+		if ((env = getenv("PGOAUTHCAFILE")) != NULL)
+			CHECK_SETOPT(actx, CURLOPT_CAINFO, env, return false);
+	}
+
+	/*
+	 * Suppress the Accept header to make our request as minimal as possible.
+	 * (Ideally we would set it to "application/json" instead, but OpenID is
+	 * pretty strict when it comes to provider behavior, so we have to check
+	 * what comes back anyway.)
+	 */
+	actx->headers = curl_slist_append(actx->headers, "Accept:");
+	if (actx->headers == NULL)
+	{
+		actx_error(actx, "out of memory");
+		return false;
+	}
+	CHECK_SETOPT(actx, CURLOPT_HTTPHEADER, actx->headers, return false);
+
+	return true;
+}
+
+/*
+ * Generic HTTP Request Handlers
+ */
+
+/*
+ * Response callback from libcurl which appends the response body into
+ * actx->work_data (see start_request()). The maximum size of the data is
+ * defined by CURL_MAX_WRITE_SIZE which by default is 16kb (and can only be
+ * changed by recompiling libcurl).
+ */
+static size_t
+append_data(char *buf, size_t size, size_t nmemb, void *userdata)
+{
+	struct async_ctx *actx = userdata;
+	PQExpBuffer resp = &actx->work_data;
+	size_t		len = size * nmemb;
+
+	/* In case we receive data over the threshold, abort the transfer */
+	if ((resp->len + len) > MAX_OAUTH_RESPONSE_SIZE)
+	{
+		actx_error(actx, "response is too large");
+		return 0;
+	}
+
+	/* The data passed from libcurl is not null-terminated */
+	appendBinaryPQExpBuffer(resp, buf, len);
+
+	/*
+	 * Signal an error in order to abort the transfer in case we ran out of
+	 * memory in accepting the data.
+	 */
+	if (PQExpBufferBroken(resp))
+	{
+		actx_error(actx, "out of memory");
+		return 0;
+	}
+
+	return len;
+}
+
+/*
+ * Begins an HTTP request on the multi handle. The caller should have set up all
+ * request-specific options on actx->curl first. The server's response body will
+ * be accumulated in actx->work_data (which will be reset, so don't store
+ * anything important there across this call).
+ *
+ * Once a request is queued, it can be driven to completion via drive_request().
+ * If actx->running is zero upon return, the request has already finished and
+ * drive_request() can be called without returning control to the client.
+ */
+static bool
+start_request(struct async_ctx *actx)
+{
+	CURLMcode	err;
+
+	resetPQExpBuffer(&actx->work_data);
+	CHECK_SETOPT(actx, CURLOPT_WRITEFUNCTION, append_data, return false);
+	CHECK_SETOPT(actx, CURLOPT_WRITEDATA, actx, return false);
+
+	err = curl_multi_add_handle(actx->curlm, actx->curl);
+	if (err)
+	{
+		actx_error(actx, "failed to queue HTTP request: %s",
+				   curl_multi_strerror(err));
+		return false;
+	}
+
+	/*
+	 * actx->running tracks the number of running handles, so we can
+	 * immediately call back if no waiting is needed.
+	 *
+	 * Even though this is nominally an asynchronous process, there are some
+	 * operations that can synchronously fail by this point (e.g. connections
+	 * to closed local ports) or even synchronously succeed if the stars align
+	 * (all the libcurl connection caches hit and the server is fast).
+	 */
+	err = curl_multi_socket_action(actx->curlm, CURL_SOCKET_TIMEOUT, 0, &actx->running);
+	if (err)
+	{
+		actx_error(actx, "asynchronous HTTP request failed: %s",
+				   curl_multi_strerror(err));
+		return false;
+	}
+
+	return true;
+}
+
+/*
+ * CURL_IGNORE_DEPRECATION was added in 7.87.0. If it's not defined, we can make
+ * it a no-op.
+ */
+#ifndef CURL_IGNORE_DEPRECATION
+#define CURL_IGNORE_DEPRECATION(x) x
+#endif
+
+/*
+ * Drives the multi handle towards completion. The caller should have already
+ * set up an asynchronous request via start_request().
+ */
+static PostgresPollingStatusType
+drive_request(struct async_ctx *actx)
+{
+	CURLMcode	err;
+	CURLMsg    *msg;
+	int			msgs_left;
+	bool		done;
+
+	if (actx->running)
+	{
+		/*---
+		 * There's an async request in progress. Pump the multi handle.
+		 *
+		 * curl_multi_socket_all() is officially deprecated, because it's
+		 * inefficient and pointless if your event loop has already handed you
+		 * the exact sockets that are ready. But that's not our use case --
+		 * our client has no way to tell us which sockets are ready. (They
+		 * don't even know there are sockets to begin with.)
+		 *
+		 * We can grab the list of triggered events from the multiplexer
+		 * ourselves, but that's effectively what curl_multi_socket_all() is
+		 * going to do. And there are currently no plans for the Curl project
+		 * to remove or break this API, so ignore the deprecation. See
+		 *
+		 *    https://curl.se/mail/lib-2024-11/0028.html
+		 *
+		 */
+		CURL_IGNORE_DEPRECATION(
+			err = curl_multi_socket_all(actx->curlm, &actx->running);
+		)
+
+		if (err)
+		{
+			actx_error(actx, "asynchronous HTTP request failed: %s",
+					   curl_multi_strerror(err));
+			return PGRES_POLLING_FAILED;
+		}
+
+		if (actx->running)
+		{
+			/* We'll come back again. */
+			return PGRES_POLLING_READING;
+		}
+	}
+
+	done = false;
+	while ((msg = curl_multi_info_read(actx->curlm, &msgs_left)) != NULL)
+	{
+		if (msg->msg != CURLMSG_DONE)
+		{
+			/*
+			 * Future libcurl versions may define new message types; we don't
+			 * know how to handle them, so we'll ignore them.
+			 */
+			continue;
+		}
+
+		/* First check the status of the request itself. */
+		if (msg->data.result != CURLE_OK)
+		{
+			/*
+			 * If a more specific error hasn't already been reported, use
+			 * libcurl's description.
+			 */
+			if (actx->errbuf.len == 0)
+				actx_error_str(actx, curl_easy_strerror(msg->data.result));
+
+			return PGRES_POLLING_FAILED;
+		}
+
+		/* Now remove the finished handle; we'll add it back later if needed. */
+		err = curl_multi_remove_handle(actx->curlm, msg->easy_handle);
+		if (err)
+		{
+			actx_error(actx, "libcurl easy handle removal failed: %s",
+					   curl_multi_strerror(err));
+			return PGRES_POLLING_FAILED;
+		}
+
+		done = true;
+	}
+
+	/* Sanity check. */
+	if (!done)
+	{
+		actx_error(actx, "no result was retrieved for the finished handle");
+		return PGRES_POLLING_FAILED;
+	}
+
+	return PGRES_POLLING_OK;
+}
+
+/*
+ * URL-Encoding Helpers
+ */
+
+/*
+ * Encodes a string using the application/x-www-form-urlencoded format, and
+ * appends it to the given buffer.
+ */
+static void
+append_urlencoded(PQExpBuffer buf, const char *s)
+{
+	char	   *escaped;
+	char	   *haystack;
+	char	   *match;
+
+	escaped = curl_easy_escape(NULL, s, 0);
+	if (!escaped)
+	{
+		markPQExpBufferBroken(buf);
+		return;
+	}
+
+	/*
+	 * curl_easy_escape() almost does what we want, but we need the
+	 * query-specific flavor which uses '+' instead of '%20' for spaces. The
+	 * Curl command-line tool does this with a simple search-and-replace, so
+	 * follow its lead.
+	 */
+	haystack = escaped;
+
+	while ((match = strstr(haystack, "%20")) != NULL)
+	{
+		/* Append the unmatched portion, followed by the plus sign. */
+		appendBinaryPQExpBuffer(buf, haystack, match - haystack);
+		appendPQExpBufferChar(buf, '+');
+
+		/* Keep searching after the match. */
+		haystack = match + 3 /* strlen("%20") */ ;
+	}
+
+	/* Push the remainder of the string onto the buffer. */
+	appendPQExpBufferStr(buf, haystack);
+
+	curl_free(escaped);
+}
+
+/*
+ * Convenience wrapper for encoding a single string. Returns NULL on allocation
+ * failure.
+ */
+static char *
+urlencode(const char *s)
+{
+	PQExpBufferData buf;
+
+	initPQExpBuffer(&buf);
+	append_urlencoded(&buf, s);
+
+	return PQExpBufferDataBroken(buf) ? NULL : buf.data;
+}
+
+/*
+ * Appends a key/value pair to the end of an application/x-www-form-urlencoded
+ * list.
+ */
+static void
+build_urlencoded(PQExpBuffer buf, const char *key, const char *value)
+{
+	if (buf->len)
+		appendPQExpBufferChar(buf, '&');
+
+	append_urlencoded(buf, key);
+	appendPQExpBufferChar(buf, '=');
+	append_urlencoded(buf, value);
+}
+
+/*
+ * Specific HTTP Request Handlers
+ *
+ * This is finally the beginning of the actual application logic. Generally
+ * speaking, a single request consists of a start_* and a finish_* step, with
+ * drive_request() pumping the machine in between.
+ */
+
+/*
+ * Queue an OpenID Provider Configuration Request:
+ *
+ *     https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfigurationRequest
+ *     https://www.rfc-editor.org/rfc/rfc8414#section-3.1
+ *
+ * This is done first to get the endpoint URIs we need to contact and to make
+ * sure the provider provides a device authorization flow. finish_discovery()
+ * will fill in actx->provider.
+ */
+static bool
+start_discovery(struct async_ctx *actx, const char *discovery_uri)
+{
+	CHECK_SETOPT(actx, CURLOPT_HTTPGET, 1L, return false);
+	CHECK_SETOPT(actx, CURLOPT_URL, discovery_uri, return false);
+
+	return start_request(actx);
+}
+
+static bool
+finish_discovery(struct async_ctx *actx)
+{
+	long		response_code;
+
+	/*----
+	 * Now check the response. OIDC Discovery 1.0 is pretty strict:
+	 *
+	 *     A successful response MUST use the 200 OK HTTP status code and
+	 *     return a JSON object using the application/json content type that
+	 *     contains a set of Claims as its members that are a subset of the
+	 *     Metadata values defined in Section 3.
+	 *
+	 * Compared to standard HTTP semantics, this makes life easy -- we don't
+	 * need to worry about redirections (which would call the Issuer host
+	 * validation into question), or non-authoritative responses, or any other
+	 * complications.
+	 */
+	CHECK_GETINFO(actx, CURLINFO_RESPONSE_CODE, &response_code, return false);
+
+	if (response_code != 200)
+	{
+		actx_error(actx, "unexpected response code %ld", response_code);
+		return false;
+	}
+
+	/*
+	 * Pull the fields we care about from the document.
+	 */
+	actx->errctx = "failed to parse OpenID discovery document";
+	if (!parse_provider(actx, &actx->provider))
+		return false;			/* error message already set */
+
+	/*
+	 * Fill in any defaults for OPTIONAL/RECOMMENDED fields we care about.
+	 */
+	if (!actx->provider.grant_types_supported)
+	{
+		/*
+		 * Per Section 3, the default is ["authorization_code", "implicit"].
+		 */
+		struct curl_slist *temp = actx->provider.grant_types_supported;
+
+		temp = curl_slist_append(temp, "authorization_code");
+		if (temp)
+		{
+			temp = curl_slist_append(temp, "implicit");
+		}
+
+		if (!temp)
+		{
+			actx_error(actx, "out of memory");
+			return false;
+		}
+
+		actx->provider.grant_types_supported = temp;
+	}
+
+	return true;
+}
+
+/*
+ * Ensure that the discovery document is provided by the expected issuer.
+ * Currently, issuers are statically configured in the connection string.
+ */
+static bool
+check_issuer(struct async_ctx *actx, PGconn *conn)
+{
+	const struct provider *provider = &actx->provider;
+
+	Assert(conn->oauth_issuer_id);	/* ensured by setup_oauth_parameters() */
+	Assert(provider->issuer);	/* ensured by parse_provider() */
+
+	/*---
+	 * We require strict equality for issuer identifiers -- no path or case
+	 * normalization, no substitution of default ports and schemes, etc. This
+	 * is done to match the rules in OIDC Discovery Sec. 4.3 for config
+	 * validation:
+	 *
+	 *    The issuer value returned MUST be identical to the Issuer URL that
+	 *    was used as the prefix to /.well-known/openid-configuration to
+	 *    retrieve the configuration information.
+	 *
+	 * as well as the rules set out in RFC 9207 for avoiding mix-up attacks:
+	 *
+	 *    Clients MUST then [...] compare the result to the issuer identifier
+	 *    of the authorization server where the authorization request was
+	 *    sent to. This comparison MUST use simple string comparison as defined
+	 *    in Section 6.2.1 of [RFC3986].
+	 *
+	 * TODO: Encoding support?
+	 */
+	if (strcmp(conn->oauth_issuer_id, provider->issuer) != 0)
+	{
+		actx_error(actx,
+				   "the issuer identifier (%s) does not match oauth_issuer (%s)",
+				   provider->issuer, conn->oauth_issuer_id);
+		return false;
+	}
+
+	return true;
+}
+
+#define OAUTH_GRANT_TYPE_DEVICE_CODE "urn:ietf:params:oauth:grant-type:device_code"
+
+/*
+ * Ensure that the provider supports the Device Authorization flow (i.e. it
+ * accepts the device_code grant type and provides an authorization endpoint).
+ */
+static bool
+check_for_device_flow(struct async_ctx *actx)
+{
+	const struct provider *provider = &actx->provider;
+	const struct curl_slist *grant;
+	bool		device_grant_found = false;
+
+	Assert(provider->issuer);	/* ensured by parse_provider() */
+
+	/*------
+	 * First, sanity checks for discovery contents that are OPTIONAL in the
+	 * spec but required for our flow:
+	 * - the issuer must support the device_code grant
+	 * - the issuer must have actually given us a
+	 *   device_authorization_endpoint
+	 */
+
+	grant = provider->grant_types_supported;
+	while (grant)
+	{
+		if (strcmp(grant->data, OAUTH_GRANT_TYPE_DEVICE_CODE) == 0)
+		{
+			device_grant_found = true;
+			break;
+		}
+
+		grant = grant->next;
+	}
+
+	if (!device_grant_found)
+	{
+		actx_error(actx, "issuer \"%s\" does not support device code grants",
+				   provider->issuer);
+		return false;
+	}
+
+	if (!provider->device_authorization_endpoint)
+	{
+		actx_error(actx,
+				   "issuer \"%s\" does not provide a device authorization endpoint",
+				   provider->issuer);
+		return false;
+	}
+
+	/* TODO: check that the endpoint uses HTTPS */
+
+	return true;
+}
+
+/*
+ * Adds the client ID (and secret, if provided) to the current request, using
+ * either HTTP headers or the request body.
+ */
+static bool
+add_client_identification(struct async_ctx *actx, PQExpBuffer reqbody, PGconn *conn)
+{
+	bool		success = false;
+	char	   *username = NULL;
+	char	   *password = NULL;
+
+	if (conn->oauth_client_secret)	/* Zero-length secrets are permitted! */
+	{
+		/*----
+		 * Use HTTP Basic auth to send the client_id and secret. Per RFC 6749,
+		 * Sec. 2.3.1,
+		 *
+		 *   Including the client credentials in the request-body using the
+		 *   two parameters is NOT RECOMMENDED and SHOULD be limited to
+		 *   clients unable to directly utilize the HTTP Basic authentication
+		 *   scheme (or other password-based HTTP authentication schemes).
+		 *
+		 * Additionally:
+		 *
+		 *   The client identifier is encoded using the
+		 *   "application/x-www-form-urlencoded" encoding algorithm per Appendix
+		 *   B, and the encoded value is used as the username; the client
+		 *   password is encoded using the same algorithm and used as the
+		 *   password.
+		 *
+		 * (Appendix B modifies application/x-www-form-urlencoded by requiring
+		 * an initial UTF-8 encoding step. Since the client ID and secret must
+		 * both be 7-bit ASCII -- RFC 6749 Appendix A -- we don't worry about
+		 * that in this function.)
+		 *
+		 * client_id is not added to the request body in this case. Not only
+		 * would it be redundant, but some providers in the wild (e.g. Okta)
+		 * refuse to accept it.
+		 */
+		username = urlencode(conn->oauth_client_id);
+		password = urlencode(conn->oauth_client_secret);
+
+		if (!username || !password)
+		{
+			actx_error(actx, "out of memory");
+			goto cleanup;
+		}
+
+		CHECK_SETOPT(actx, CURLOPT_HTTPAUTH, CURLAUTH_BASIC, goto cleanup);
+		CHECK_SETOPT(actx, CURLOPT_USERNAME, username, goto cleanup);
+		CHECK_SETOPT(actx, CURLOPT_PASSWORD, password, goto cleanup);
+
+		actx->used_basic_auth = true;
+	}
+	else
+	{
+		/*
+		 * If we're not otherwise authenticating, client_id is REQUIRED in the
+		 * request body.
+		 */
+		build_urlencoded(reqbody, "client_id", conn->oauth_client_id);
+
+		CHECK_SETOPT(actx, CURLOPT_HTTPAUTH, CURLAUTH_NONE, goto cleanup);
+		actx->used_basic_auth = false;
+	}
+
+	success = true;
+
+cleanup:
+	free(username);
+	free(password);
+
+	return success;
+}
+
+/*
+ * Queue a Device Authorization Request:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc8628#section-3.1
+ *
+ * This is the second step. We ask the provider to verify the end user out of
+ * band and authorize us to act on their behalf; it will give us the required
+ * nonces for us to later poll the request status, which we'll grab in
+ * finish_device_authz().
+ */
+static bool
+start_device_authz(struct async_ctx *actx, PGconn *conn)
+{
+	const char *device_authz_uri = actx->provider.device_authorization_endpoint;
+	PQExpBuffer work_buffer = &actx->work_data;
+
+	Assert(conn->oauth_client_id);	/* ensured by setup_oauth_parameters() */
+	Assert(device_authz_uri);	/* ensured by check_for_device_flow() */
+
+	/* Construct our request body. */
+	resetPQExpBuffer(work_buffer);
+	if (conn->oauth_scope && conn->oauth_scope[0])
+		build_urlencoded(work_buffer, "scope", conn->oauth_scope);
+
+	if (!add_client_identification(actx, work_buffer, conn))
+		return false;
+
+	if (PQExpBufferBroken(work_buffer))
+	{
+		actx_error(actx, "out of memory");
+		return false;
+	}
+
+	/* Make our request. */
+	CHECK_SETOPT(actx, CURLOPT_URL, device_authz_uri, return false);
+	CHECK_SETOPT(actx, CURLOPT_COPYPOSTFIELDS, work_buffer->data, return false);
+
+	return start_request(actx);
+}
+
+static bool
+finish_device_authz(struct async_ctx *actx)
+{
+	long		response_code;
+
+	CHECK_GETINFO(actx, CURLINFO_RESPONSE_CODE, &response_code, return false);
+
+	/*
+	 * Per RFC 8628, Section 3, a successful device authorization response
+	 * uses 200 OK.
+	 */
+	if (response_code == 200)
+	{
+		actx->errctx = "failed to parse device authorization";
+		if (!parse_device_authz(actx, &actx->authz))
+			return false;		/* error message already set */
+
+		return true;
+	}
+
+	/*
+	 * The device authorization endpoint uses the same error response as the
+	 * token endpoint, so the error handling roughly follows
+	 * finish_token_request(). The key difference is that an error here is
+	 * immediately fatal.
+	 */
+	if (response_code == 400 || response_code == 401)
+	{
+		struct token_error err = {0};
+
+		if (!parse_token_error(actx, &err))
+		{
+			free_token_error(&err);
+			return false;
+		}
+
+		record_token_error(actx, &err);
+
+		free_token_error(&err);
+		return false;
+	}
+
+	/* Any other response codes are considered invalid */
+	actx_error(actx, "unexpected response code %ld", response_code);
+	return false;
+}
+
+/*
+ * Queue an Access Token Request:
+ *
+ *     https://www.rfc-editor.org/rfc/rfc6749#section-4.1.3
+ *
+ * This is the final step. We continually poll the token endpoint to see if the
+ * user has authorized us yet. finish_token_request() will pull either the token
+ * or a (ideally temporary) error status from the provider.
+ */
+static bool
+start_token_request(struct async_ctx *actx, PGconn *conn)
+{
+	const char *token_uri = actx->provider.token_endpoint;
+	const char *device_code = actx->authz.device_code;
+	PQExpBuffer work_buffer = &actx->work_data;
+
+	Assert(conn->oauth_client_id);	/* ensured by setup_oauth_parameters() */
+	Assert(token_uri);			/* ensured by parse_provider() */
+	Assert(device_code);		/* ensured by parse_device_authz() */
+
+	/* Construct our request body. */
+	resetPQExpBuffer(work_buffer);
+	build_urlencoded(work_buffer, "device_code", device_code);
+	build_urlencoded(work_buffer, "grant_type", OAUTH_GRANT_TYPE_DEVICE_CODE);
+
+	if (!add_client_identification(actx, work_buffer, conn))
+		return false;
+
+	if (PQExpBufferBroken(work_buffer))
+	{
+		actx_error(actx, "out of memory");
+		return false;
+	}
+
+	/* Make our request. */
+	CHECK_SETOPT(actx, CURLOPT_URL, token_uri, return false);
+	CHECK_SETOPT(actx, CURLOPT_COPYPOSTFIELDS, work_buffer->data, return false);
+
+	return start_request(actx);
+}
+
+static bool
+finish_token_request(struct async_ctx *actx, struct token *tok)
+{
+	long		response_code;
+
+	CHECK_GETINFO(actx, CURLINFO_RESPONSE_CODE, &response_code, return false);
+
+	/*
+	 * Per RFC 6749, Section 5, a successful response uses 200 OK.
+	 */
+	if (response_code == 200)
+	{
+		actx->errctx = "failed to parse access token response";
+		if (!parse_access_token(actx, tok))
+			return false;		/* error message already set */
+
+		return true;
+	}
+
+	/*
+	 * An error response uses either 400 Bad Request or 401 Unauthorized.
+	 * There are references online to implementations using 403 for error
+	 * return which would violate the specification. For now we stick to the
+	 * specification but we might have to revisit this.
+	 */
+	if (response_code == 400 || response_code == 401)
+	{
+		if (!parse_token_error(actx, &tok->err))
+			return false;
+
+		return true;
+	}
+
+	/* Any other response codes are considered invalid */
+	actx_error(actx, "unexpected response code %ld", response_code);
+	return false;
+}
+
+/*
+ * Finishes the token request and examines the response. If the flow has
+ * completed, a valid token will be returned via the parameter list. Otherwise,
+ * the token parameter remains unchanged, and the caller needs to wait for
+ * another interval (which will have been increased in response to a slow_down
+ * message from the server) before starting a new token request.
+ *
+ * False is returned only for permanent error conditions.
+ */
+static bool
+handle_token_response(struct async_ctx *actx, char **token)
+{
+	bool		success = false;
+	struct token tok = {0};
+	const struct token_error *err;
+
+	if (!finish_token_request(actx, &tok))
+		goto token_cleanup;
+
+	/* A successful token request gives either a token or an in-band error. */
+	Assert(tok.access_token || tok.err.error);
+
+	if (tok.access_token)
+	{
+		*token = tok.access_token;
+		tok.access_token = NULL;
+
+		success = true;
+		goto token_cleanup;
+	}
+
+	/*
+	 * authorization_pending and slow_down are the only acceptable errors;
+	 * anything else and we bail.
+	 */
+	err = &tok.err;
+	if (strcmp(err->error, "authorization_pending") != 0 &&
+		strcmp(err->error, "slow_down") != 0)
+	{
+		record_token_error(actx, err);
+		goto token_cleanup;
+	}
+
+	/*
+	 * A slow_down error requires us to permanently increase our retry
+	 * interval by five seconds. 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)
+	{
+		/*
+		 * translator: The first %s is a URL for the user to visit in a
+		 * browser, and the second %s is a code to be copy-pasted there.
+		 */
+		fprintf(stderr, libpq_gettext("Visit %s and enter the code: %s\n"),
+				prompt.verification_uri, prompt.user_code);
+	}
+	else if (res < 0)
+	{
+		actx_error(actx, "device prompt failed");
+		return false;
+	}
+
+	return true;
+}
+
+
+/*
+ * The 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)
+	{
+		/*
+		 * Create our asynchronous state, and hook it into the upper-level
+		 * OAuth state immediately, so any failures below won't leak the
+		 * context allocation.
+		 */
+		actx = calloc(1, sizeof(*actx));
+		if (!actx)
+		{
+			libpq_append_conn_error(conn, "out of memory");
+			return PGRES_POLLING_FAILED;
+		}
+
+		actx->mux = PGINVALID_SOCKET;
+#ifdef HAVE_SYS_EPOLL_H
+		actx->timerfd = -1;
+#endif
+
+		/* Should we enable unsafe features? */
+		actx->debugging = oauth_unsafe_debugging_enabled();
+
+		state->async_ctx = actx;
+		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;
+
+				if (!check_issuer(actx, conn))
+					goto error_return;
+
+				actx->errctx = "cannot run OAuth device authorization";
+				if (!check_for_device_flow(actx))
+					goto error_return;
+
+				actx->errctx = "failed to obtain device authorization";
+				if (!start_device_authz(actx, conn))
+					goto error_return;
+
+				actx->step = OAUTH_STEP_DEVICE_AUTHORIZATION;
+				break;
+
+			case OAUTH_STEP_DEVICE_AUTHORIZATION:
+				if (!finish_device_authz(actx))
+					goto error_return;
+
+				actx->errctx = "failed to obtain access token";
+				if (!start_token_request(actx, conn))
+					goto error_return;
+
+				actx->step = OAUTH_STEP_TOKEN_REQUEST;
+				break;
+
+			case OAUTH_STEP_TOKEN_REQUEST:
+				if (!handle_token_response(actx, &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..0d4185194d
--- /dev/null
+++ b/src/interfaces/libpq/fe-auth-oauth.c
@@ -0,0 +1,900 @@
+/*-------------------------------------------------------------------------
+ *
+ * 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"
+
+/*
+ * Constructs an OAUTHBEARER client initial response (RFC 7628, Sec. 3.1).
+ *
+ * If discover is true, the token pointer will be ignored and the initial
+ * response will instead contain a request for the server's required OAuth
+ * parameters (Sec. 4.3). Otherwise, a bearer token must be provided.
+ *
+ * Returns the response as a null-terminated string, or NULL on error.
+ */
+static char *
+client_initial_response(PGconn *conn, bool discover, const char *token)
+{
+	static const char *const resp_format = "n,," kvsep "auth=%s%s" kvsep kvsep;
+
+	PQExpBufferData buf;
+	const char *authn_scheme;
+	char	   *response = NULL;
+
+	if (discover)
+	{
+		/* Parameter discovery uses a completely empty auth value. */
+		authn_scheme = token = "";
+	}
+	else
+	{
+		/*
+		 * Use a Bearer authentication scheme (RFC 6750, Sec. 2.1). A trailing
+		 * space is used as a separator.
+		 */
+		authn_scheme = "Bearer ";
+
+		/* We must have a token. */
+		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, authn_scheme, token);
+
+	if (!PQExpBufferDataBroken(buf))
+		response = strdup(buf.data);
+	termPQExpBuffer(&buf);
+
+	if (!response)
+		libpq_append_conn_error(conn, "out of memory");
+
+	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;
+}
+
+#define HTTPS_SCHEME "https://"
+#define HTTP_SCHEME "http://"
+
+/* We support both well-known suffixes defined by RFC 8414. */
+#define WK_PREFIX "/.well-known/"
+#define OPENID_WK_SUFFIX "openid-configuration"
+#define OAUTH_WK_SUFFIX "oauth-authorization-server"
+
+/*
+ * Derives an issuer identifier from one of our recognized .well-known URIs,
+ * using the rules in RFC 8414.
+ */
+static char *
+issuer_from_well_known_uri(PGconn *conn, const char *wkuri)
+{
+	const char *authority_start = NULL;
+	const char *wk_start;
+	const char *wk_end;
+	char	   *issuer;
+	ptrdiff_t	start_offset,
+				end_offset;
+	size_t		end_len;
+
+	/*
+	 * https:// is required for issuer identifiers (RFC 8414, Sec. 2; OIDC
+	 * Discovery 1.0, Sec. 3). This is a case-insensitive comparison at this
+	 * level (but issuer identifier comparison at the level above this is
+	 * case-sensitive, so in practice it's probably moot).
+	 */
+	if (pg_strncasecmp(wkuri, HTTPS_SCHEME, strlen(HTTPS_SCHEME)) == 0)
+		authority_start = wkuri + strlen(HTTPS_SCHEME);
+
+	if (!authority_start
+		&& oauth_unsafe_debugging_enabled()
+		&& pg_strncasecmp(wkuri, HTTP_SCHEME, strlen(HTTP_SCHEME)) == 0)
+	{
+		/* Allow http:// for testing only. */
+		authority_start = wkuri + strlen(HTTP_SCHEME);
+	}
+
+	if (!authority_start)
+	{
+		libpq_append_conn_error(conn,
+								"OAuth discovery URI \"%s\" must use HTTPS",
+								wkuri);
+		return NULL;
+	}
+
+	/*
+	 * Well-known URIs in general may support queries and fragments, but the
+	 * two types we support here do not. (They must be constructed from the
+	 * components of issuer identifiers, which themselves may not contain any
+	 * queries or fragments.)
+	 *
+	 * It's important to check this first, to avoid getting tricked later by a
+	 * prefix buried inside a query or fragment.
+	 */
+	if (strpbrk(authority_start, "?#") != NULL)
+	{
+		libpq_append_conn_error(conn,
+								"OAuth discovery URI \"%s\" must not contain query or fragment components",
+								wkuri);
+		return NULL;
+	}
+
+	/*
+	 * Find the start of the .well-known prefix. IETF rules state this must be
+	 * at the beginning of the path component, but OIDC defined it at the end
+	 * instead, so we have to search for it anywhere.
+	 */
+	wk_start = strstr(authority_start, WK_PREFIX);
+	if (!wk_start)
+	{
+		libpq_append_conn_error(conn,
+								"OAuth discovery URI \"%s\" is not a .well-known URI",
+								wkuri);
+		return NULL;
+	}
+
+	/*
+	 * Now find the suffix type. We only support the two defined in OIDC
+	 * Discovery 1.0 and RFC 8414.
+	 */
+	wk_end = wk_start + strlen(WK_PREFIX);
+
+	if (strncmp(wk_end, OPENID_WK_SUFFIX, strlen(OPENID_WK_SUFFIX)) == 0)
+		wk_end += strlen(OPENID_WK_SUFFIX);
+	else if (strncmp(wk_end, OAUTH_WK_SUFFIX, strlen(OAUTH_WK_SUFFIX)) == 0)
+		wk_end += strlen(OAUTH_WK_SUFFIX);
+	else
+		wk_end = NULL;
+
+	/*
+	 * Even if there's a match, we still need to check to make sure the suffix
+	 * takes up the entire path segment, to weed out constructions like
+	 * "/.well-known/openid-configuration-bad".
+	 */
+	if (!wk_end || (*wk_end != '/' && *wk_end != '\0'))
+	{
+		libpq_append_conn_error(conn,
+								"OAuth discovery URI \"%s\" uses an unsupported .well-known suffix",
+								wkuri);
+		return NULL;
+	}
+
+	/*
+	 * Finally, make sure the .well-known components are provided either as a
+	 * prefix (IETF style) or as a postfix (OIDC style). In other words,
+	 * "https://localhost/a/.well-known/openid-configuration/b" is not allowed
+	 * to claim association with "https://localhost/a/b".
+	 */
+	if (*wk_end != '\0')
+	{
+		/*
+		 * It's not at the end, so it's required to be at the beginning at the
+		 * path. Find the starting slash.
+		 */
+		const char *path_start;
+
+		path_start = strchr(authority_start, '/');
+		Assert(path_start);		/* otherwise we wouldn't have found WK_PREFIX */
+
+		if (wk_start != path_start)
+		{
+			libpq_append_conn_error(conn,
+									"OAuth discovery URI \"%s\" uses an invalid format",
+									wkuri);
+			return NULL;
+		}
+	}
+
+	/* Checks passed! Now build the issuer. */
+	issuer = strdup(wkuri);
+	if (!issuer)
+	{
+		libpq_append_conn_error(conn, "out of memory");
+		return NULL;
+	}
+
+	/*
+	 * The .well-known components are from [wk_start, wk_end). Remove those to
+	 * form the issuer ID, by shifting the path suffix (which may be empty)
+	 * leftwards.
+	 */
+	start_offset = wk_start - wkuri;
+	end_offset = wk_end - wkuri;
+	end_len = strlen(wk_end) + 1;	/* move the NULL terminator too */
+
+	memmove(issuer + start_offset, issuer + end_offset, end_len);
+
+	return issuer;
+}
+
+static bool
+handle_oauth_sasl_error(PGconn *conn, char *msg, int msglen)
+{
+	JsonLexContext lex = {0};
+	JsonSemAction sem = {0};
+	JsonParseErrorType err;
+	struct json_ctx ctx = {0};
+	char	   *errmsg = NULL;
+
+	/* Sanity check. */
+	if (strlen(msg) != msglen)
+	{
+		libpq_append_conn_error(conn,
+								"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)
+		libpq_append_conn_error(conn,
+								"failed to parse server's error response: %s",
+								errmsg);
+
+	/* Don't need the error buffer or the JSON lexer anymore. */
+	termPQExpBuffer(&ctx.errbuf);
+	freeJsonLexContext(&lex);
+
+	if (errmsg)
+		return false;
+
+	/* TODO: what if these override what the user already specified? */
+	/* TODO: what if there's no discovery URI? */
+	if (ctx.discovery_uri)
+	{
+		char	   *discovery_issuer;
+
+		/* The URI must correspond to our existing issuer, to avoid mix-ups. */
+		discovery_issuer = issuer_from_well_known_uri(conn, ctx.discovery_uri);
+		if (!discovery_issuer)
+			return false;		/* error message already set */
+
+		if (strcmp(conn->oauth_issuer_id, discovery_issuer) != 0)
+		{
+			libpq_append_conn_error(conn,
+									"server's discovery document at %s (issuer \"%s\") is incompatible with oauth_issuer (%s)",
+									ctx.discovery_uri, discovery_issuer,
+									conn->oauth_issuer_id);
+
+			free(discovery_issuer);
+			return false;
+		}
+
+		free(discovery_issuer);
+
+		if (conn->oauth_discovery_uri)
+			free(conn->oauth_discovery_uri);
+
+		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)
+	{
+		libpq_append_conn_error(conn,
+								"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 and we haven't
+		 * already retried this connection once.
+		 */
+		if (conn->oauth_discovery_uri && conn->oauth_want_retry == PG_BOOL_UNKNOWN)
+			conn->oauth_want_retry = PG_BOOL_YES;
+	}
+	/* 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.)
+		 */
+		if (!request->token)
+		{
+			libpq_append_conn_error(conn,
+									"user-defined OAuth flow did not provide a token");
+			return PGRES_POLLING_FAILED;
+		}
+
+		state->token = strdup(request->token);
+		if (!state->token)
+		{
+			libpq_append_conn_error(conn, "out of memory");
+			return PGRES_POLLING_FAILED;
+		}
+
+		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.)
+			 */
+			state->token = strdup(request.token);
+			if (!state->token)
+			{
+				libpq_append_conn_error(conn, "out of memory");
+				goto fail;
+			}
+
+			/* short-circuit */
+			if (request.cleanup)
+				request.cleanup(conn, &request);
+			return true;
+		}
+
+		request_copy = malloc(sizeof(*request_copy));
+		if (!request_copy)
+		{
+			libpq_append_conn_error(conn, "out of memory");
+			goto fail;
+		}
+
+		memcpy(request_copy, &request, sizeof(request));
+
+		conn->async_auth = run_user_oauth_flow;
+		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
+	{
+#if USE_BUILTIN_OAUTH
+		/*
+		 * Hand off to our built-in OAuth flow.
+		 *
+		 * Only allow one try per connection, since we're not performing any
+		 * caching at the moment. (Custom flows might be more sophisticated.)
+		 */
+		conn->async_auth = pg_fe_run_oauth_flow;
+		conn->oauth_want_retry = PG_BOOL_NO;
+
+#else
+		libpq_append_conn_error(conn, "no custom OAuth flows are available, and libpq was not built using --with-builtin-oauth");
+		goto fail;
+
+#endif
+	}
+
+	return true;
+
+fail:
+	if (request.cleanup)
+		request.cleanup(conn, &request);
+	return false;
+}
+
+/*
+ * Fill in our issuer identifier and discovery URI, if possible, using the
+ * connection parameters. If conn->oauth_discovery_uri can't be populated in
+ * this function, it will be requested from the server.
+ */
+static bool
+setup_oauth_parameters(PGconn *conn)
+{
+	/*---
+	 * To talk to a server, we require the user to provide issuer and client
+	 * identifiers.
+	 *
+	 * While it's possible for an OAuth client to support multiple issuers, it
+	 * requires additional effort to make sure the flows in use are safe -- to
+	 * quote RFC 9207,
+	 *
+	 *     OAuth clients that interact with only one authorization server are
+	 *     not vulnerable to mix-up attacks. However, when such clients decide
+	 *     to add support for a second authorization server in the future, they
+	 *     become vulnerable and need to apply countermeasures to mix-up
+	 *     attacks.
+	 *
+	 * For now, we allow only one.
+	 */
+	if (!conn->oauth_issuer || !conn->oauth_client_id)
+	{
+		libpq_append_conn_error(conn,
+								"server requires OAuth authentication, but oauth_issuer and oauth_client_id are not both set");
+		return false;
+	}
+
+	/*
+	 * oauth_issuer is interpreted differently if it's a well-known discovery
+	 * URI rather than just an issuer identifier.
+	 */
+	if (strstr(conn->oauth_issuer, WK_PREFIX) != NULL)
+	{
+		/*
+		 * Convert the URI back to an issuer identifier. (This also performs
+		 * validation of the URI format.)
+		 */
+		conn->oauth_issuer_id = issuer_from_well_known_uri(conn,
+														   conn->oauth_issuer);
+		if (!conn->oauth_issuer_id)
+			return false;		/* error message already set */
+
+		conn->oauth_discovery_uri = strdup(conn->oauth_issuer);
+		if (!conn->oauth_discovery_uri)
+		{
+			libpq_append_conn_error(conn, "out of memory");
+			return false;
+		}
+	}
+	else
+	{
+		/*
+		 * Treat oauth_issuer as an issuer identifier. We'll ask the server
+		 * for the discovery URI.
+		 */
+		conn->oauth_issuer_id = strdup(conn->oauth_issuer);
+		if (!conn->oauth_issuer_id)
+		{
+			libpq_append_conn_error(conn, "out of memory");
+			return false;
+		}
+	}
+
+	return true;
+}
+
+static SASLStatus
+oauth_exchange(void *opaq, bool final,
+			   char *input, int inputlen,
+			   char **output, int *outputlen)
+{
+	fe_oauth_state *state = opaq;
+	PGconn	   *conn = state->conn;
+	bool		discover = false;
+
+	*output = NULL;
+	*outputlen = 0;
+
+	switch (state->state)
+	{
+		case FE_OAUTH_INIT:
+			/* We begin in the initial response phase. */
+			Assert(inputlen == -1);
+
+			if (!setup_oauth_parameters(conn))
+				return SASL_FAILED;
+
+			if (conn->oauth_discovery_uri)
+			{
+				/*
+				 * 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. This doesn't
+				 * require any asynchronous work.
+				 */
+				discover = true;
+			}
+
+			/* fall through */
+
+		case FE_OAUTH_REQUESTING_TOKEN:
+			/* We should still be in the initial response phase. */
+			Assert(inputlen == -1);
+
+			*output = client_initial_response(conn, discover, state->token);
+			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.
+			 */
+			libpq_append_conn_error(conn,
+									"server sent additional OAuth data after error");
+			return SASL_FAILED;
+
+		default:
+			libpq_append_conn_error(conn, "invalid OAuth exchange state");
+			break;
+	}
+
+	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);
+}
+
+/*
+ * Returns true if the PGOAUTHDEBUG=UNSAFE flag is set in the environment.
+ */
+bool
+oauth_unsafe_debugging_enabled(void)
+{
+	const char *env = getenv("PGOAUTHDEBUG");
+
+	return (env && strcmp(env, "UNSAFE") == 0);
+}
diff --git a/src/interfaces/libpq/fe-auth-oauth.h b/src/interfaces/libpq/fe-auth-oauth.h
new file mode 100644
index 0000000000..ba4d33c79c
--- /dev/null
+++ b/src/interfaces/libpq/fe-auth-oauth.h
@@ -0,0 +1,43 @@
+/*-------------------------------------------------------------------------
+ *
+ * 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);
+extern bool oauth_unsafe_debugging_enabled(void);
+
+#endif							/* FE_AUTH_OAUTH_H */
diff --git a/src/interfaces/libpq/fe-auth-sasl.h b/src/interfaces/libpq/fe-auth-sasl.h
index 258bfd0564..b47011d077 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 20d3427e94..d260b60c0e 100644
--- a/src/interfaces/libpq/fe-auth.c
+++ b/src/interfaces/libpq/fe-auth.c
@@ -40,6 +40,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"
@@ -430,7 +431,7 @@ pg_SSPI_startup(PGconn *conn, int use_negotiate, int payloadlen)
  * Initialize SASL authentication exchange.
  */
 static int
-pg_SASL_init(PGconn *conn, int payloadlen)
+pg_SASL_init(PGconn *conn, int payloadlen, bool *async)
 {
 	char	   *initialresponse = NULL;
 	int			initialresponselen;
@@ -448,7 +449,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;
@@ -535,6 +536,13 @@ pg_SASL_init(PGconn *conn, int payloadlen)
 			conn->sasl = &pg_scram_mech;
 			conn->password_needed = true;
 		}
+		else if (strcmp(mechanism_buf.data, OAUTHBEARER_NAME) == 0 &&
+				 !selected_mechanism)
+		{
+			selected_mechanism = OAUTHBEARER_NAME;
+			conn->sasl = &pg_oauth_mech;
+			conn->password_needed = false;
+		}
 	}
 
 	if (!selected_mechanism)
@@ -578,26 +586,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.
 	 */
@@ -642,7 +672,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;
@@ -672,11 +702,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)
@@ -984,12 +1024,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;
 
@@ -1147,7 +1193,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;
@@ -1164,7 +1210,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)
@@ -1493,3 +1540,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 aaf87e8e88..259e9bbc5e 100644
--- a/src/interfaces/libpq/fe-connect.c
+++ b/src/interfaces/libpq/fe-connect.c
@@ -365,6 +365,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}
@@ -628,6 +645,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
@@ -2645,6 +2663,7 @@ PQconnectPoll(PGconn *conn)
 		case CONNECTION_NEEDED:
 		case CONNECTION_GSS_STARTUP:
 		case CONNECTION_CHECK_TARGET:
+		case CONNECTION_AUTHENTICATING:
 			break;
 
 		default:
@@ -3680,6 +3699,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
@@ -3835,6 +3855,19 @@ keep_going:						/* We will come back to here until there is
 					/* Check to see if we should mention pgpassfile */
 					pgpassfileWarning(conn);
 
+					/*
+					 * OAuth connections may perform two-step discovery, where
+					 * the first connection is a dummy.
+					 */
+					if (conn->sasl == &pg_oauth_mech
+						&& conn->oauth_want_retry == PG_BOOL_YES)
+					{
+						/* Only allow retry once. */
+						conn->oauth_want_retry = PG_BOOL_NO;
+						need_new_connection = true;
+						goto keep_going;
+					}
+
 					CONNECTION_FAILED();
 				}
 				else if (beresp == PqMsg_NegotiateProtocolVersion)
@@ -3868,7 +3901,17 @@ keep_going:						/* We will come back to here until there is
 				 * Note that conn->pghost must be non-NULL if we are going to
 				 * avoid the Kerberos code doing a hostname look-up.
 				 */
-				res = pg_fe_sendauth(areq, msgLength, conn);
+				res = pg_fe_sendauth(areq, msgLength, conn, &async);
+
+				if (async && (res == STATUS_OK))
+				{
+					/*
+					 * We'll come back later once we're ready to respond.
+					 * Don't consume the request yet.
+					 */
+					conn->status = CONNECTION_AUTHENTICATING;
+					goto keep_going;
+				}
 
 				/*
 				 * OK, we have processed the message; mark data consumed.  We
@@ -3905,6 +3948,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:
 			{
 				/*
@@ -4586,6 +4664,7 @@ pqMakeEmptyPGconn(void)
 	conn->verbosity = PQERRORS_DEFAULT;
 	conn->show_context = PQSHOW_CONTEXT_ERRORS;
 	conn->sock = PGINVALID_SOCKET;
+	conn->altsock = PGINVALID_SOCKET;
 	conn->Pfdebug = NULL;
 
 	/*
@@ -4703,6 +4782,12 @@ freePGconn(PGconn *conn)
 	free(conn->rowBuf);
 	free(conn->target_session_attrs);
 	free(conn->load_balance_hosts);
+	free(conn->oauth_issuer);
+	free(conn->oauth_issuer_id);
+	free(conn->oauth_discovery_uri);
+	free(conn->oauth_client_id);
+	free(conn->oauth_client_secret);
+	free(conn->oauth_scope);
 	termPQExpBuffer(&conn->errorMessage);
 	termPQExpBuffer(&conn->workBuffer);
 
@@ -7227,6 +7312,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 928a47162d..e2ba483ea8 100644
--- a/src/interfaces/libpq/fe-misc.c
+++ b/src/interfaces/libpq/fe-misc.c
@@ -1056,10 +1056,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;
@@ -1076,7 +1079,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 15012c770c..3c6c7fd23b 100644
--- a/src/interfaces/libpq/libpq-fe.h
+++ b/src/interfaces/libpq/libpq-fe.h
@@ -28,6 +28,10 @@ extern "C"
  */
 #include "postgres_ext.h"
 
+#ifdef WIN32
+#include <winsock2.h>			/* for SOCKET */
+#endif
+
 /*
  * These symbols may be used in compile-time #ifdef tests for the availability
  * of v14-and-newer libpq features.
@@ -59,6 +63,8 @@ extern "C"
 /* Features added in PostgreSQL v18: */
 /* Indicates presence of PQfullProtocolVersion */
 #define LIBPQ_HAS_FULL_PROTOCOL_VERSION 1
+/* Indicates presence of the PQAUTHDATA_PROMPT_OAUTH_DEVICE authdata hook */
+#define LIBPQ_HAS_PROMPT_OAUTH_DEVICE 1
 
 /*
  * Option flags for PQcopyResult
@@ -103,6 +109,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
@@ -184,6 +192,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.
  */
@@ -717,10 +732,83 @@ 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;
+
+/* for _PQoauthBearerRequest.async() */
+#ifdef WIN32
+#define SOCKTYPE SOCKET
+#else
+#define SOCKTYPE int
+#endif
+
+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,
+										SOCKTYPE *altsock);
+
+	/*
+	 * Callback to clean up custom allocations. A hook implementation may use
+	 * this to free request->token and any resources in request->user.
+	 *
+	 * This is technically optional, but highly recommended, because there is
+	 * no other indication as to when it is safe to free the token.
+	 */
+	void		(*cleanup) (PGconn *conn, struct _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;
+
+#undef SOCKTYPE
+
 extern char *PQencryptPassword(const char *passwd, const char *user);
 extern char *PQencryptPasswordConn(PGconn *conn, const char *passwd, const char *user, const char *algorithm);
 extern PGresult *PQchangePassword(PGconn *conn, const char *user, const char *passwd);
 
+typedef int (*PQauthDataHook_type) (PGAuthData type, PGconn *conn, void *data);
+extern void PQsetAuthDataHook(PQauthDataHook_type hook);
+extern PQauthDataHook_type PQgetAuthDataHook(void);
+extern int	PQdefaultAuthDataHook(PGAuthData type, PGconn *conn, void *data);
+
 /* === in encnames.c === */
 
 extern int	pg_char_to_encoding(const char *name);
diff --git a/src/interfaces/libpq/libpq-int.h b/src/interfaces/libpq/libpq-int.h
index 08cc391cbd..df2bd1f389 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,16 @@ struct pg_conn
 								 * cancel request, instead of being a normal
 								 * connection that's used for queries */
 
+	/* OAuth v2 */
+	char	   *oauth_issuer;	/* token issuer/URL */
+	char	   *oauth_issuer_id;	/* token issuer identifier */
+	char	   *oauth_discovery_uri;	/* URI of the issuer's discovery
+										 * document */
+	char	   *oauth_client_id;	/* client identifier */
+	char	   *oauth_client_secret;	/* client secret */
+	char	   *oauth_scope;	/* access token scope */
+	PGTernaryBool oauth_want_retry; /* should we retry on failure? */
+
 	/* Optional file to write trace info to */
 	FILE	   *Pfdebug;
 	int			traceFlags;
@@ -506,6 +518,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 ed2a4048d1..e4d92eb402 100644
--- a/src/interfaces/libpq/meson.build
+++ b/src/interfaces/libpq/meson.build
@@ -4,6 +4,7 @@
 # args for executables (which depend on libpq).
 
 libpq_sources = files(
+  'fe-auth-oauth.c',
   'fe-auth-scram.c',
   'fe-auth.c',
   'fe-cancel.c',
@@ -40,6 +41,10 @@ if gssapi.found()
   )
 endif
 
+if oauth_library == 'curl'
+  libpq_sources += files('fe-auth-oauth-curl.c')
+endif
+
 export_file = custom_target('libpq.exports',
   kwargs: gen_export_kwargs,
 )
diff --git a/src/interfaces/libpq/pqexpbuffer.c b/src/interfaces/libpq/pqexpbuffer.c
index 037875c523..9473ed6749 100644
--- a/src/interfaces/libpq/pqexpbuffer.c
+++ b/src/interfaces/libpq/pqexpbuffer.c
@@ -46,7 +46,7 @@ static const char *const oom_buffer_ptr = oom_buffer;
  *
  * Put a PQExpBuffer in "broken" state if it isn't already.
  */
-static void
+void
 markPQExpBufferBroken(PQExpBuffer str)
 {
 	if (str->data != oom_buffer)
diff --git a/src/interfaces/libpq/pqexpbuffer.h b/src/interfaces/libpq/pqexpbuffer.h
index d05010066b..9956829a88 100644
--- a/src/interfaces/libpq/pqexpbuffer.h
+++ b/src/interfaces/libpq/pqexpbuffer.h
@@ -121,6 +121,12 @@ extern void initPQExpBuffer(PQExpBuffer str);
 extern void destroyPQExpBuffer(PQExpBuffer str);
 extern void termPQExpBuffer(PQExpBuffer str);
 
+/*------------------------
+ * markPQExpBufferBroken
+ *		Put a PQExpBuffer in "broken" state if it isn't already.
+ */
+extern void markPQExpBufferBroken(PQExpBuffer str);
+
 /*------------------------
  * resetPQExpBuffer
  *		Reset a PQExpBuffer to empty
diff --git a/src/makefiles/meson.build b/src/makefiles/meson.build
index aba7411a1b..91db55f13e 100644
--- a/src/makefiles/meson.build
+++ b/src/makefiles/meson.build
@@ -66,6 +66,7 @@ pgxs_kv = {
   'SUN_STUDIO_CC': 'no', # not supported so far
 
   # want the chosen option, rather than the library
+  'with_builtin_oauth' : oauth_library,
   'with_ssl' : ssl_library,
   'with_uuid': uuidopt,
 
@@ -229,6 +230,7 @@ pgxs_deps = {
   'gssapi': gssapi,
   'icu': icu,
   'ldap': ldap,
+  'libcurl': libcurl,
   'libxml': libxml,
   'libxslt': libxslt,
   'llvm': llvm,
diff --git a/src/test/modules/Makefile b/src/test/modules/Makefile
index c0d3cf0e14..bdfd5f1f8d 100644
--- a/src/test/modules/Makefile
+++ b/src/test/modules/Makefile
@@ -11,6 +11,7 @@ SUBDIRS = \
 		  dummy_index_am \
 		  dummy_seclabel \
 		  libpq_pipeline \
+		  oauth_validator \
 		  plsample \
 		  spgist_name_ops \
 		  test_bloomfilter \
diff --git a/src/test/modules/meson.build b/src/test/modules/meson.build
index c829b61953..bd13e4afbd 100644
--- a/src/test/modules/meson.build
+++ b/src/test/modules/meson.build
@@ -9,6 +9,7 @@ subdir('gin')
 subdir('injection_points')
 subdir('ldap_password_func')
 subdir('libpq_pipeline')
+subdir('oauth_validator')
 subdir('plsample')
 subdir('spgist_name_ops')
 subdir('ssl_passphrase_callback')
diff --git a/src/test/modules/oauth_validator/.gitignore b/src/test/modules/oauth_validator/.gitignore
new file mode 100644
index 0000000000..5dcb3ff972
--- /dev/null
+++ b/src/test/modules/oauth_validator/.gitignore
@@ -0,0 +1,4 @@
+# Generated subdirectories
+/log/
+/results/
+/tmp_check/
diff --git a/src/test/modules/oauth_validator/Makefile b/src/test/modules/oauth_validator/Makefile
new file mode 100644
index 0000000000..0200a6a63e
--- /dev/null
+++ b/src/test/modules/oauth_validator/Makefile
@@ -0,0 +1,40 @@
+#-------------------------------------------------------------------------
+#
+# Makefile for src/test/modules/oauth_validator
+#
+# Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+# Portions Copyright (c) 1994, Regents of the University of California
+#
+# src/test/modules/oauth_validator/Makefile
+#
+#-------------------------------------------------------------------------
+
+MODULES = validator fail_validator
+PGFILEDESC = "validator - test OAuth validator module"
+
+PROGRAM = oauth_hook_client
+PGAPPICON = win32
+OBJS = $(WIN32RES) oauth_hook_client.o
+
+PG_CPPFLAGS = -I$(libpq_srcdir)
+PG_LIBS_INTERNAL += $(libpq_pgport)
+
+NO_INSTALLCHECK = 1
+
+TAP_TESTS = 1
+
+ifdef USE_PGXS
+PG_CONFIG = pg_config
+PGXS := $(shell $(PG_CONFIG) --pgxs)
+include $(PGXS)
+else
+subdir = src/test/modules/oauth_validator
+top_builddir = ../../../..
+include $(top_builddir)/src/Makefile.global
+include $(top_srcdir)/contrib/contrib-global.mk
+
+export PYTHON
+export with_builtin_oauth
+export with_python
+
+endif
diff --git a/src/test/modules/oauth_validator/fail_validator.c b/src/test/modules/oauth_validator/fail_validator.c
new file mode 100644
index 0000000000..b0fcc07c2a
--- /dev/null
+++ b/src/test/modules/oauth_validator/fail_validator.c
@@ -0,0 +1,44 @@
+/*-------------------------------------------------------------------------
+ *
+ * fail_validator.c
+ *	  Test module for serverside OAuth token validation callbacks, which always
+ *	  fails
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/test/modules/oauth_validator/fail_validator.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "fmgr.h"
+#include "libpq/oauth.h"
+#include "miscadmin.h"
+#include "utils/guc.h"
+#include "utils/memutils.h"
+
+PG_MODULE_MAGIC;
+
+static ValidatorModuleResult *fail_token(ValidatorModuleState *state,
+										 const char *token,
+										 const char *role);
+
+static const OAuthValidatorCallbacks validator_callbacks = {
+	.validate_cb = fail_token,
+};
+
+const OAuthValidatorCallbacks *
+_PG_oauth_validator_module_init(void)
+{
+	return &validator_callbacks;
+}
+
+static ValidatorModuleResult *
+fail_token(ValidatorModuleState *state, const char *token, const char *role)
+{
+	elog(FATAL, "fail_validator: sentinel error");
+	pg_unreachable();
+}
diff --git a/src/test/modules/oauth_validator/meson.build b/src/test/modules/oauth_validator/meson.build
new file mode 100644
index 0000000000..36cdde752c
--- /dev/null
+++ b/src/test/modules/oauth_validator/meson.build
@@ -0,0 +1,69 @@
+# Copyright (c) 2024, PostgreSQL Global Development Group
+
+validator_sources = files(
+  'validator.c',
+)
+
+if host_system == 'windows'
+  validator_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'validator',
+    '--FILEDESC', 'validator - test OAuth validator module',])
+endif
+
+validator = shared_module('validator',
+  validator_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += validator
+
+fail_validator_sources = files(
+  'fail_validator.c',
+)
+
+if host_system == 'windows'
+  fail_validator_sources += rc_lib_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'fail_validator',
+    '--FILEDESC', 'fail_validator - failing OAuth validator module',])
+endif
+
+fail_validator = shared_module('fail_validator',
+  fail_validator_sources,
+  kwargs: pg_test_mod_args,
+)
+test_install_libs += fail_validator
+
+oauth_hook_client_sources = files(
+  'oauth_hook_client.c',
+)
+
+if host_system == 'windows'
+  oauth_hook_client_sources += rc_bin_gen.process(win32ver_rc, extra_args: [
+    '--NAME', 'oauth_hook_client',
+    '--FILEDESC', 'oauth_hook_client - test program for libpq OAuth hooks',])
+endif
+
+oauth_hook_client = executable('oauth_hook_client',
+  oauth_hook_client_sources,
+  dependencies: [frontend_code, libpq],
+  kwargs: default_bin_args + {
+    'install': false,
+  },
+)
+testprep_targets += oauth_hook_client
+
+tests += {
+  'name': 'oauth_validator',
+  'sd': meson.current_source_dir(),
+  'bd': meson.current_build_dir(),
+  'tap': {
+    'tests': [
+      't/001_server.pl',
+      't/002_client.pl',
+    ],
+    'env': {
+      'PYTHON': python.path(),
+      'with_builtin_oauth': oauth_library,
+      'with_python': 'yes',
+    },
+  },
+}
diff --git a/src/test/modules/oauth_validator/oauth_hook_client.c b/src/test/modules/oauth_validator/oauth_hook_client.c
new file mode 100644
index 0000000000..b9278a2930
--- /dev/null
+++ b/src/test/modules/oauth_validator/oauth_hook_client.c
@@ -0,0 +1,157 @@
+/*-------------------------------------------------------------------------
+ *
+ * oauth_hook_client.c
+ *		Verify OAuth hook functionality in libpq
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ *
+ * IDENTIFICATION
+ *		src/test/modules/oauth_validator/oauth_hook_client.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres_fe.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+
+#include "getopt_long.h"
+#include "libpq-fe.h"
+
+static int	handle_auth_data(PGAuthData type, PGconn *conn, void *data);
+
+static void
+usage(char *argv[])
+{
+	fprintf(stderr, "usage: %s [flags] CONNINFO\n\n", argv[0]);
+
+	fprintf(stderr, "recognized flags:\n");
+	fprintf(stderr, " -h, --help				show this message\n");
+	fprintf(stderr, " --expected-scope SCOPE	fail if received scopes do not match SCOPE\n");
+	fprintf(stderr, " --expected-uri URI		fail if received configuration link does not match URI\n");
+	fprintf(stderr, " --no-hook					don't install OAuth hooks (connection will fail)\n");
+	fprintf(stderr, " --token TOKEN				use the provided TOKEN value\n");
+}
+
+static bool no_hook = false;
+static const char *expected_uri = NULL;
+static const char *expected_scope = NULL;
+static char *token = NULL;
+
+int
+main(int argc, char *argv[])
+{
+	static const struct option long_options[] = {
+		{"help", no_argument, NULL, 'h'},
+
+		{"expected-scope", required_argument, NULL, 1000},
+		{"expected-uri", required_argument, NULL, 1001},
+		{"no-hook", no_argument, NULL, 1002},
+		{"token", required_argument, NULL, 1003},
+		{0}
+	};
+
+	const char *conninfo;
+	PGconn	   *conn;
+	int			c;
+
+	while ((c = getopt_long(argc, argv, "h", long_options, NULL)) != -1)
+	{
+		switch (c)
+		{
+			case 'h':
+				usage(argv);
+				return 0;
+
+			case 1000:			/* --expected-scope */
+				expected_scope = optarg;
+				break;
+
+			case 1001:			/* --expected-uri */
+				expected_uri = optarg;
+				break;
+
+			case 1002:			/* --no-hook */
+				no_hook = true;
+				break;
+
+			case 1003:			/* --token */
+				token = optarg;
+				break;
+
+			default:
+				usage(argv);
+				return 1;
+		}
+	}
+
+	if (argc != optind + 1)
+	{
+		usage(argv);
+		return 1;
+	}
+
+	conninfo = argv[optind];
+
+	/* Set up our OAuth hooks. */
+	PQsetAuthDataHook(handle_auth_data);
+
+	/* Connect. (All the actual work is in the hook.) */
+	conn = PQconnectdb(conninfo);
+	if (PQstatus(conn) != CONNECTION_OK)
+	{
+		fprintf(stderr, "Connection to database failed: %s\n",
+				PQerrorMessage(conn));
+		PQfinish(conn);
+		return 1;
+	}
+
+	printf("connection succeeded\n");
+	PQfinish(conn);
+	return 0;
+}
+
+static int
+handle_auth_data(PGAuthData type, PGconn *conn, void *data)
+{
+	PQoauthBearerRequest *req = data;
+
+	if (no_hook || (type != PQAUTHDATA_OAUTH_BEARER_TOKEN))
+		return 0;
+
+	if (expected_uri)
+	{
+		if (!req->openid_configuration)
+		{
+			fprintf(stderr, "expected URI \"%s\", got NULL\n", expected_uri);
+			return -1;
+		}
+
+		if (strcmp(expected_uri, req->openid_configuration) != 0)
+		{
+			fprintf(stderr, "expected URI \"%s\", got \"%s\"\n", expected_uri, req->openid_configuration);
+			return -1;
+		}
+	}
+
+	if (expected_scope)
+	{
+		if (!req->scope)
+		{
+			fprintf(stderr, "expected scope \"%s\", got NULL\n", expected_scope);
+			return -1;
+		}
+
+		if (strcmp(expected_scope, req->scope) != 0)
+		{
+			fprintf(stderr, "expected scope \"%s\", got \"%s\"\n", expected_scope, req->scope);
+			return -1;
+		}
+	}
+
+	req->token = token;
+	return 1;
+}
diff --git a/src/test/modules/oauth_validator/t/001_server.pl b/src/test/modules/oauth_validator/t/001_server.pl
new file mode 100644
index 0000000000..1b0a04cf1f
--- /dev/null
+++ b/src/test/modules/oauth_validator/t/001_server.pl
@@ -0,0 +1,428 @@
+
+# Copyright (c) 2021-2024, PostgreSQL Global Development Group
+
+use strict;
+use warnings FATAL => 'all';
+
+use JSON::PP     qw(encode_json);
+use MIME::Base64 qw(encode_base64);
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use PostgreSQL::Test::OAuthServer;
+use Test::More;
+
+if (!$ENV{PG_TEST_EXTRA} || $ENV{PG_TEST_EXTRA} !~ /\boauth\b/)
+{
+	plan skip_all =>
+	  'Potentially unsafe test oauth not enabled in PG_TEST_EXTRA';
+}
+
+if ($ENV{with_builtin_oauth} ne 'curl')
+{
+	plan skip_all => 'client-side OAuth not supported by this build';
+}
+
+if ($ENV{with_python} ne 'yes')
+{
+	plan skip_all => 'OAuth tests require --with-python to run';
+}
+
+my $node = PostgreSQL::Test::Cluster->new('primary');
+$node->init;
+$node->append_conf('postgresql.conf', "log_connections = on\n");
+$node->append_conf('postgresql.conf',
+	"oauth_validator_libraries = 'validator'\n");
+$node->start;
+
+$node->safe_psql('postgres', 'CREATE USER test;');
+$node->safe_psql('postgres', 'CREATE USER testalt;');
+$node->safe_psql('postgres', 'CREATE USER testparam;');
+
+# Save a background connection for later configuration changes.
+my $bgconn = $node->background_psql('postgres');
+
+my $webserver = PostgreSQL::Test::OAuthServer->new();
+$webserver->run();
+
+END
+{
+	my $exit_code = $?;
+
+	$webserver->stop() if defined $webserver;    # might have been SKIP'd
+
+	$? = $exit_code;
+}
+
+my $port = $webserver->port();
+my $issuer = "http://localhost:$port";
+
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test      oauth issuer="$issuer"       scope="openid postgres"
+local all testalt   oauth issuer="$issuer/.well-known/oauth-authorization-server/alternate" scope="openid postgres alt"
+local all testparam oauth issuer="$issuer/param" scope="openid postgres"
+});
+$node->reload;
+
+my ($log_start, $log_end);
+$log_start = $node->wait_for_log(qr/reloading configuration files/);
+
+
+# To test against HTTP rather than HTTPS, we need to enable PGOAUTHDEBUG. But
+# first, check to make sure the client refuses such connections by default.
+$node->connect_fails(
+	"user=test dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635",
+	"HTTPS is required without debug mode",
+	expected_stderr =>
+	  qr@OAuth discovery URI "\Q$issuer\E/.well-known/openid-configuration" must use HTTPS@
+);
+
+$ENV{PGOAUTHDEBUG} = "UNSAFE";
+
+my $user = "test";
+if ($node->connect_ok(
+		"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635",
+		"connect",
+		expected_stderr =>
+		  qr@Visit https://example\.com/ and enter the code: postgresuser@))
+{
+	$log_end = $node->wait_for_log(qr/connection authorized/, $log_start);
+	$node->log_check(
+		"user $user: validator receives correct parameters",
+		$log_start,
+		log_like => [
+			qr/oauth_validator: token="9243959234", role="$user"/,
+			qr/oauth_validator: issuer="\Q$issuer\E", scope="openid postgres"/,
+		]);
+	$node->log_check(
+		"user $user: validator sets authenticated identity",
+		$log_start,
+		log_like =>
+		  [ qr/connection authenticated: identity="test" method=oauth/, ]);
+	$log_start = $log_end;
+}
+
+# The /alternate issuer uses slightly different parameters, along with an
+# OAuth-style discovery document.
+$user = "testalt";
+if ($node->connect_ok(
+		"user=$user dbname=postgres oauth_issuer=$issuer/alternate oauth_client_id=f02c6361-0636",
+		"connect",
+		expected_stderr =>
+		  qr@Visit https://example\.org/ and enter the code: postgresuser@))
+{
+	$log_end = $node->wait_for_log(qr/connection authorized/, $log_start);
+	$node->log_check(
+		"user $user: validator receives correct parameters",
+		$log_start,
+		log_like => [
+			qr/oauth_validator: token="9243959234-alt", role="$user"/,
+			qr|oauth_validator: issuer="\Q$issuer/alternate\E", scope="openid postgres alt"|,
+		]);
+	$node->log_check(
+		"user $user: validator sets authenticated identity",
+		$log_start,
+		log_like =>
+		  [ qr/connection authenticated: identity="testalt" method=oauth/, ]);
+	$log_start = $log_end;
+}
+
+# The issuer linked by the server must match the client's oauth_issuer setting.
+$node->connect_fails(
+	"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0636",
+	"oauth_issuer must match discovery",
+	expected_stderr =>
+	  qr@server's discovery document at \Q$issuer/.well-known/oauth-authorization-server/alternate\E \(issuer "\Q$issuer/alternate\E"\) is incompatible with oauth_issuer \(\Q$issuer\E\)@
+);
+
+# Make sure the client_id and secret are correctly encoded. $vschars contains
+# every allowed character for a client_id/_secret (the "VSCHAR" class).
+# $vschars_esc is additionally backslash-escaped for inclusion in a
+# single-quoted connection string.
+my $vschars =
+  " !\"#\$%&'()*+,-./0123456789:;<=>?\@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~";
+my $vschars_esc =
+  " !\"#\$%&\\'()*+,-./0123456789:;<=>?\@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\\\]^_`abcdefghijklmnopqrstuvwxyz{|}~";
+
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer/alternate oauth_client_id='$vschars_esc'",
+	"escapable characters: client_id",
+	expected_stderr =>
+	  qr@Visit https://example\.org/ and enter the code: postgresuser@);
+$node->connect_ok(
+	"user=$user dbname=postgres oauth_issuer=$issuer/alternate oauth_client_id='$vschars_esc' oauth_client_secret='$vschars_esc'",
+	"escapable characters: client_id and secret",
+	expected_stderr =>
+	  qr@Visit https://example\.org/ and enter the code: postgresuser@);
+
+#
+# Further tests rely on support for specific behaviors in oauth_server.py. To
+# trigger these behaviors, we ask for the special issuer .../param (which is set
+# up in HBA for the testparam user) and encode magic instructions into the
+# oauth_client_id.
+#
+
+my $common_connstr =
+  "user=testparam dbname=postgres oauth_issuer=$issuer/param ";
+my $base_connstr = $common_connstr;
+
+sub connstr
+{
+	my (%params) = @_;
+
+	my $json = encode_json(\%params);
+	my $encoded = encode_base64($json, "");
+
+	return "$base_connstr oauth_client_id=$encoded";
+}
+
+# Make sure the param system works end-to-end first.
+$node->connect_ok(
+	connstr(),
+	"connect to /param",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$node->connect_ok(
+	connstr(stage => 'token', retries => 1),
+	"token retry",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(stage => 'token', retries => 2),
+	"token retry (twice)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(stage => 'all', retries => 1, interval => 2),
+	"token retry (two second interval)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(stage => 'all', retries => 1, interval => JSON::PP::null),
+	"token retry (default interval)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$node->connect_ok(
+	connstr(stage => 'all', content_type => 'application/json;charset=utf-8'),
+	"content type with charset",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(
+		stage => 'all',
+		content_type => "application/json \t;\t charset=utf-8"),
+	"content type with charset (whitespace)",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+$node->connect_ok(
+	connstr(stage => 'device', uri_spelling => "verification_url"),
+	"alternative spelling of verification_uri",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$node->connect_fails(
+	connstr(stage => 'device', huge_response => JSON::PP::true),
+	"bad device authz response: overlarge JSON",
+	expected_stderr =>
+	  qr/failed to obtain device authorization: response is too large/);
+$node->connect_fails(
+	connstr(stage => 'token', huge_response => JSON::PP::true),
+	"bad token response: overlarge JSON",
+	expected_stderr =>
+	  qr/failed to obtain access token: response is too large/);
+
+$node->connect_fails(
+	connstr(stage => 'device', content_type => 'text/plain'),
+	"bad device authz response: wrong content type",
+	expected_stderr =>
+	  qr/failed to parse device authorization: unexpected content type/);
+$node->connect_fails(
+	connstr(stage => 'token', content_type => 'text/plain'),
+	"bad token response: wrong content type",
+	expected_stderr =>
+	  qr/failed to parse access token response: unexpected content type/);
+$node->connect_fails(
+	connstr(stage => 'token', content_type => 'application/jsonx'),
+	"bad token response: wrong content type (correct prefix)",
+	expected_stderr =>
+	  qr/failed to parse access token response: unexpected content type/);
+
+$node->connect_fails(
+	connstr(
+		stage => 'all',
+		interval => ~0,
+		retries => 1,
+		retry_code => "slow_down"),
+	"bad token response: server overflows the device authz interval",
+	expected_stderr =>
+	  qr/failed to obtain access token: slow_down interval overflow/);
+
+$node->connect_fails(
+	connstr(stage => 'token', error_code => "invalid_grant"),
+	"bad token response: invalid_grant, no description",
+	expected_stderr => qr/failed to obtain access token: \(invalid_grant\)/);
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_grant",
+		error_desc => "grant expired"),
+	"bad token response: expired grant",
+	expected_stderr =>
+	  qr/failed to obtain access token: grant expired \(invalid_grant\)/);
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_client",
+		error_status => 401),
+	"bad token response: client authentication failure, default description",
+	expected_stderr =>
+	  qr/failed to obtain access token: provider requires client authentication, and no oauth_client_secret is set \(invalid_client\)/
+);
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_client",
+		error_status => 401,
+		error_desc => "authn failure"),
+	"bad token response: client authentication failure, provided description",
+	expected_stderr =>
+	  qr/failed to obtain access token: authn failure \(invalid_client\)/);
+
+$node->connect_fails(
+	connstr(stage => 'token', token => ""),
+	"server rejects access: empty token",
+	expected_stderr => qr/bearer authentication failed/);
+$node->connect_fails(
+	connstr(stage => 'token', token => "****"),
+	"server rejects access: invalid token contents",
+	expected_stderr => qr/bearer authentication failed/);
+
+# Test behavior of the oauth_client_secret.
+$base_connstr = "$common_connstr oauth_client_secret=''";
+
+$node->connect_ok(
+	connstr(stage => 'all', expected_secret => ''),
+	"empty oauth_client_secret",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$base_connstr = "$common_connstr oauth_client_secret='$vschars_esc'";
+
+$node->connect_ok(
+	connstr(stage => 'all', expected_secret => $vschars),
+	"nonempty oauth_client_secret",
+	expected_stderr =>
+	  qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_client",
+		error_status => 401),
+	"bad token response: client authentication failure, default description with oauth_client_secret",
+	expected_stderr =>
+	  qr/failed to obtain access token: provider rejected the oauth_client_secret \(invalid_client\)/
+);
+$node->connect_fails(
+	connstr(
+		stage => 'token',
+		error_code => "invalid_client",
+		error_status => 401,
+		error_desc => "mutual TLS required for client"),
+	"bad token response: client authentication failure, provided description with oauth_client_secret",
+	expected_stderr =>
+	  qr/failed to obtain access token: mutual TLS required for client \(invalid_client\)/
+);
+
+#
+# This section of tests reconfigures the validator module itself, rather than
+# the OAuth server.
+#
+
+# Searching the logs is easier if OAuth parameter discovery isn't cluttering
+# things up; hardcode the discovery URI. (Scope is hardcoded to empty to cover
+# that case as well.)
+$common_connstr =
+  "user=test dbname=postgres oauth_issuer=$issuer/.well-known/openid-configuration oauth_scope=''";
+
+$bgconn->query_safe("ALTER SYSTEM SET oauth_validator.authn_id TO ''");
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+if ($node->connect_fails(
+		"$common_connstr oauth_client_id=f02c6361-0635",
+		"validator must set authn_id",
+		expected_stderr => qr/OAuth bearer authentication failed/))
+{
+	$log_end =
+	  $node->wait_for_log(qr/FATAL:\s+OAuth bearer authentication failed/,
+		$log_start);
+
+	$node->log_check(
+		"validator must set authn_id: breadcrumbs are logged",
+		$log_start,
+		log_like => [
+			qr/connection authenticated: identity=""/,
+			qr/DETAIL:\s+Validator provided no identity/,
+			qr/FATAL:\s+OAuth bearer authentication failed/,
+		]);
+
+	$log_start = $log_end;
+}
+
+$bgconn->query_safe("ALTER SYSTEM RESET oauth_validator.authn_id");
+$node->reload;
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+#
+# Test multiple validators.
+#
+
+$node->append_conf('postgresql.conf',
+	"oauth_validator_libraries = 'validator, fail_validator'\n");
+
+# With multiple validators, every HBA line must explicitly declare one.
+my $result = $node->restart(fail_ok => 1);
+is($result, 0,
+	'restart fails without explicit validators in oauth HBA entries');
+
+$log_start = $node->wait_for_log(
+	qr/authentication method "oauth" requires argument "validator" to be set/,
+	$log_start);
+
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test    oauth validator=validator      issuer="$issuer"           scope="openid postgres"
+local all testalt oauth validator=fail_validator issuer="$issuer/alternate" scope="openid postgres alt"
+});
+$node->restart;
+
+$log_start = $node->wait_for_log(qr/ready to accept connections/, $log_start);
+
+# The test user should work as before.
+$user = "test";
+if ($node->connect_ok(
+		"user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635",
+		"validator is used for $user",
+		expected_stderr =>
+		  qr@Visit https://example\.com/ and enter the code: postgresuser@))
+{
+	$log_start = $node->wait_for_log(qr/connection authorized/, $log_start);
+}
+
+# testalt should be routed through the fail_validator.
+$user = "testalt";
+$node->connect_fails(
+	"user=$user dbname=postgres oauth_issuer=$issuer/.well-known/oauth-authorization-server/alternate oauth_client_id=f02c6361-0636",
+	"fail_validator is used for $user",
+	expected_stderr => qr/FATAL:\s+fail_validator: sentinel error/);
+
+$node->stop;
+
+done_testing();
diff --git a/src/test/modules/oauth_validator/t/002_client.pl b/src/test/modules/oauth_validator/t/002_client.pl
new file mode 100644
index 0000000000..5b56292c00
--- /dev/null
+++ b/src/test/modules/oauth_validator/t/002_client.pl
@@ -0,0 +1,114 @@
+
+# Copyright (c) 2021-2024, PostgreSQL Global Development Group
+
+use strict;
+use warnings FATAL => 'all';
+
+use JSON::PP     qw(encode_json);
+use MIME::Base64 qw(encode_base64);
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Utils;
+use Test::More;
+
+if (!$ENV{PG_TEST_EXTRA} || $ENV{PG_TEST_EXTRA} !~ /\boauth\b/)
+{
+	plan skip_all =>
+	  'Potentially unsafe test oauth not enabled in PG_TEST_EXTRA';
+}
+
+#
+# Cluster Setup
+#
+
+my $node = PostgreSQL::Test::Cluster->new('primary');
+$node->init;
+$node->append_conf('postgresql.conf', "log_connections = on\n");
+$node->append_conf('postgresql.conf',
+	"oauth_validator_libraries = 'validator'\n");
+$node->start;
+
+$node->safe_psql('postgres', 'CREATE USER test;');
+
+# These tests don't use the builtin flow, and we don't have an authorization
+# server running, so the address used here shouldn't matter. Use an invalid IP
+# address, so if there's some cascade of errors that causes the client to
+# attempt a connection, we'll fail noisily.
+my $issuer = "https://256.256.256.256";
+my $scope = "openid postgres";
+
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf', qq{
+local all test oauth issuer="$issuer" scope="$scope"
+});
+$node->reload;
+
+my ($log_start, $log_end);
+$log_start = $node->wait_for_log(qr/reloading configuration files/);
+
+$ENV{PGOAUTHDEBUG} = "UNSAFE";
+
+#
+# Tests
+#
+
+my $user = "test";
+my $base_connstr = $node->connstr() . " user=$user";
+my $common_connstr =
+  "$base_connstr oauth_issuer=$issuer oauth_client_id=myID";
+
+sub test
+{
+	my ($test_name, %params) = @_;
+
+	my $flags = [];
+	if (defined($params{flags}))
+	{
+		$flags = $params{flags};
+	}
+
+	my @cmd = ("oauth_hook_client", @{$flags}, $common_connstr);
+	diag "running '" . join("' '", @cmd) . "'";
+
+	my ($stdout, $stderr) = run_command(\@cmd);
+
+	if (defined($params{expected_stdout}))
+	{
+		like($stdout, $params{expected_stdout}, "$test_name: stdout matches");
+	}
+
+	if (defined($params{expected_stderr}))
+	{
+		like($stderr, $params{expected_stderr}, "$test_name: stderr matches");
+	}
+	else
+	{
+		is($stderr, "", "$test_name: no stderr");
+	}
+}
+
+test(
+	"basic synchronous hook can provide a token",
+	flags => [
+		"--token", "my-token",
+		"--expected-uri", "$issuer/.well-known/openid-configuration",
+		"--expected-scope", $scope,
+	],
+	expected_stdout => qr/connection succeeded/);
+
+$node->log_check("validator receives correct token",
+	$log_start,
+	log_like => [ qr/oauth_validator: token="my-token", role="$user"/, ]);
+
+if ($ENV{with_builtin_oauth} ne 'curl')
+{
+	# libpq should help users out if no OAuth support is built in.
+	test(
+		"fails without custom hook installed",
+		flags => ["--no-hook"],
+		expected_stderr =>
+		  qr/no custom OAuth flows are available, and libpq was not built using --with-builtin-oauth/
+	);
+}
+
+done_testing();
diff --git a/src/test/modules/oauth_validator/t/oauth_server.py b/src/test/modules/oauth_validator/t/oauth_server.py
new file mode 100755
index 0000000000..ae7ea7af6d
--- /dev/null
+++ b/src/test/modules/oauth_validator/t/oauth_server.py
@@ -0,0 +1,370 @@
+#! /usr/bin/env python3
+
+import base64
+import http.server
+import json
+import os
+import sys
+import time
+import urllib.parse
+from collections import defaultdict
+
+
+class OAuthHandler(http.server.BaseHTTPRequestHandler):
+    JsonObject = dict[str, object]  # TypeAlias is not available until 3.10
+
+    def _check_issuer(self):
+        """
+        Switches the behavior of the provider depending on the issuer URI.
+        """
+        self._alt_issuer = (
+            self.path.startswith("/alternate/")
+            or self.path == "/.well-known/oauth-authorization-server/alternate"
+        )
+        self._parameterized = self.path.startswith("/param/")
+
+        if self._alt_issuer:
+            # The /alternate issuer uses IETF-style .well-known URIs.
+            if self.path.startswith("/.well-known/"):
+                self.path = self.path.removesuffix("/alternate")
+            else:
+                self.path = self.path.removeprefix("/alternate")
+        elif self._parameterized:
+            self.path = self.path.removeprefix("/param")
+
+    def _check_authn(self):
+        """
+        Checks the expected value of the Authorization header, if any.
+        """
+        secret = self._get_param("expected_secret", None)
+        if secret is None:
+            return
+
+        assert "Authorization" in self.headers
+        method, creds = self.headers["Authorization"].split()
+
+        if method != "Basic":
+            raise RuntimeError(f"client used {method} auth; expected Basic")
+
+        username = urllib.parse.quote_plus(self.client_id)
+        password = urllib.parse.quote_plus(secret)
+        expected_creds = f"{username}:{password}"
+
+        if creds.encode() != base64.b64encode(expected_creds.encode()):
+            raise RuntimeError(
+                f"client sent '{creds}'; expected b64encode('{expected_creds}')"
+            )
+
+    def do_GET(self):
+        self._response_code = 200
+        self._check_issuer()
+
+        config_path = "/.well-known/openid-configuration"
+        if self._alt_issuer:
+            config_path = "/.well-known/oauth-authorization-server"
+
+        if self.path == config_path:
+            resp = self.config()
+        else:
+            self.send_error(404, "Not Found")
+            return
+
+        self._send_json(resp)
+
+    def _parse_params(self) -> dict[str, str]:
+        """
+        Parses apart the form-urlencoded request body and returns the resulting
+        dict. For use by do_POST().
+        """
+        size = int(self.headers["Content-Length"])
+        form = self.rfile.read(size)
+
+        assert self.headers["Content-Type"] == "application/x-www-form-urlencoded"
+        return urllib.parse.parse_qs(
+            form.decode("utf-8"),
+            strict_parsing=True,
+            keep_blank_values=True,
+            encoding="utf-8",
+            errors="strict",
+        )
+
+    @property
+    def client_id(self) -> str:
+        """
+        Returns the client_id sent in the POST body or the Authorization header.
+        self._parse_params() must have been called first.
+        """
+        if "client_id" in self._params:
+            return self._params["client_id"][0]
+
+        if "Authorization" not in self.headers:
+            raise RuntimeError("client did not send any client_id")
+
+        _, creds = self.headers["Authorization"].split()
+
+        decoded = base64.b64decode(creds).decode("utf-8")
+        username, _ = decoded.split(":", 1)
+
+        return urllib.parse.unquote_plus(username)
+
+    def do_POST(self):
+        self._response_code = 200
+        self._check_issuer()
+
+        self._params = self._parse_params()
+        if self._parameterized:
+            # Pull encoded test parameters out of the peer's client_id field.
+            # This is expected to be Base64-encoded JSON.
+            js = base64.b64decode(self.client_id)
+            self._test_params = json.loads(js)
+
+        self._check_authn()
+
+        if self.path == "/authorize":
+            resp = self.authorization()
+        elif self.path == "/token":
+            resp = self.token()
+        else:
+            self.send_error(404)
+            return
+
+        self._send_json(resp)
+
+    def _should_modify(self) -> bool:
+        """
+        Returns True if the client has requested a modification to this stage of
+        the exchange.
+        """
+        if not hasattr(self, "_test_params"):
+            return False
+
+        stage = self._test_params.get("stage")
+
+        return (
+            stage == "all"
+            or (
+                stage == "discovery"
+                and self.path == "/.well-known/openid-configuration"
+            )
+            or (stage == "device" and self.path == "/authorize")
+            or (stage == "token" and self.path == "/token")
+        )
+
+    def _get_param(self, name, default):
+        """
+        If the client has requested a modification to this stage (see
+        _should_modify()), this method searches the provided test parameters for
+        a key of the given name, and returns it if found. Otherwise the provided
+        default is returned.
+        """
+        if self._should_modify() and name in self._test_params:
+            return self._test_params[name]
+
+        return default
+
+    @property
+    def _content_type(self) -> str:
+        """
+        Returns "application/json" unless the test has requested something
+        different.
+        """
+        return self._get_param("content_type", "application/json")
+
+    @property
+    def _interval(self) -> int:
+        """
+        Returns 0 unless the test has requested something different.
+        """
+        return self._get_param("interval", 0)
+
+    @property
+    def _retry_code(self) -> str:
+        """
+        Returns "authorization_pending" unless the test has requested something
+        different.
+        """
+        return self._get_param("retry_code", "authorization_pending")
+
+    @property
+    def _uri_spelling(self) -> str:
+        """
+        Returns "verification_uri" unless the test has requested something
+        different.
+        """
+        return self._get_param("uri_spelling", "verification_uri")
+
+    @property
+    def _response_padding(self):
+        """
+        If the huge_response test parameter is set to True, returns a dict
+        containing a gigantic string value, which can then be folded into a JSON
+        response.
+        """
+        if not self._get_param("huge_response", False):
+            return dict()
+
+        return {"_pad_": "x" * 1024 * 1024}
+
+    @property
+    def _access_token(self):
+        """
+        The actual Bearer token sent back to the client on success. Tests may
+        override this with the "token" test parameter.
+        """
+        token = self._get_param("token", None)
+        if token is not None:
+            return token
+
+        token = "9243959234"
+        if self._alt_issuer:
+            token += "-alt"
+
+        return token
+
+    def _send_json(self, js: JsonObject) -> None:
+        """
+        Sends the provided JSON dict as an application/json response.
+        self._response_code can be modified to send JSON error responses.
+        """
+        resp = json.dumps(js).encode("ascii")
+        self.log_message("sending JSON response: %s", resp)
+
+        self.send_response(self._response_code)
+        self.send_header("Content-Type", self._content_type)
+        self.send_header("Content-Length", str(len(resp)))
+        self.end_headers()
+
+        self.wfile.write(resp)
+
+    def config(self) -> JsonObject:
+        port = self.server.socket.getsockname()[1]
+
+        issuer = f"http://localhost:{port}"
+        if self._alt_issuer:
+            issuer += "/alternate"
+        elif self._parameterized:
+            issuer += "/param"
+
+        return {
+            "issuer": issuer,
+            "token_endpoint": issuer + "/token",
+            "device_authorization_endpoint": issuer + "/authorize",
+            "response_types_supported": ["token"],
+            "subject_types_supported": ["public"],
+            "id_token_signing_alg_values_supported": ["RS256"],
+            "grant_types_supported": ["urn:ietf:params:oauth:grant-type:device_code"],
+        }
+
+    @property
+    def _token_state(self):
+        """
+        A cached _TokenState object for the connected client (as determined by
+        the request's client_id), or a new one if it doesn't already exist.
+
+        This relies on the existence of a defaultdict attached to the server;
+        see main() below.
+        """
+        return self.server.token_state[self.client_id]
+
+    def _remove_token_state(self):
+        """
+        Removes any cached _TokenState for the current client_id. Call this
+        after the token exchange ends to get rid of unnecessary state.
+        """
+        if self.client_id in self.server.token_state:
+            del self.server.token_state[self.client_id]
+
+    def authorization(self) -> JsonObject:
+        uri = "https://example.com/"
+        if self._alt_issuer:
+            uri = "https://example.org/"
+
+        resp = {
+            "device_code": "postgres",
+            "user_code": "postgresuser",
+            self._uri_spelling: uri,
+            "expires-in": 5,
+            **self._response_padding,
+        }
+
+        interval = self._interval
+        if interval is not None:
+            resp["interval"] = interval
+            self._token_state.min_delay = interval
+        else:
+            self._token_state.min_delay = 5  # default
+
+        # Check the scope.
+        if "scope" in self._params:
+            assert self._params["scope"][0], "empty scopes should be omitted"
+
+        return resp
+
+    def token(self) -> JsonObject:
+        if err := self._get_param("error_code", None):
+            self._response_code = self._get_param("error_status", 400)
+
+            resp = {"error": err}
+            if desc := self._get_param("error_desc", ""):
+                resp["error_description"] = desc
+
+            return resp
+
+        if self._should_modify() and "retries" in self._test_params:
+            retries = self._test_params["retries"]
+
+            # Check to make sure the token interval is being respected.
+            now = time.monotonic()
+            if self._token_state.last_try is not None:
+                delay = now - self._token_state.last_try
+                assert (
+                    delay > self._token_state.min_delay
+                ), f"client waited only {delay} seconds between token requests (expected {self._token_state.min_delay})"
+
+            self._token_state.last_try = now
+
+            # If we haven't reached the required number of retries yet, return a
+            # "pending" response.
+            if self._token_state.retries < retries:
+                self._token_state.retries += 1
+
+                self._response_code = 400
+                return {"error": self._retry_code}
+
+        # Clean up any retry tracking state now that the exchange is ending.
+        self._remove_token_state()
+
+        return {
+            "access_token": self._access_token,
+            "token_type": "bearer",
+            **self._response_padding,
+        }
+
+
+def main():
+    s = http.server.HTTPServer(("127.0.0.1", 0), OAuthHandler)
+
+    # Attach a "cache" dictionary to the server to allow the OAuthHandlers to
+    # track state across token requests. The use of defaultdict ensures that new
+    # entries will be created automatically.
+    class _TokenState:
+        retries = 0
+        min_delay = None
+        last_try = None
+
+    s.token_state = defaultdict(_TokenState)
+
+    # Give the parent the port number to contact (this is also the signal that
+    # we're ready to receive requests).
+    port = s.socket.getsockname()[1]
+    print(port)
+
+    stdout = sys.stdout.fileno()
+    sys.stdout.close()
+    os.close(stdout)
+
+    s.serve_forever()  # we expect our parent to send a termination signal
+
+
+if __name__ == "__main__":
+    main()
diff --git a/src/test/modules/oauth_validator/validator.c b/src/test/modules/oauth_validator/validator.c
new file mode 100644
index 0000000000..dbba326bc4
--- /dev/null
+++ b/src/test/modules/oauth_validator/validator.c
@@ -0,0 +1,100 @@
+/*-------------------------------------------------------------------------
+ *
+ * validator.c
+ *	  Test module for serverside OAuth token validation callbacks
+ *
+ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group
+ * Portions Copyright (c) 1994, Regents of the University of California
+ *
+ * src/test/modules/oauth_validator/validator.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres.h"
+
+#include "fmgr.h"
+#include "libpq/oauth.h"
+#include "miscadmin.h"
+#include "utils/guc.h"
+#include "utils/memutils.h"
+
+PG_MODULE_MAGIC;
+
+static void validator_startup(ValidatorModuleState *state);
+static void validator_shutdown(ValidatorModuleState *state);
+static ValidatorModuleResult *validate_token(ValidatorModuleState *state,
+											 const char *token,
+											 const char *role);
+
+static const OAuthValidatorCallbacks validator_callbacks = {
+	.startup_cb = validator_startup,
+	.shutdown_cb = validator_shutdown,
+	.validate_cb = validate_token
+};
+
+static char *authn_id = NULL;
+
+void
+_PG_init(void)
+{
+	DefineCustomStringVariable("oauth_validator.authn_id",
+							   "Authenticated identity to use for future connections",
+							   NULL,
+							   &authn_id,
+							   NULL,
+							   PGC_SIGHUP,
+							   0,
+							   NULL, NULL, NULL);
+
+	MarkGUCPrefixReserved("oauth_validator");
+}
+
+const OAuthValidatorCallbacks *
+_PG_oauth_validator_module_init(void)
+{
+	return &validator_callbacks;
+}
+
+#define PRIVATE_COOKIE ((void *) 13579)
+
+static void
+validator_startup(ValidatorModuleState *state)
+{
+	state->private_data = PRIVATE_COOKIE;
+}
+
+static void
+validator_shutdown(ValidatorModuleState *state)
+{
+	/* Check to make sure our private state still exists. */
+	if (state->private_data != PRIVATE_COOKIE)
+		elog(ERROR, "oauth_validator: private state cookie changed to %p in shutdown",
+			 state->private_data);
+}
+
+static ValidatorModuleResult *
+validate_token(ValidatorModuleState *state, const char *token, const char *role)
+{
+	ValidatorModuleResult *res;
+
+	/* Check to make sure our private state still exists. */
+	if (state->private_data != PRIVATE_COOKIE)
+		elog(ERROR, "oauth_validator: private state cookie changed to %p in validate",
+			 state->private_data);
+
+	res = palloc(sizeof(ValidatorModuleResult));
+
+	elog(LOG, "oauth_validator: token=\"%s\", role=\"%s\"", token, role);
+	elog(LOG, "oauth_validator: issuer=\"%s\", scope=\"%s\"",
+		 MyProcPort->hba->oauth_issuer,
+		 MyProcPort->hba->oauth_scope);
+
+	res->authorized = true;
+	if (authn_id)
+		res->authn_id = pstrdup(authn_id);
+	else
+		res->authn_id = pstrdup(role);
+
+	return res;
+}
diff --git a/src/test/perl/PostgreSQL/Test/Cluster.pm b/src/test/perl/PostgreSQL/Test/Cluster.pm
index 508e5e3917..8357272d67 100644
--- a/src/test/perl/PostgreSQL/Test/Cluster.pm
+++ b/src/test/perl/PostgreSQL/Test/Cluster.pm
@@ -2513,6 +2513,11 @@ instead of the default.
 
 If this regular expression is set, matches it with the output generated.
 
+=item expected_stderr => B<value>
+
+If this regular expression is set, matches it against the standard error
+stream; otherwise the stderr must be empty.
+
 =item log_like => [ qr/required message/ ]
 
 =item log_unlike => [ qr/prohibited message/ ]
@@ -2556,7 +2561,20 @@ sub connect_ok
 		like($stdout, $params{expected_stdout}, "$test_name: stdout matches");
 	}
 
-	is($stderr, "", "$test_name: no stderr");
+	if (defined($params{expected_stderr}))
+	{
+		if (like($stderr, $params{expected_stderr}, "$test_name: stderr matches")
+			&& ($ret != 0))
+		{
+			# In this case (failing test but matching stderr) we'll have
+			# swallowed the output needed to debug. Put it back into the logs.
+			diag("$test_name: full stderr:\n" . $stderr);
+		}
+	}
+	else
+	{
+		is($stderr, "", "$test_name: no stderr");
+	}
 
 	$self->log_check($test_name, $log_location, %params);
 }
diff --git a/src/test/perl/PostgreSQL/Test/OAuthServer.pm b/src/test/perl/PostgreSQL/Test/OAuthServer.pm
new file mode 100644
index 0000000000..a13240cd01
--- /dev/null
+++ b/src/test/perl/PostgreSQL/Test/OAuthServer.pm
@@ -0,0 +1,65 @@
+#!/usr/bin/perl
+
+package PostgreSQL::Test::OAuthServer;
+
+use warnings;
+use strict;
+use Scalar::Util;
+use Socket;
+use IO::Select;
+use Test::More;
+
+local *server_socket;
+
+sub new
+{
+	my $class = shift;
+
+	my $self = {};
+	bless($self, $class);
+
+	return $self;
+}
+
+sub port
+{
+	my $self = shift;
+
+	return $self->{'port'};
+}
+
+sub run
+{
+	my $self = shift;
+	my $port;
+
+	my $pid = open(my $read_fh, "-|", $ENV{PYTHON}, "t/oauth_server.py")
+	  or die "failed to start OAuth server: $!";
+
+	read($read_fh, $port, 7) // die "failed to read port number: $!";
+	chomp $port;
+	die "server did not advertise a valid port"
+	  unless Scalar::Util::looks_like_number($port);
+
+	$self->{'pid'} = $pid;
+	$self->{'port'} = $port;
+	$self->{'child'} = $read_fh;
+
+	note("OAuth provider (PID $pid) is listening on port $port\n");
+}
+
+sub stop
+{
+	my $self = shift;
+
+	note("Sending SIGTERM to OAuth provider PID: $self->{'pid'}\n");
+
+	kill(15, $self->{'pid'});
+	$self->{'pid'} = undef;
+
+	# Closing the popen() handle waits for the process to exit.
+	close($self->{'child'});
+	$self->{'child'} = undef;
+}
+
+1;
diff --git a/src/tools/pgindent/pgindent b/src/tools/pgindent/pgindent
index e889af6b1e..362b20a94f 100755
--- a/src/tools/pgindent/pgindent
+++ b/src/tools/pgindent/pgindent
@@ -235,6 +235,14 @@ sub pre_indent
 	# Protect wrapping in CATALOG()
 	$source =~ s!^(CATALOG\(.*)$!/*$1*/!gm;
 
+	# Treat a CURL_IGNORE_DEPRECATION() as braces for the purposes of
+	# indentation. (The recursive regex comes from the perlre documentation; it
+	# matches balanced parentheses as group $1 and the contents as group $2.)
+	my $curlopen = '{ /* CURL_IGNORE_DEPRECATION */';
+	my $curlclose = '} /* CURL_IGNORE_DEPRECATION */';
+	$source =~
+	  s!^[ \t]+CURL_IGNORE_DEPRECATION(\(((?:(?>[^()]+)|(?1))*)\))!$curlopen$2$curlclose!gms;
+
 	return $source;
 }
 
@@ -249,6 +257,12 @@ sub post_indent
 	$source =~ s!^/\* Open extern "C" \*/$!{!gm;
 	$source =~ s!^/\* Close extern "C" \*/$!}!gm;
 
+	# Restore the CURL_IGNORE_DEPRECATION() macro, keeping in mind that our
+	# markers may have been re-indented.
+	$source =~
+	  s!{[ \t]+/\* CURL_IGNORE_DEPRECATION \*/!CURL_IGNORE_DEPRECATION(!gm;
+	$source =~ s!}[ \t]+/\* CURL_IGNORE_DEPRECATION \*/!)!gm;
+
 	## Comments
 
 	# Undo change of dash-protected block comments
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 08521d51a9..41030d9c16 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
@@ -369,6 +370,9 @@ CState
 CTECycleClause
 CTEMaterialize
 CTESearchClause
+CURL
+CURLM
+CURLoption
 CV
 CachedExpression
 CachedPlan
@@ -1718,6 +1722,8 @@ NumericDigit
 NumericSortSupport
 NumericSumAccum
 NumericVar
+OAuthStep
+OAuthValidatorCallbacks
 OM_uint32
 OP
 OSAPerGroupState
@@ -1782,6 +1788,7 @@ PFN
 PGAlignedBlock
 PGAlignedXLogBlock
 PGAsyncStatusType
+PGAuthData
 PGCALL2
 PGChecksummablePage
 PGContextVisibility
@@ -1944,11 +1951,14 @@ PQArgBlock
 PQEnvironmentOption
 PQExpBuffer
 PQExpBufferData
+PQauthDataHook_type
 PQcommMethods
 PQconninfoOption
 PQnoticeProcessor
 PQnoticeReceiver
+PQoauthBearerRequest
 PQprintOpt
+PQpromptOAuthDevice
 PQsslKeyPassHook_OpenSSL_type
 PREDICATELOCK
 PREDICATELOCKTAG
@@ -3069,6 +3079,8 @@ VacuumRelation
 VacuumStmt
 ValidIOData
 ValidateIndexState
+ValidatorModuleState
+ValidatorModuleResult
 ValuesScan
 ValuesScanState
 Var
@@ -3462,6 +3474,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
@@ -3665,6 +3679,7 @@ normal_rand_fctx
 nsphash_hash
 ntile_context
 numeric
+oauth_state
 object_access_hook_type
 object_access_hook_type_str
 off_t
-- 
2.34.1

