From 0864fb5e0f478b693f84db8bc542f0e46e141d19 Mon Sep 17 00:00:00 2001
From: Ajit Awekar <ajit.awekar@enterprisedb.com>
Date: Tue, 30 Jun 2026 16:52:32 +0530
Subject: [PATCH 4/5] Add GSS credential expiry to credential validation

Register a method-specific validator for GSSAPI/Kerberos sessions (CVT_GSS).
The new helper be_gssapi_get_context_expired() uses gss_context_time() on the
acceptor context retained on the Port to report, with no round-trip to the KDC,
whether the context lifetime derived from the client's Kerberos ticket has
elapsed.  A short-lived-ticket TAP test (009_gss_continuous_validation.pl)
confirms an idle GSS session is terminated once the credential lapses;
PostgreSQL::Test::Kerberos gains clockskew and ticket-lifetime parameters and
namespaces its KDC files by test script name.
---
 src/backend/libpq/auth-validate-methods.c     |  33 ++++
 src/backend/libpq/auth-validate.c             |   2 +
 src/backend/libpq/be-secure-gssapi.c          |  37 +++++
 src/include/libpq/auth-validate.h             |   1 +
 src/include/libpq/libpq-be.h                  |   7 +
 src/test/kerberos/meson.build                 |   1 +
 .../t/009_gss_continuous_validation.pl        | 144 ++++++++++++++++++
 src/test/perl/PostgreSQL/Test/Kerberos.pm     |  40 +++--
 8 files changed, 255 insertions(+), 10 deletions(-)
 create mode 100644 src/test/kerberos/t/009_gss_continuous_validation.pl

diff --git a/src/backend/libpq/auth-validate-methods.c b/src/backend/libpq/auth-validate-methods.c
index 0b74057fffd..7039b3201b8 100644
--- a/src/backend/libpq/auth-validate-methods.c
+++ b/src/backend/libpq/auth-validate-methods.c
@@ -31,6 +31,7 @@
 /* Function declarations for internal use */
 static bool validate_oauth_credentials(void);
 static bool validate_cert_credentials(void);
+static bool validate_gss_credentials(void);
 
 /*
  * Initialize validation methods
@@ -46,6 +47,7 @@ InitializeValidationMethods(void)
 	 */
 	RegisterCredentialValidator(CVT_OAUTH, validate_oauth_credentials);
 	RegisterCredentialValidator(CVT_CERT, validate_cert_credentials);
+	RegisterCredentialValidator(CVT_GSS, validate_gss_credentials);
 }
 
 /*
@@ -134,3 +136,34 @@ validate_cert_credentials(void)
 
 	return true;
 }
+
+/*
+ * Validate GSSAPI (Kerberos) credentials.
+ *
+ * The GSS security context established at authentication time carries a
+ * lifetime derived from the Kerberos ticket the client presented at connection
+ * time.  Once that lifetime has elapsed, the credential that authenticated the
+ * session is no longer valid.  This is checked locally from the context
+ * retained on the Port, with no round-trip to the KDC.  Returns false once the
+ * context has expired, true otherwise.
+ *
+ * If the session is not using a GSS context (which should not happen for a
+ * GSS-authenticated session), there is nothing GSS-specific to validate, so
+ * the credentials are considered valid.
+ */
+static bool
+validate_gss_credentials(void)
+{
+#ifdef ENABLE_GSS
+	Port	   *port = MyProcPort;
+
+	if (port == NULL)
+		return true;
+
+	/* The session is no longer valid once the GSS context expires */
+	if (be_gssapi_get_context_expired(port))
+		return false;
+#endif
+
+	return true;
+}
diff --git a/src/backend/libpq/auth-validate.c b/src/backend/libpq/auth-validate.c
index fd31be1ca99..e39a33b71a9 100644
--- a/src/backend/libpq/auth-validate.c
+++ b/src/backend/libpq/auth-validate.c
@@ -48,6 +48,8 @@ UserAuthToValidationType(UserAuth auth_method)
 			return CVT_OAUTH;
 		case uaCert:
 			return CVT_CERT;
