From 4a99026215f47fcf338d76056496d0724ffb445c Mon Sep 17 00:00:00 2001
From: Zsolt Parragi <zsolt.parragi@percona.com>
Date: Wed, 17 Jun 2026 06:31:42 +0000
Subject: [PATCH v3 2/3] libpq: require encrypted connections for OAUTHBEARER

Bearer tokens must be protected in transit (RFC 7628, Sec. 5; RFC 6750,
Sec. 5.2), but libpq previously sent them to the server over plaintext
TCP connections. Refuse the SASL exchange unless the connection is
encrypted with SSL/TLS or GSSAPI, or uses a Unix-domain socket.

The previous behavior can be restored for local development and testing
with the new unsafe debug option PGOAUTHDEBUG=UNSAFE:plaintext-server.
The legacy PGOAUTHDEBUG=UNSAFE format enables it implicitly.
---
 doc/src/sgml/libpq.sgml                       | 24 +++++++++-
 src/interfaces/libpq/fe-auth-oauth.c          | 24 ++++++++++
 src/interfaces/libpq/oauth-debug.h            | 10 +++--
 .../modules/oauth_validator/t/001_server.pl   | 45 +++++++++++++++++++
 4 files changed, 99 insertions(+), 4 deletions(-)

diff --git a/doc/src/sgml/libpq.sgml b/doc/src/sgml/libpq.sgml
index 7d3c3bb66d8..49634253740 100644
--- a/doc/src/sgml/libpq.sgml
+++ b/doc/src/sgml/libpq.sgml
@@ -10275,6 +10275,14 @@ void PQinitSSL(int do_ssl);
    client application does not have a usable web browser, for example when
    running a client via <acronym>SSH</acronym>.
   </para>
+  <para>
+   Bearer tokens must be protected in transit
+   (<ulink url="https://datatracker.ietf.org/doc/html/rfc7628">RFC 7628</ulink>).
+   <application>libpq</application> refuses to perform OAuth authentication
+   unless the connection to the server is encrypted with
+   <acronym>SSL</acronym>/<acronym>TLS</acronym> or <acronym>GSSAPI</acronym>,
+   or uses a Unix-domain socket.
+  </para>
   <para>
    The builtin flow will, by default, print a URL to visit and a user code to
    enter there:
@@ -10699,6 +10707,19 @@ PGOAUTHDEBUG=UNSAFE                      <lineannotation>legacy format; enables
       </listitem>
      </varlistentry>
 
+     <varlistentry>
+      <term><literal>plaintext-server</literal> (unsafe)</term>
+      <listitem>
+       <para>
+        Permits sending the OAuth bearer token to the
+        <productname>PostgreSQL</productname> server over an unencrypted
+        connection. Without this option, OAUTHBEARER authentication requires
+        a connection encrypted with <acronym>SSL</acronym>/<acronym>TLS</acronym>
+        or <acronym>GSSAPI</acronym>, or a Unix-domain socket.
+       </para>
+      </listitem>
+     </varlistentry>
+
      <varlistentry>
       <term><literal>call-count</literal> (safe)</term>
       <listitem>
@@ -10724,7 +10745,8 @@ PGOAUTHDEBUG=UNSAFE                      <lineannotation>legacy format; enables
 
    <para>
     Unsafe options (<literal>http</literal>, <literal>trace</literal>,
-    <literal>dos-endpoint</literal>) require the <literal>UNSAFE:</literal> prefix.
+    <literal>dos-endpoint</literal>, <literal>plaintext-server</literal>)
+    require the <literal>UNSAFE:</literal> prefix.
     If unsafe options are specified without this prefix, or if an option name is
     unrecognized, a warning is printed to standard error and that option is
     ignored. Other valid options in the list continue to work. Safe options
