v20-0005-backend-add-OAUTHBEARER-SASL-mechanism.patch

application/octet-stream

Filename: v20-0005-backend-add-OAUTHBEARER-SASL-mechanism.patch
Type: application/octet-stream
Part: 3
Message: Re: [PoC] Federated Authn/z with OAUTHBEARER

Patch

Same data as JSON: GET /api/v1/attachments/:id/patch the parsed metadata as JSON — format, series position, per-file stats; never the diff bytes. API reference →
Format: format-patch
Series: patch v20-0005
Subject: backend: add OAUTHBEARER SASL mechanism
File+
src/backend/libpq/auth.c 8 18
src/backend/libpq/auth-oauth.c 883 0
src/backend/libpq/auth-sasl.c 1 9
src/backend/libpq/auth-scram.c 3 1
src/backend/libpq/hba.c 28 3
src/backend/libpq/Makefile 1 0
src/backend/libpq/meson.build 1 0
src/backend/utils/misc/guc_tables.c 12 0
src/include/libpq/auth.h 17 0
src/include/libpq/hba.h 5 1
src/include/libpq/oauth.h 24 0
src/include/libpq/sasl.h 11 0
src/tools/pgindent/typedefs.list 1 0
From 5488ac25f5396017c4f1a3c78bc4f807b558e83d Mon Sep 17 00:00:00 2001
From: Jacob Champion <pchampion@vmware.com>
Date: Tue, 4 May 2021 16:21:11 -0700
Subject: [PATCH v20 5/9] backend: add OAUTHBEARER SASL mechanism

DO NOT USE THIS PROOF OF CONCEPT IN PRODUCTION.

Implement OAUTHBEARER (RFC 7628) on the server side. This adds a new
auth method, oauth, to pg_hba.

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 program: the oauth_validator_command.
This command must do the following:

1. Receive the bearer token by reading its contents from a file
   descriptor passed from the server. (The numeric value of this
   descriptor may be inserted into the oauth_validator_command using the
   %f specifier.)

   This MUST be the first action the command performs. The server will
   not begin reading stdout from the command until the token has been
   read in full, so if the command tries to print anything and hits a
   buffer limit, the backend will deadlock and time out.

2. 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 command must exit with a
   non-zero status. 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 command should print the
      authenticated identity string to stdout, followed by a newline.

      If the user cannot be authenticated, the validator should not
      print anything to stdout. It should also exit with a non-zero
      status, unless the token may be used to authorize the connection
      through some other means (see below).

      On a success, the command may then exit with a zero success code.
      By default, the server will then check to make sure the identity
      string matches the role that is being used (or matches a usermap
      entry, if one is in use).

   b. To optionally authorize the user, in combination with the HBA
      option trust_validator_authz=1 (see below), the validator simply
      returns a zero exit code if the client should be allowed to
      connect with its presented role (which can be passed to the
      command using the %r specifier), or a non-zero code otherwise.

      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 command may print
      the authenticated ID and then fail with a non-zero exit code.
      (This makes it easier to see what's going on in the Postgres
      logs.)

4. Token validators may optionally log to stderr. This will be printed
   verbatim into the Postgres server logs.

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.

Unlike the client, servers support OAuth without needing to be built
against libiddawc (since the responsibility for "speaking" OAuth/OIDC
correctly is delegated entirely to the oauth_validator_command).

Several TODOs:
- port to platforms other than "modern Linux/BSD"
- overhaul the communication with oauth_validator_command, which is
  currently a bad hack on OpenPipeStream()
- implement more helpful handling of HBA misconfigurations
- use logdetail during auth failures
- deal with role names that can't be safely passed to system() without
  shell-escaping
- allow passing the configured issuer to the oauth_validator_command, to
  deal with multi-issuer setups
- ...and more.