+		case uaGSS:
+			return CVT_GSS;
 		default:
 			/*
 			 * No method-specific validator for other auth methods.  Password
diff --git a/src/backend/libpq/be-secure-gssapi.c b/src/backend/libpq/be-secure-gssapi.c
index 540ed62a5cc..2e6827c2c9f 100644
--- a/src/backend/libpq/be-secure-gssapi.c
+++ b/src/backend/libpq/be-secure-gssapi.c
@@ -788,3 +788,40 @@ be_gssapi_get_delegation(Port *port)
 
 	return port->gss->delegated_creds;
 }
+
+/*
+ * Report whether the GSSAPI security context established at authentication
+ * time has passed its expiration.
+ *
+ * The lifetime of the GSS context is derived from the Kerberos ticket the
+ * client presented at connection time.  gss_context_time() reports how many
+ * seconds the context will remain valid; once that reaches zero (or the call
+ * reports GSS_S_CONTEXT_EXPIRED) the credential that authenticated this
+ * session has expired.  This is a purely local check and involves no
+ * round-trip to the KDC.
+ *
+ * Returns true if a context is present and has expired, false otherwise.  If
+ * no GSS context is present, or the lifetime cannot be read, the session is
+ * conservatively treated as not expired so that callers do not terminate it on
+ * the basis of missing or unreadable state.
+ */
+bool
+be_gssapi_get_context_expired(Port *port)
+{
+	OM_uint32	major,
+				minor,
+				lifetime;
+
+	if (!port || !port->gss || port->gss->ctx == GSS_C_NO_CONTEXT)
+		return false;
+
+	major = gss_context_time(&minor, port->gss->ctx, &lifetime);
+
+	if (major == GSS_S_CONTEXT_EXPIRED)
+		return true;
+
+	if (GSS_ERROR(major))
+		return false;
+
+	return (lifetime == 0);
+}
diff --git a/src/include/libpq/auth-validate.h b/src/include/libpq/auth-validate.h
index b0b5a1144a7..5a0e1df4dcf 100644
--- a/src/include/libpq/auth-validate.h
+++ b/src/include/libpq/auth-validate.h
@@ -21,6 +21,7 @@ typedef enum CredentialValidationType
 {
 	CVT_OAUTH = 0,				/* OAuth bearer token authentication */
 	CVT_CERT,					/* TLS client certificate authentication */
+	CVT_GSS,					/* GSSAPI/Kerberos authentication */
 	CVT_COUNT					/* Total number of credential validation types */
 } CredentialValidationType;
 
diff --git a/src/include/libpq/libpq-be.h b/src/include/libpq/libpq-be.h
index aa007da958c..f3fae64623d 100644
--- a/src/include/libpq/libpq-be.h
+++ b/src/include/libpq/libpq-be.h
@@ -353,6 +353,13 @@ extern bool be_gssapi_get_enc(Port *port);
 extern const char *be_gssapi_get_princ(Port *port);
 extern bool be_gssapi_get_delegation(Port *port);
 
+/*
+ * Report whether the GSSAPI security context established at authentication
+ * time has passed its expiration (derived from the client's Kerberos ticket
+ * lifetime).  Returns false when no GSS context is present.
+ */
+extern bool be_gssapi_get_context_expired(Port *port);
+
 /* Read and write to a GSSAPI-encrypted connection. */
 extern ssize_t be_gssapi_read(Port *port, void *ptr, size_t len);
 extern ssize_t be_gssapi_write(Port *port, const void *ptr, size_t len);