diff --git a/src/interfaces/libpq/fe-auth-oauth.c b/src/interfaces/libpq/fe-auth-oauth.c
index 826f7461cb3..6e24a365adb 100644
--- a/src/interfaces/libpq/fe-auth-oauth.c
+++ b/src/interfaces/libpq/fe-auth-oauth.c
@@ -1209,6 +1209,30 @@ oauth_exchange(void *opaq, bool final,
 			/* We begin in the initial response phase. */
 			Assert(inputlen == -1);
 
+			/*
+			 * Bearer tokens must be protected in transit (RFC 7628, Sec. 5;
+			 * RFC 6750, Sec. 5.2). Refuse to continue the exchange if the
+			 * connection to the server is neither encrypted (via TLS or GSS)
+			 * nor local, unless the user has explicitly opted into unsafe
+			 * behavior.
+			 */
+			{
+				bool		encrypted = conn->ssl_in_use;
+
+#ifdef ENABLE_GSS
+				encrypted = encrypted || conn->gssenc;
+#endif
+
+				if (!encrypted &&
+					conn->raddr.addr.ss_family != AF_UNIX &&
+					!(oauth_parse_debug_flags() & OAUTHDEBUG_UNSAFE_PLAINTEXT_SERVER))
+				{
+					libpq_append_conn_error(conn,
+											"OAuth bearer authentication requires an encrypted connection (consider sslmode=require or gssencmode=require)");
+					return SASL_FAILED;
+				}
+			}
+
 			if (!setup_oauth_parameters(conn))
 				return SASL_FAILED;
 
diff --git a/src/interfaces/libpq/oauth-debug.h b/src/interfaces/libpq/oauth-debug.h
index 4f0c87117e1..07f7703a7f3 100644
--- a/src/interfaces/libpq/oauth-debug.h
+++ b/src/interfaces/libpq/oauth-debug.h
@@ -35,11 +35,13 @@
  */
 
 /* allow HTTP (unencrypted) connections */
-#define OAUTHDEBUG_UNSAFE_HTTP			(1<<0)
+#define OAUTHDEBUG_UNSAFE_HTTP				(1<<0)
 /* log HTTP traffic (exposes secrets) */
-#define OAUTHDEBUG_UNSAFE_TRACE			(1<<1)
+#define OAUTHDEBUG_UNSAFE_TRACE				(1<<1)
 /* allow zero-second retry intervals */
-#define OAUTHDEBUG_UNSAFE_DOS_ENDPOINT	(1<<2)
+#define OAUTHDEBUG_UNSAFE_DOS_ENDPOINT		(1<<2)
+/* allow sending the bearer token to the server over plaintext connections */
+#define OAUTHDEBUG_UNSAFE_PLAINTEXT_SERVER	(1<<3)
 
 /* mind the gap in values; see OAUTHDEBUG_UNSAFE_MASK below */
 
@@ -112,6 +114,8 @@ oauth_parse_debug_flags(void)
 			flag = OAUTHDEBUG_UNSAFE_TRACE;
 		else if (strcmp(option, "dos-endpoint") == 0)
 			flag = OAUTHDEBUG_UNSAFE_DOS_ENDPOINT;
+		else if (strcmp(option, "plaintext-server") == 0)
+			flag = OAUTHDEBUG_UNSAFE_PLAINTEXT_SERVER;
 		else if (strcmp(option, "call-count") == 0)
 			flag = OAUTHDEBUG_CALL_COUNT;
 		else if (strcmp(option, "plugin-errors") == 0)
diff --git a/src/test/modules/oauth_validator/t/001_server.pl b/src/test/modules/oauth_validator/t/001_server.pl
index 1619fbffd45..ead1d7b4ffb 100644
--- a/src/test/modules/oauth_validator/t/001_server.pl
+++ b/src/test/modules/oauth_validator/t/001_server.pl
@@ -50,6 +50,8 @@ $node->append_conf('postgresql.conf',
 	"oauth_validator_libraries = 'validator'\n");
 # Needed to allow connect_fails to inspect postmaster log:
 $node->append_conf('postgresql.conf', "log_min_messages = debug2");
+# The plaintext-TCP enforcement tests below need a TCP listener.
+$node->append_conf('postgresql.conf', "listen_addresses = '127.0.0.1'\n");
 $node->start;
 
 $node->safe_psql('postgres', 'CREATE USER test;');
@@ -831,6 +833,49 @@ $node->connect_fails(
 	"fail_validator is used for $user",
 	expected_stderr => qr/FATAL:\s+fail_validator: sentinel error/);
 
+#
+# Test that bearer tokens are refused over unencrypted TCP connections
+# unless the unsafe plaintext-server debug option is set.
+#
+
+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"
+host  all test 127.0.0.1/32 oauth validator=validator issuer="$issuer" scope="openid postgres"
+});
+$node->reload;
+
+$log_start =
+  $node->wait_for_log(qr/reloading configuration files/, $log_start);
+
+$user = "test";
+my $tcp_connstr = "host=127.0.0.1 user=$user dbname=postgres"
+  . " oauth_issuer=$issuer oauth_client_id=f02c6361-0635";
+
+# The PGOAUTHDEBUG value set above does not include plaintext-server, so
+# this must fail before any flow is run.
+$node->connect_fails(
+	$tcp_connstr,
+	"bearer tokens are not sent over unencrypted TCP",
+	expected_stderr =>
+	  qr@OAuth bearer authentication requires an encrypted connection@,
+	log_unlike => [qr/oauth_validator: token=/]);
+
+{
+	local $ENV{PGOAUTHDEBUG} = "$ENV{PGOAUTHDEBUG},plaintext-server";
+
+	$node->connect_ok(
+		$tcp_connstr,
+		"unencrypted TCP is allowed with plaintext-server",
+		expected_stderr =>
+		  qr@Visit https://example\.com/ and enter the code: postgresuser@,
+		log_like => [
+			qr/connection authenticated: identity="test" method=oauth/,
+			qr/connection authorized/,
+		]);
+}
+
 #
 # Test ABI compatibility magic marker
 #
-- 
2.43.0