Co-authored-by: Daniel Gustafsson <daniel@yesql.se>
---
 src/backend/libpq/Makefile          |   1 +
 src/backend/libpq/auth-oauth.c      | 883 ++++++++++++++++++++++++++++
 src/backend/libpq/auth-sasl.c       |  10 +-
 src/backend/libpq/auth-scram.c      |   4 +-
 src/backend/libpq/auth.c            |  26 +-
 src/backend/libpq/hba.c             |  31 +-
 src/backend/libpq/meson.build       |   1 +
 src/backend/utils/misc/guc_tables.c |  12 +
 src/include/libpq/auth.h            |  17 +
 src/include/libpq/hba.h             |   6 +-
 src/include/libpq/oauth.h           |  24 +
 src/include/libpq/sasl.h            |  11 +
 src/tools/pgindent/typedefs.list    |   1 +
 13 files changed, 995 insertions(+), 32 deletions(-)
 create mode 100644 src/backend/libpq/auth-oauth.c
 create mode 100644 src/include/libpq/oauth.h

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..16596c089a
--- /dev/null
+++ b/src/backend/libpq/auth-oauth.c
@@ -0,0 +1,883 @@
+/*-------------------------------------------------------------------------
+ *
+ * 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-2021, 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 "lib/stringinfo.h"
+#include "libpq/auth.h"
+#include "libpq/hba.h"
+#include "libpq/oauth.h"
+#include "libpq/sasl.h"
+#include "storage/fd.h"
+#include "utils/json.h"
+
+/* GUC */
+char	   *oauth_validator_command;
+
+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);
+
+/* 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, const char **logdetail);
+static bool run_validator_command(Port *port, const char *token);
+static bool check_exit(FILE **fh, const char *command);
+static bool set_cloexec(int fd);
+static bool username_ok_for_shell(const char *username);
+
+#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))
+		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;
+
+	return ctx;
+}
+
+static int
+oauth_exchange(void *opaq, const char *input, int inputlen,
+			   char **output, int *outputlen, const char **logdetail)
+{
+	char	   *p;
+	char		cbind_flag;
+	char	   *auth;
+
+	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 = 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, logdetail))
+	{
+		generate_error_response(ctx, output, outputlen);
+
+		ctx->state = OAUTH_STATE_ERROR;
+		return PG_SASL_EXCHANGE_CONTINUE;
+	}
+
+	ctx->state = OAUTH_STATE_FINISHED;
+	return PG_SASL_EXCHANGE_SUCCESS;
+}
+
+/*
+ * 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.")));
+
+	return NULL;				/* unreachable */
+}
+
+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 the .well-known URI based on our issuer.
+	 * TODO: RFC 8414 defines a competing well-known URI, so we'll probably
+	 * have to make this configurable too.
+	 */
+	initStringInfo(&issuer);
+	appendStringInfoString(&issuer, ctx->issuer);
+	appendStringInfoString(&issuer, "/.well-known/openid-configuration");
+
+	initStringInfo(&buf);
+
+	/*
+	 * TODO: note that escaping here should be belt-and-suspenders, 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;
+}
+
+static bool
+validate(Port *port, const char *auth, const char **logdetail)
+{
+	static const char *const b64_set =
+		"abcdefghijklmnopqrstuvwxyz"
+		"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+		"0123456789-._~+/";
+
+	const char *token;
+	size_t		span;
+	int			ret;
+
+	/* TODO: handle logdetail when the test framework can check it */
+
+	/*-----
+	 * 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.)
+	 *
+	 * TODO: handle the Authorization spec, RFC 7235 Sec. 2.1.
+	 */
+	if (pg_strncasecmp(auth, BEARER_SCHEME, strlen(BEARER_SCHEME)))
+		return false;
+
+	/* Pull the bearer token out of the auth value. */
+	token = auth + strlen(BEARER_SCHEME);
+
+	/* Swallow any additional spaces. */
+	while (*token == ' ')
+		token++;
+
+	/*
+	 * Before invoking the validator command, sanity-check the token format to
+	 * avoid any injection attacks later in the chain. Invalid formats are
+	 * technically a protocol violation, but don't reflect any information
+	 * about the sensitive Bearer token back to the client; log at COMMERROR
+	 * instead.
+	 */
+
+	/* Tokens must not be empty. */
+	if (!*token)
+	{
+		ereport(COMMERROR,
+				(errcode(ERRCODE_PROTOCOL_VIOLATION),
+				 errmsg("malformed OAUTHBEARER message"),
+				 errdetail("Bearer token is empty.")));
+		return false;
+	}
+
+	/*
+	 * Make sure the token contains only allowed characters. Tokens may end
+	 * with any number of '=' characters.
+	 */
+	span = strspn(token, b64_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 OAUTHBEARER message"),
+				 errdetail("Bearer token is not in the correct format.")));
+		return false;
+	}
+
+	/* Have the validator check the token. */
+	if (!run_validator_command(port, token))
+		return false;
+
+	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.
+		 */
+		return true;
+	}
+
+	/* Make sure the validator authenticated the user. */
+	if (!MyClientConnectionInfo.authn_id)
+	{
+		/* TODO: use logdetail; reduce message duplication */
+		ereport(LOG,
+				(errmsg("OAuth bearer authentication failed for user \"%s\": validator provided no identity",
+						port->user_name)));
+		return false;
+	}
+
+	/* Finally, check the user map. */
+	ret = check_usermap(port->hba->usermap, port->user_name,
+						MyClientConnectionInfo.authn_id, false);
+	return (ret == STATUS_OK);
+}
+
+static bool
+run_validator_command(Port *port, const char *token)
+{
+	bool		success = false;
+	int			rc;
+	int			pipefd[2];
+	int			rfd = -1;
+	int			wfd = -1;
+
+	StringInfoData command = {0};
+	char	   *p;
+	FILE	   *fh = NULL;
+
+	ssize_t		written;
+	char	   *line = NULL;
+	size_t		size = 0;
+	ssize_t		len;
+
+	Assert(oauth_validator_command);
+
+	if (!oauth_validator_command[0])
+	{
+		ereport(COMMERROR,
+				(errmsg("oauth_validator_command is not set"),
+				 errhint("To allow OAuth authenticated connections, set "
+						 "oauth_validator_command in postgresql.conf.")));
+		return false;
+	}
+
+	/*------
+	 * Since popen() is unidirectional, open up a pipe for the other
+	 * direction. Use CLOEXEC to ensure that our write end doesn't
+	 * accidentally get copied into child processes, which would prevent us
+	 * from closing it cleanly.
+	 *
+	 * XXX this is ugly. We should just read from the child process's stdout,
+	 * but that's a lot more code.
+	 * XXX by bypassing the popen API, we open the potential of process
+	 * deadlock. Clearly document child process requirements (i.e. the child
+	 * MUST read all data off of the pipe before writing anything).
+	 * TODO: port to Windows using _pipe().
+	 */
+	rc = pipe(pipefd);
+	if (rc < 0)
+	{
+		ereport(COMMERROR,
+				(errcode_for_file_access(),
+				 errmsg("could not create child pipe: %m")));
+		return false;
+	}
+
+	rfd = pipefd[0];
+	wfd = pipefd[1];
+
+	if (!set_cloexec(wfd))
+	{
+		/* error message was already logged */
+		goto cleanup;
+	}
+
+	/*----------
+	 * Construct the command, substituting any recognized %-specifiers:
+	 *
+	 *   %f: the file descriptor of the input pipe
+	 *   %r: the role that the client wants to assume (port->user_name)
+	 *   %%: a literal '%'
+	 */
+	initStringInfo(&command);
+
+	for (p = oauth_validator_command; *p; p++)
+	{
+		if (p[0] == '%')
+		{
+			switch (p[1])
+			{
+				case 'f':
+					appendStringInfo(&command, "%d", rfd);
+					p++;
+					break;
+				case 'r':
+
+					/*
+					 * TODO: decide how this string should be escaped. The
+					 * role is controlled by the client, so if we don't escape
+					 * it, command injections are inevitable.
+					 *
+					 * This is probably an indication that the role name needs
+					 * to be communicated to the validator process in some
+					 * other way. For this proof of concept, just be
+					 * incredibly strict about the characters that are allowed
+					 * in user names.
+					 */
+					if (!username_ok_for_shell(port->user_name))
+						goto cleanup;
+
+					appendStringInfoString(&command, port->user_name);
+					p++;
+					break;
+				case '%':
+					appendStringInfoChar(&command, '%');
+					p++;
+					break;
+				default:
+					appendStringInfoChar(&command, p[0]);
+			}
+		}
+		else
+			appendStringInfoChar(&command, p[0]);
+	}
+
+	/* Execute the command. */
+	fh = OpenPipeStream(command.data, "r");
+	if (!fh)
+	{
+		ereport(COMMERROR,
+				(errcode_for_file_access(),
+				 errmsg("opening pipe to OAuth validator: %m")));
+		goto cleanup;
+	}
+
+	/* We don't need the read end of the pipe anymore. */
+	close(rfd);
+	rfd = -1;
+
+	/* Give the command the token to validate. */
+	written = write(wfd, token, strlen(token));
+	if (written != strlen(token))
+	{
+		/* TODO must loop for short writes, EINTR et al */
+		ereport(COMMERROR,
+				(errcode_for_file_access(),
+				 errmsg("could not write token to child pipe: %m")));
+		goto cleanup;
+	}
+
+	close(wfd);
+	wfd = -1;
+
+	/*-----
+	 * Read the command's response.
+	 *
+	 * TODO: getline() is probably too new to use, unfortunately.
+	 * TODO: loop over all lines
+	 */
+	if ((len = getline(&line, &size, fh)) >= 0)
+	{
+		/* TODO: fail if the authn_id doesn't end with a newline */
+		if (len > 0)
+			line[len - 1] = '\0';
+
+		set_authn_id(port, line);
+	}
+	else if (ferror(fh))
+	{
+		ereport(COMMERROR,
+				(errcode_for_file_access(),
+				 errmsg("could not read from command \"%s\": %m",
+						command.data)));
+		goto cleanup;
+	}
+
+	/* Make sure the command exits cleanly. */
+	if (!check_exit(&fh, command.data))
+	{
+		/* error message already logged */
+		goto cleanup;
+	}
+
+	/* Done. */
+	success = true;
+
+cleanup:
+	if (line)
+		free(line);
+
+	/*
+	 * In the successful case, the pipe fds are already closed. For the error
+	 * case, always close out the pipe before waiting for the command, to
+	 * prevent deadlock.
+	 */
+	if (rfd >= 0)
+		close(rfd);
+	if (wfd >= 0)
+		close(wfd);
+
+	if (fh)
+	{
+		Assert(!success);
+		check_exit(&fh, command.data);
+	}
+
+	if (command.data)
+		pfree(command.data);
+
+	return success;
+}
+
+static bool
+check_exit(FILE **fh, const char *command)
+{
+	int			rc;
+
+	rc = ClosePipeStream(*fh);
+	*fh = NULL;
+
+	if (rc == -1)
+	{
+		/* pclose() itself failed. */
+		ereport(COMMERROR,
+				(errcode_for_file_access(),
+				 errmsg("could not close pipe to command \"%s\": %m",
+						command)));
+	}
+	else if (rc != 0)
+	{
+		char	   *reason = wait_result_to_str(rc);
+
+		ereport(COMMERROR,
+				(errmsg("failed to execute command \"%s\": %s",
+						command, reason)));
+
+		pfree(reason);
+	}
+
+	return (rc == 0);
+}
+
+static bool
+set_cloexec(int fd)
+{
+	int			flags;
+	int			rc;
+
+	flags = fcntl(fd, F_GETFD);
+	if (flags == -1)
+	{
+		ereport(COMMERROR,
+				(errcode_for_file_access(),
+				 errmsg("could not get fd flags for child pipe: %m")));
+		return false;
+	}
+
+	rc = fcntl(fd, F_SETFD, flags | FD_CLOEXEC);
+	if (rc < 0)
+	{
+		ereport(COMMERROR,
+				(errcode_for_file_access(),
+				 errmsg("could not set FD_CLOEXEC for child pipe: %m")));
+		return false;
+	}
+
+	return true;
+}
+
+/*
+ * XXX This should go away eventually and be replaced with either a proper
+ * escape or a different strategy for communication with the validator command.
+ */
+static bool
+username_ok_for_shell(const char *username)
+{
+	/* This set is borrowed from fe_utils' appendShellStringNoError(). */
+	static const char *const allowed =
+		"abcdefghijklmnopqrstuvwxyz"
+		"ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+		"0123456789-_./:";
+	size_t		span;
+
+	Assert(username && username[0]);	/* should have already been checked */
+
+	span = strspn(username, allowed);
+	if (username[span] != '\0')
+	{
+		ereport(COMMERROR,
+				(errmsg("PostgreSQL user name contains unsafe characters and cannot be passed to the OAuth validator")));
+		return false;
+	}
+
+	return true;
+}
diff --git a/src/backend/libpq/auth-sasl.c b/src/backend/libpq/auth-sasl.c
index 08b24d90b4..4039e7fa3e 100644
--- a/src/backend/libpq/auth-sasl.c
+++ b/src/backend/libpq/auth-sasl.c
@@ -20,14 +20,6 @@
 #include "libpq/pqformat.h"
 #include "libpq/sasl.h"
 