diff --git a/src/test/kerberos/meson.build b/src/test/kerberos/meson.build
index 11aa732e69b..7d063956205 100644
--- a/src/test/kerberos/meson.build
+++ b/src/test/kerberos/meson.build
@@ -8,6 +8,7 @@ tests += {
     'test_kwargs': {'priority': 40}, # kerberos tests are slow, start early
     'tests': [
       't/001_auth.pl',
+      't/009_gss_continuous_validation.pl',
     ],
     'env': {
       'with_gssapi': gssapi.found() ? 'yes' : 'no',
diff --git a/src/test/kerberos/t/009_gss_continuous_validation.pl b/src/test/kerberos/t/009_gss_continuous_validation.pl
new file mode 100644
index 00000000000..e683a59900b
--- /dev/null
+++ b/src/test/kerberos/t/009_gss_continuous_validation.pl
@@ -0,0 +1,144 @@
+
+# Copyright (c) 2021-2026, PostgreSQL Global Development Group
+
+# Test continuous credential validation for GSSAPI/Kerberos sessions.
+#
+# A session authenticated with GSSAPI keeps running independently of the
+# Kerberos ticket that established it; nothing re-checks the ticket once the
+# connection is up.  With credential_validation_enabled, the backend should
+# periodically re-verify that the GSS security context (whose lifetime is
+# derived from the client's ticket) has not expired, and terminate the session
+# once it has.
+#
+# This test sets up a KDC, obtains a short-lived ticket, opens a GSS session,
+# lets the ticket (and therefore the server-side GSS context) expire while the
+# session sits idle, and then verifies that the next command is rejected with a
+# FATAL "session credentials have expired".
+#
+# Like 001_auth.pl this requires a full MIT Kerberos installation; see the
+# README for details.
+
+use strict;
+use warnings FATAL => 'all';
+use PostgreSQL::Test::Utils;
+use PostgreSQL::Test::Cluster;
+use PostgreSQL::Test::Kerberos;
+use Test::More;
+
+if ($ENV{with_gssapi} ne 'yes')
+{
+	plan skip_all => 'GSSAPI/Kerberos not supported by this build';
+}
+elsif (!$ENV{PG_TEST_EXTRA} || $ENV{PG_TEST_EXTRA} !~ /\bkerberos\b/)
+{
+	plan skip_all =>
+	  'Potentially unsafe test GSSAPI/Kerberos not enabled in PG_TEST_EXTRA';
+}
+
+# Timing parameters.  The server-side GSS (acceptor) context lifetime is not
+# the client's Kerberos ticket lifetime; it is bounded by the allowable clock
+# skew.  We therefore configure a small clock skew so that the GSS context
+# expires shortly after authentication, and then sit idle long enough that the
+# context is certainly expired ($idle_seconds > $clockskew) and the validation
+# timer has certainly fired ($idle_seconds > $validation_interval, whose
+# minimum is 5s) before issuing the next command.
+my $validation_interval = 5;
+my $clockskew = 5;
+my $ticket_lifetime = '15s';
+my $idle_seconds = 25;
+
+my $dbname = 'postgres';
+my $username = 'test1';
+
+note "setting up Kerberos";
+
+my $host = 'auth-test-localhost.postgresql.example.com';
+my $hostaddr = '127.0.0.1';
+my $realm = 'EXAMPLE.COM';
+
+my $krb =
+  PostgreSQL::Test::Kerberos->new($host, $hostaddr, $realm,
+	clockskew => $clockskew);
+
+my $test1_password = 'secret1';
+$krb->create_principal('test1', $test1_password);
+
+note "setting up PostgreSQL instance";
+
+my $node = PostgreSQL::Test::Cluster->new('node');
+$node->init;
+$node->append_conf(
+	'postgresql.conf', qq{
+listen_addresses = '$hostaddr'
+krb_server_keyfile = '$krb->{keytab}'
+log_connections = all
+log_min_messages = debug2
+lc_messages = 'C'
+credential_validation_enabled = on
+credential_validation_interval = $validation_interval
+});
+$node->start;
+
+$node->safe_psql('postgres', 'CREATE USER test1;');
+
+unlink($node->data_dir . '/pg_hba.conf');
+$node->append_conf(
+	'pg_hba.conf',
+	qq{
+host all all $hostaddr/32 gss include_realm=0
+});
+$node->restart;
+
+note "running tests";
+
+# The server-side GSS (acceptor) context lifetime is bounded by the larger of
+# the ticket's remaining lifetime and the allowable clock skew, so force both
+# to be small: a short ticket together with the small clock skew configured
+# above makes the GSS context expire shortly after the session is established.
+$krb->create_ticket('test1', $test1_password, lifetime => $ticket_lifetime);
+
+# Open a GSS-authenticated session, confirm it is live, then sit idle past the
+# GSS context lifetime (and the validation interval) before issuing another
+# command.
+# The idle period is spent in a client-side "\! sleep", so the backend waits at
+# a command boundary -- exactly where the validation timer's pending flag is
+# acted upon when the next command arrives.
+#
+# Use gssencmode=disable so that GSSAPI is used only for authentication and the
+# transport stays in plaintext.  With GSS transport encryption the expiring
+# context would break the connection at the transport layer (and trigger a
+# libpq reconnect) before the backend's credential validation runs; disabling
+# it isolates the behavior under test -- the server terminating the session
+# because its credentials expired.
+my $connstr = $node->connstr('postgres')
+  . " user=$username host=$host hostaddr=$hostaddr gssencmode=disable";
+
+my $script =
+  "SELECT 'session-live';\n" . "\\! sleep $idle_seconds\n" . "SELECT 'after-expiry';\n";
+
+my ($ret, $stdout, $stderr) = $node->psql(
+	'postgres', $script,
+	connstr => $connstr,
+	extra_params => ['-w']);
+
+isnt($ret, 0, 'GSS session is terminated once its credentials expire');
+like(
+	$stdout,
+	qr/session-live/,
+	'session is live before the GSS credentials expire');
+unlike(
+	$stdout,
+	qr/after-expiry/,
+	'command after expiry does not return a result');
+like(
+	$stderr,
+	qr/session credentials have expired/,
+	'session terminated with the credential-expiry FATAL');
+
+# The session must have actually authenticated via GSS for this to be a
+# meaningful test of GSS credential validation.
+ok( $node->log_contains(
+		qq{connection authenticated: identity="test1\@$realm" method=gss}),
+	'session was authenticated with GSS');
+
+done_testing();
diff --git a/src/test/perl/PostgreSQL/Test/Kerberos.pm b/src/test/perl/PostgreSQL/Test/Kerberos.pm
index e861d93533e..39de8f918b8 100644
--- a/src/test/perl/PostgreSQL/Test/Kerberos.pm
+++ b/src/test/perl/PostgreSQL/Test/Kerberos.pm
@@ -8,6 +8,7 @@ package PostgreSQL::Test::Kerberos;
 
 use strict;
 use warnings FATAL => 'all';
+use File::Basename;
 use PostgreSQL::Test::Utils;
 use Test::More;
 
@@ -62,15 +63,22 @@ INIT
 		$krb5kdc = $krb5_sbin_dir . '/' . $krb5kdc;
 	}
 
-	$krb5_conf = "${PostgreSQL::Test::Utils::tmp_check}/krb5.conf";
-	$kdc_conf = "${PostgreSQL::Test::Utils::tmp_check}/kdc.conf";
-	$krb5_cache = "${PostgreSQL::Test::Utils::tmp_check}/krb5cc";
-	$krb5_log = "${PostgreSQL::Test::Utils::log_path}/krb5libs.log";
-	$kdc_log = "${PostgreSQL::Test::Utils::log_path}/krb5kdc.log";
+	# Derive a per-test prefix from the running script's name so that the KDC
+	# files, which live in the shared tmp_check/log directories, do not collide
+	# when more than one Kerberos test script is run in the same build (each
+	# script sets up its own KDC).  This mirrors how Cluster.pm namespaces node
+	# data directories.
+	my $prefix = basename($0);
+
+	$krb5_conf = "${PostgreSQL::Test::Utils::tmp_check}/${prefix}_krb5.conf";
+	$kdc_conf = "${PostgreSQL::Test::Utils::tmp_check}/${prefix}_kdc.conf";
+	$krb5_cache = "${PostgreSQL::Test::Utils::tmp_check}/${prefix}_krb5cc";
+	$krb5_log = "${PostgreSQL::Test::Utils::log_path}/${prefix}_krb5libs.log";
+	$kdc_log = "${PostgreSQL::Test::Utils::log_path}/${prefix}_krb5kdc.log";
 	$kdc_port = PostgreSQL::Test::Cluster::get_free_port();
-	$kdc_datadir = "${PostgreSQL::Test::Utils::tmp_check}/krb5kdc";
-	$kdc_pidfile = "${PostgreSQL::Test::Utils::tmp_check}/krb5kdc.pid";
-	$keytab = "${PostgreSQL::Test::Utils::tmp_check}/krb5.keytab";
+	$kdc_datadir = "${PostgreSQL::Test::Utils::tmp_check}/${prefix}_krb5kdc";
+	$kdc_pidfile = "${PostgreSQL::Test::Utils::tmp_check}/${prefix}_krb5kdc.pid";
+	$keytab = "${PostgreSQL::Test::Utils::tmp_check}/${prefix}_krb5.keytab";
 }
 
 =pod
@@ -102,7 +110,13 @@ Name of the Kerberos realm.
 sub new
 {
 	my $class = shift;
-	my ($host, $hostaddr, $realm) = @_;
+	my ($host, $hostaddr, $realm, %params) = @_;
+
+	# Optionally override the allowable clock skew.  The acceptor's GSS context
+	# lifetime is bounded by this value, so tests that need a GSS context to
+	# expire quickly can set a small clock skew.
+	my $clockskew_line =
+	  defined $params{clockskew} ? "clockskew = $params{clockskew}\n" : "";
 
 	my ($stdout, $krb5_version);
 	run_log [ $krb5_config, '--version' ], '>' => \$stdout
@@ -140,7 +154,8 @@ dns_lookup_kdc = false
 default_realm = $realm
 forwardable = false
 rdns = false
-
+dns_canonicalize_hostname = false
+$clockskew_line
 [realms]
 $realm = {
     kdc = $hostaddr:$kdc_port
@@ -222,6 +237,11 @@ sub create_ticket
 
 	push @cmd, '-f' if ($params{forwardable});
 
+	# Request a specific ticket lifetime (e.g. '10s') when asked.  This is
+	# used to test behavior that depends on credentials expiring, such as
+	# continuous credential validation.
+	push @cmd, ('-l', $params{lifetime}) if (defined $params{lifetime});
+
 	run_log [@cmd], \$password or BAIL_OUT($?);
 	run_log [ $klist, '-f' ] or BAIL_OUT($?);
 }
-- 
2.52.0