-/*
- * Maximum accepted size of SASL messages.
- *
- * The messages that the server or libpq generate are much smaller than this,
- * but have some headroom.
- */
-#define PG_MAX_SASL_MESSAGE_LENGTH	1024
-
 /*
  * Perform a SASL exchange with a libpq client, using a specific mechanism
  * implementation.
@@ -103,7 +95,7 @@ CheckSASLAuth(const pg_be_sasl_mech *mech, Port *port, char *shadow_pass,
 
 		/* Get the actual SASL message */
 		initStringInfo(&buf);
-		if (pq_getmessage(&buf, PG_MAX_SASL_MESSAGE_LENGTH))
+		if (pq_getmessage(&buf, mech->max_message_length))
 		{
 			/* EOF - pq_getmessage already logged error */
 			pfree(buf.data);
diff --git a/src/backend/libpq/auth-scram.c b/src/backend/libpq/auth-scram.c
index 4161959914..486a34e719 100644
--- a/src/backend/libpq/auth-scram.c
+++ b/src/backend/libpq/auth-scram.c
@@ -113,7 +113,9 @@ static int	scram_exchange(void *opaq, const char *input, int inputlen,
 const pg_be_sasl_mech pg_be_scram_mech = {
 	scram_get_mechanisms,
 	scram_init,
-	scram_exchange
+	scram_exchange,
+
+	PG_MAX_SASL_MESSAGE_LENGTH
 };
 
 /*
diff --git a/src/backend/libpq/auth.c b/src/backend/libpq/auth.c
index 2b607c5270..0a5a8640fc 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 d506c3c0b7..e592bedf9f 100644
--- a/src/backend/libpq/hba.c
+++ b/src/backend/libpq/hba.c
@@ -114,7 +114,8 @@ static const char *const UserAuthName[] =
 	"ldap",
 	"cert",
 	"radius",
-	"peer"
+	"peer",
+	"oauth",
 };
 
 /*
@@ -1743,6 +1744,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,
@@ -2062,8 +2065,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)
@@ -2446,6 +2450,27 @@ 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)
+	{
+		if (hbaline->auth_method != uaOAuth)
+			INVALID_AUTH_OPTION("issuer", gettext_noop("oauth"));
+		hbaline->oauth_issuer = pstrdup(val);
+	}
+	else if (strcmp(name, "scope") == 0)
+	{
+		if (hbaline->auth_method != uaOAuth)
+			INVALID_AUTH_OPTION("scope", gettext_noop("oauth"));
+		hbaline->oauth_scope = pstrdup(val);
+	}
+	else if (strcmp(name, "trust_validator_authz") == 0)
+	{
+		if (hbaline->auth_method != uaOAuth)
+			INVALID_AUTH_OPTION("trust_validator_authz", gettext_noop("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/utils/misc/guc_tables.c b/src/backend/utils/misc/guc_tables.c
index 45013582a7..d28209901f 100644
--- a/src/backend/utils/misc/guc_tables.c
+++ b/src/backend/utils/misc/guc_tables.c
@@ -48,6 +48,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"
@@ -4670,6 +4671,17 @@ struct config_string ConfigureNamesString[] =
 		check_debug_io_direct, assign_debug_io_direct, NULL
 	},
 
+	{
+		{"oauth_validator_command", PGC_SIGHUP, CONN_AUTH_AUTH,
+			gettext_noop("Command to validate OAuth v2 bearer tokens."),
+			NULL,
+			GUC_SUPERUSER_ONLY | GUC_NOT_IN_SAMPLE
+		},
+		&oauth_validator_command,
+		"",
+		NULL, NULL, NULL
+	},
+
 	/* End-of-list marker */
 	{
 		{NULL, 0, 0, NULL, NULL}, NULL, NULL, NULL, NULL, NULL
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..53c999a69f 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,9 @@ typedef struct HbaLine
 	char	   *radiusidentifiers_s;
 	List	   *radiusports;
 	char	   *radiusports_s;
+	char	   *oauth_issuer;
+	char	   *oauth_scope;
+	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..5edab3b25a
--- /dev/null
+++ b/src/include/libpq/oauth.h
@@ -0,0 +1,24 @@
+/*-------------------------------------------------------------------------
+ *
+ * oauth.h
+ *	  Interface to libpq/auth-oauth.c
+ *
+ * Portions Copyright (c) 1996-2021, 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 char *oauth_validator_command;
+
+/* Implementation */
+extern const pg_be_sasl_mech pg_be_oauth_mech;
+
+#endif							/* PG_OAUTH_H */
diff --git a/src/include/libpq/sasl.h b/src/include/libpq/sasl.h
index 7a1f970cca..3f2c02b8f2 100644
--- a/src/include/libpq/sasl.h
+++ b/src/include/libpq/sasl.h
@@ -26,6 +26,14 @@
 #define PG_SASL_EXCHANGE_SUCCESS		1
 #define PG_SASL_EXCHANGE_FAILURE		2
 
+/*
+ * Maximum accepted size of SASL messages.
+ *
+ * The messages that the server or libpq generate are much smaller than this,
+ * but have some headroom.
+ */
+#define PG_MAX_SASL_MESSAGE_LENGTH	1024
+
 /*
  * Backend SASL mechanism callbacks.
  *
@@ -127,6 +135,9 @@ typedef struct pg_be_sasl_mech
 							 const char *input, int inputlen,
 							 char **output, int *outputlen,
 							 const char **logdetail);
+
+	/* The maximum size allowed for client SASLResponses. */
+	int			max_message_length;
 } pg_be_sasl_mech;
 
 /* Common implementation for auth.c */
diff --git a/src/tools/pgindent/typedefs.list b/src/tools/pgindent/typedefs.list
index 291c80e3ff..8119523cd9 100644
--- a/src/tools/pgindent/typedefs.list
+++ b/src/tools/pgindent/typedefs.list
@@ -3543,6 +3543,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