1: 386e7c4df31 = 1: 1f38ec8039b Move PG_MAX_AUTH_TOKEN_LENGTH to libpq/auth.h
2: b829f7a8ac7 = 2: 9f87ffea1c7 require_auth: prepare for multiple SASL mechanisms
3: f88f98df97d ! 3: bda684d19cc libpq: handle asynchronous actions during SASL
@@ src/interfaces/libpq/fe-auth.c: pg_SASL_continue(PGconn *conn, int payloadlen, b
+ * need to do is signal the caller.
+ */
+ *async = true;
-+ return STATUS_OK;
++
++ /*
++ * The mechanism may optionally generate some output to send before
++ * switching over to async auth, so continue onwards.
++ */
+ }
+
if (final && status == SASL_CONTINUE)
@@ src/interfaces/libpq/fe-auth.c: pg_fe_sendauth(AuthRequest areq, int payloadlen,
case AUTH_REQ_SASL_CONT:
case AUTH_REQ_SASL_FIN:
- if (conn->sasl_state == NULL)
-- {
+ {
- appendPQExpBufferStr(&conn->errorMessage,
- "fe_sendauth: invalid authentication request from server: AUTH_REQ_SASL_CONT without AUTH_REQ_SASL\n");
- return STATUS_ERROR;
@@ src/interfaces/libpq/fe-auth.c: pg_fe_sendauth(AuthRequest areq, int payloadlen,
- oldmsglen = conn->errorMessage.len;
- if (pg_SASL_continue(conn, payloadlen,
- (areq == AUTH_REQ_SASL_FIN)) != STATUS_OK)
- {
+- {
- /* Use this message if pg_SASL_continue didn't supply one */
- if (conn->errorMessage.len == oldmsglen)
+ bool final = false;
4: d96712cda1d ! 4: 3dc6dd3433c Add OAUTHBEARER SASL mechanism
@@ Commit message
Several TODOs:
- 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
- fill in documentation stubs
- support protocol "variants" implemented by major providers
- implement more helpful handling of HBA misconfigurations
@@ doc/src/sgml/installation.sgml: ninja install
## doc/src/sgml/libpq.sgml ##
+@@ doc/src/sgml/libpq.sgml: postgresql://%2Fvar%2Flib%2Fpostgresql/dbname
+
+
+
++
++ oauth
++
++
++ The server must request an OAuth bearer token from the client.
++
++
++
++
+
+ none
+
@@ doc/src/sgml/libpq.sgml: postgresql://%2Fvar%2Flib%2Fpostgresql/dbname
@@ doc/src/sgml/libpq.sgml: postgresql://%2Fvar%2Flib%2Fpostgresql/dbname
+ attacks" on OAuth clients.
+
+
-+ This standard handshake requires two separate network connections to the
-+ server per authentication attempt. To skip asking the server for a
-+ discovery document URL, you may set oauth_issuer to a
-+ /.well-known/ URI used for OAuth discovery. (In this
-+ case, it is recommended that
++ You may also explicitly set oauth_issuer to the
++ /.well-known/ URI used for OAuth discovery. In this
++ case, if the server asks for a different URL, the connection will fail,
++ but a custom OAuth flow
++ may be able to speed up the standard handshake by using previously
++ cached tokens. (In this case, it is recommended that
+ 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
@@ doc/src/sgml/libpq.sgml: postgresql://%2Fvar%2Flib%2Fpostgresql/dbname
+ An OAuth 2.0 client identifier, as issued by the authorization server.
+ If the PostgreSQL server
+ requests an OAuth token for the
-+ connection (and if no custom OAuth
-+ hook is installed to provide one), then this parameter must be
-+ set; otherwise, the connection will fail.
++ connection (and if no custom
++ OAuth hook is installed to provide one), then this parameter must
++ be set; otherwise, the connection will fail.
+
+
+
@@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
+ break;
+
+ case OAUTH_STEP_TOKEN_REQUEST:
-+ if (!handle_token_response(actx, &state->token))
++ if (!handle_token_response(actx, &conn->oauth_token))
+ goto error_return;
+
+ if (!actx->user_prompted)
@@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
+ actx->user_prompted = true;
+ }
+
-+ if (state->token)
++ if (conn->oauth_token)
+ break; /* done! */
+
+ /*
@@ src/interfaces/libpq/fe-auth-oauth-curl.c (new)
+ * 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);
++ } while (!conn->oauth_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;
++ return conn->oauth_token ? PGRES_POLLING_OK : PGRES_POLLING_READING;
+
+error_return:
+
@@ src/interfaces/libpq/fe-auth-oauth.c (new)
+ /* Any async authentication state should have been cleaned up already. */
+ Assert(!state->async_ctx);
+
-+ free(state->token);
+ free(state);
+}
+
@@ src/interfaces/libpq/fe-auth-oauth.c (new)
+/*
+ * 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.
++ * If discover is true, the initial response will contain a request for the
++ * server's required OAuth parameters (Sec. 4.3). Otherwise, conn->token must
++ * be set; it will be sent as the connection's bearer token.
+ *
+ * Returns the response as a null-terminated string, or NULL on error.
+ */
+static char *
-+client_initial_response(PGconn *conn, bool discover, const char *token)
++client_initial_response(PGconn *conn, bool discover)
+{
+ static const char *const resp_format = "n,," kvsep "auth=%s%s" kvsep kvsep;
+
+ PQExpBufferData buf;
+ const char *authn_scheme;
+ char *response = NULL;
++ const char *token = conn->oauth_token;
+
+ if (discover)
+ {
@@ src/interfaces/libpq/fe-auth-oauth.c (new)
+ */
+ authn_scheme = "Bearer ";
+
-+ /* We must have a token. */
++ /* conn->token must have been set in this case. */
+ 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?
-+ */
++ Assert(false);
+ libpq_append_conn_error(conn,
-+ "no OAuth token was set for the connection");
++ "internal error: no OAuth token was set for the connection");
+ return NULL;
+ }
+ }
@@ src/interfaces/libpq/fe-auth-oauth.c (new)
+/*
+ * Parses the server error result (RFC 7628, Sec. 3.2.2) contained in msg and
+ * stores any discovered openid_configuration and scope settings for the
-+ * connection. conn->oauth_want_retry will be set if the error status is
-+ * suitable for a second attempt.
++ * connection.
+ */
+static bool
+handle_oauth_sasl_error(PGconn *conn, const char *msg, int msglen)
@@ src/interfaces/libpq/fe-auth-oauth.c (new)
+ if (errmsg)
+ goto cleanup;
+
-+ /* 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;
@@ src/interfaces/libpq/fe-auth-oauth.c (new)
+
+ free(discovery_issuer);
+
-+ if (conn->oauth_discovery_uri)
-+ free(conn->oauth_discovery_uri);
-+
-+ conn->oauth_discovery_uri = ctx.discovery_uri;
-+ ctx.discovery_uri = NULL;
++ if (!conn->oauth_discovery_uri)
++ {
++ conn->oauth_discovery_uri = ctx.discovery_uri;
++ ctx.discovery_uri = NULL;
++ }
++ else
++ {
++ /* This must match the URI we'd previously determined. */
++ if (strcmp(conn->oauth_discovery_uri, ctx.discovery_uri) != 0)
++ {
++ libpq_append_conn_error(conn,
++ "server's discovery document has moved to %s (previous location was %s)",
++ ctx.discovery_uri,
++ conn->oauth_discovery_uri);
++ goto cleanup;
++ }
++ }
+ }
+
+ if (ctx.scope)
+ {
-+ if (conn->oauth_scope)
-+ free(conn->oauth_scope);
-+
-+ conn->oauth_scope = ctx.scope;
-+ ctx.scope = NULL;
++ /* Servers may not override a previously set oauth_scope. */
++ if (!conn->oauth_scope)
++ {
++ conn->oauth_scope = ctx.scope;
++ ctx.scope = NULL;
++ }
+ }
-+ /* TODO: missing error scope should clear any existing connection scope */
+
+ if (!ctx.status)
+ {
@@ src/interfaces/libpq/fe-auth-oauth.c (new)
+ goto cleanup;
+ }
+
-+ if (strcmp(ctx.status, "invalid_token") == 0)
++ 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.
++ * invalid_token is the only error code we'll automatically retry for;
++ * otherwise, just bail out now.
+ */
-+ if (conn->oauth_discovery_uri && conn->oauth_want_retry == PG_BOOL_UNKNOWN)
-+ conn->oauth_want_retry = PG_BOOL_YES;
++ libpq_append_conn_error(conn,
++ "server rejected OAuth bearer token: %s",
++ ctx.status);
++ goto cleanup;
+ }
-+ /* TODO: include status in hard failure message */
+
+ success = true;
+
@@ src/interfaces/libpq/fe-auth-oauth.c (new)
+ else if (status == PGRES_POLLING_OK)
+ {
+ /*
-+ * We already have a token, so copy it into the state. (We can't hold
++ * We already have a token, so copy it into the conn. (We can't hold
+ * onto the original string, since it may not be safe for us to free()
+ * it.)
+ */
@@ src/interfaces/libpq/fe-auth-oauth.c (new)
+ return PGRES_POLLING_FAILED;
+ }
+
-+ state->token = strdup(request->token);
-+ if (!state->token)
++ conn->oauth_token = strdup(request->token);
++ if (!conn->oauth_token)
+ {
+ libpq_append_conn_error(conn, "out of memory");
+ return PGRES_POLLING_FAILED;
@@ src/interfaces/libpq/fe-auth-oauth.c (new)
+ return PGRES_POLLING_OK;
+ }
+
-+ /* TODO: what if no altsock was set? */
++ /* The hook wants the client to poll the altsock. Make sure it set one. */
++ if (conn->altsock == PGINVALID_SOCKET)
++ {
++ libpq_append_conn_error(conn,
++ "user-defined OAuth flow did not provide a socket for polling");
++ return PGRES_POLLING_FAILED;
++ }
++
+ return status;
+}
+
@@ src/interfaces/libpq/fe-auth-oauth.c (new)
+ if (request.token)
+ {
+ /*
-+ * We already have a token, so copy it into the state. (We can't
++ * We already have a token, so copy it into the conn. (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)
++ conn->oauth_token = strdup(request.token);
++ if (!conn->oauth_token)
+ {
+ libpq_append_conn_error(conn, "out of memory");
+ goto fail;
@@ src/interfaces/libpq/fe-auth-oauth.c (new)
+ else
+ {
+#if USE_LIBCURL
-+ /*
-+ * 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.)
-+ */
++ /* Hand off to our built-in OAuth flow. */
+ conn->async_auth = pg_fe_run_oauth_flow;
+ conn->cleanup_async_auth = pg_fe_cleanup_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 with libcurl support");
@@ src/interfaces/libpq/fe-auth-oauth.c (new)
+ if (!setup_oauth_parameters(conn))
+ return SASL_FAILED;
+
-+ if (conn->oauth_discovery_uri)
++ if (conn->oauth_token)
+ {
+ /*
-+ * Decide whether we're using a user-provided OAuth flow, or
-+ * the one we have built in.
++ * A previous connection already fetched the token; we'll use
++ * it below.
++ */
++ }
++ else if (conn->oauth_discovery_uri)
++ {
++ /*
++ * We don't have a token, but we have a discovery URI already
++ * stored. 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)
++ if (conn->oauth_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.
++ * already cached), and we can use it immediately.
+ */
+ }
+ 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.
++ * Otherwise, we'll have to hand the connection over to
++ * our OAuth implementation.
++ *
++ * This could take a while, since it generally involves a
++ * user in the loop. To avoid consuming the server's
++ * authentication timeout, we'll continue this handshake
++ * to the end, so that the server can close its side of
++ * the connection. We'll open a second connection later
++ * once we've retrieved a token.
+ */
-+ Assert(conn->async_auth); /* should have been set
-+ * already */
-+ state->step = FE_OAUTH_REQUESTING_TOKEN;
-+ return SASL_ASYNC;
++ discover = true;
+ }
+ }
+ 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.
++ * If we don't have a token, and we don't have a discovery URI
++ * to be able to request a token, we ask the server for one
++ * explicitly.
+ */
+ 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);
++ /*
++ * Generate an initial response. This either contains a token, if
++ * we have one, or an empty discovery response which is doomed to
++ * fail.
++ */
++ *output = client_initial_response(conn, discover);
+ if (!*output)
+ return SASL_FAILED;
+
+ *outputlen = strlen(*output);
+ state->step = FE_OAUTH_BEARER_SENT;
+
++ if (conn->oauth_token)
++ {
++ /*
++ * For the purposes of require_auth, our side of
++ * authentication is done at this point; the server will
++ * either accept the connection or send an error. Unlike
++ * SCRAM, there is no additional server data to check upon
++ * success.
++ */
++ conn->client_finished_auth = true;
++ }
++
+ 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))
++ /*
++ * OAUTHBEARER does not make use of additional data with a
++ * successful SASL exchange, so we shouldn't get an
++ * AuthenticationSASLFinal message.
++ */
++ libpq_append_conn_error(conn,
++ "server sent unexpected additional OAuth data");
+ return SASL_FAILED;
++ }
+
+ /*
-+ * Respond with the required dummy message (RFC 7628, sec. 3.2.3).
++ * An error message was sent by the server. Respond with the
++ * required dummy message (RFC 7628, sec. 3.2.3).
+ */
+ *output = strdup(kvsep);
+ if (unlikely(!*output))
@@ src/interfaces/libpq/fe-auth-oauth.c (new)
+ }
+ *outputlen = strlen(*output); /* == 1 */
+
-+ state->step = FE_OAUTH_SERVER_ERROR;
-+ return SASL_CONTINUE;
++ /* Grab the settings from discovery. */
++ if (!handle_oauth_sasl_error(conn, input, inputlen))
++ return SASL_FAILED;
++
++ if (conn->oauth_token)
++ {
++ /*
++ * The server rejected our token. Continue onwards towards the
++ * expected FATAL message, but mark our state to catch any
++ * unexpected "success" from the server.
++ */
++ state->step = FE_OAUTH_SERVER_ERROR;
++ return SASL_CONTINUE;
++ }
++
++ if (!conn->async_auth)
++ {
++ /*
++ * No OAuth flow is set up yet. Did we get enough information
++ * from the server to create one?
++ */
++ if (!conn->oauth_discovery_uri)
++ {
++ libpq_append_conn_error(conn,
++ "server requires OAuth authentication, but no discovery metadata was provided");
++ return SASL_FAILED;
++ }
++
++ /* Yes. Set up the flow now. */
++ if (!setup_token_request(conn, state))
++ return SASL_FAILED;
++
++ if (conn->oauth_token)
++ {
++ /*
++ * A token was available in a custom flow's cache. Skip
++ * the asynchronous processing.
++ */
++ goto reconnect;
++ }
++ }
++
++ /*
++ * Time to retrieve a token. 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->step = FE_OAUTH_REQUESTING_TOKEN;
++ return SASL_ASYNC;
++
++ case FE_OAUTH_REQUESTING_TOKEN:
++
++ /*
++ * We've returned successfully from token retrieval. Double-check
++ * that we have what we need for the next connection.
++ */
++ if (!conn->oauth_token)
++ {
++ Assert(false); /* should have failed before this point! */
++ libpq_append_conn_error(conn,
++ "internal error: OAuth flow did not set a token");
++ return SASL_FAILED;
++ }
++
++ goto reconnect;
+
+ case FE_OAUTH_SERVER_ERROR:
+
@@ src/interfaces/libpq/fe-auth-oauth.c (new)
+ break;
+ }
+
++ Assert(false); /* should never get here */
++ return SASL_FAILED;
++
++reconnect:
++
++ /*
++ * Despite being a failure from the point of view of SASL, we have enough
++ * information to restart with a new connection.
++ */
++ libpq_append_conn_error(conn, "retrying connection with new bearer token");
++ conn->oauth_want_retry = true;
+ return SASL_FAILED;
+}
+
@@ src/interfaces/libpq/fe-auth-oauth.c (new)
+}
+
+/*
++ * Fully clears out any stored OAuth token. This is done proactively upon
++ * successful connection as well as during pqClosePGconn().
++ */
++void
++pqClearOAuthToken(PGconn *conn)
++{
++ if (!conn->oauth_token)
++ return;
++
++ explicit_bzero(conn->oauth_token, strlen(conn->oauth_token));
++ free(conn->oauth_token);
++ conn->oauth_token = NULL;
++}
++
++/*
+ * Returns true if the PGOAUTHDEBUG=UNSAFE flag is set in the environment.
+ */
+bool
@@ src/interfaces/libpq/fe-auth-oauth.h (new)
+enum fe_oauth_step
+{
+ FE_OAUTH_INIT,
-+ FE_OAUTH_REQUESTING_TOKEN,
+ FE_OAUTH_BEARER_SENT,
++ FE_OAUTH_REQUESTING_TOKEN,
+ FE_OAUTH_SERVER_ERROR,
+};
+
@@ src/interfaces/libpq/fe-auth-oauth.h (new)
+ enum fe_oauth_step step;
+
+ PGconn *conn;
-+ char *token;
-+
+ void *async_ctx;
+} fe_oauth_state;
+
+extern PostgresPollingStatusType pg_fe_run_oauth_flow(PGconn *conn);
+extern void pg_fe_cleanup_oauth_flow(PGconn *conn);
++extern void pqClearOAuthToken(PGconn *conn);
+extern bool oauth_unsafe_debugging_enabled(void);
+
+/* Mechanisms in fe-auth-oauth.c */
@@ src/interfaces/libpq/fe-auth.c: pg_SASL_init(PGconn *conn, int payloadlen, bool
}
if (!selected_mechanism)
+@@ src/interfaces/libpq/fe-auth.c: pg_SASL_init(PGconn *conn, int payloadlen, bool *async)
+
+ if (!allowed)
+ {
+- /*
+- * TODO: this is dead code until a second SASL mechanism is added;
+- * the connection can't have proceeded past check_expected_areq()
+- * if no SASL methods are allowed.
+- */
+- Assert(false);
+-
+ libpq_append_conn_error(conn, "authentication method requirement \"%s\" failed: server requested %s authentication",
+ conn->require_auth, selected_mechanism);
+ goto error;
@@ src/interfaces/libpq/fe-auth.c: PQchangePassword(PGconn *conn, const char *user, const char *passwd)
}
}
@@ src/interfaces/libpq/fe-connect.c
#include "libpq-int.h"
#include "mb/pg_wchar.h"
@@ src/interfaces/libpq/fe-connect.c: static const internalPQconninfoOption PQconninfoOptions[] = {
- "Load-Balance-Hosts", "", 8, /* sizeof("disable") = 8 */
- offsetof(struct pg_conn, load_balance_hosts)},
+ {"scram_server_key", NULL, NULL, NULL, "SCRAM-Server-Key", "D", SCRAM_MAX_KEY_LEN * 2,
+ offsetof(struct pg_conn, scram_server_key)},
+ /* OAuth v2 */
+ {"oauth_issuer", NULL, NULL, NULL,
@@ src/interfaces/libpq/fe-connect.c: pqDropServerData(PGconn *conn)
conn->write_failed = false;
free(conn->write_err_msg);
conn->write_err_msg = NULL;
-+ /* conn->oauth_want_retry = false; TODO */
++ conn->oauth_want_retry = false;
/*
* Cancel connections need to retain their be_pid and be_key across
+@@ src/interfaces/libpq/fe-connect.c: static inline void
+ fill_allowed_sasl_mechs(PGconn *conn)
+ {
+ /*---
+- * We only support one mechanism at the moment, so rather than deal with a
++ * We only support two mechanisms at the moment, so rather than deal with a
+ * linked list, conn->allowed_sasl_mechs is an array of static length. We
+ * rely on the compile-time assertion here to keep us honest.
+ *
+@@ src/interfaces/libpq/fe-connect.c: fill_allowed_sasl_mechs(PGconn *conn)
+ * - handle the new mechanism name in the require_auth portion of
+ * pqConnectOptions2(), below.
+ */
+- StaticAssertDecl(lengthof(conn->allowed_sasl_mechs) == 1,
++ StaticAssertDecl(lengthof(conn->allowed_sasl_mechs) == 2,
+ "fill_allowed_sasl_mechs() must be updated when resizing conn->allowed_sasl_mechs[]");
+
+ conn->allowed_sasl_mechs[0] = &pg_scram_mech;
++ conn->allowed_sasl_mechs[1] = &pg_oauth_mech;
+ }
+
+ /*
+@@ src/interfaces/libpq/fe-connect.c: pqConnectOptions2(PGconn *conn)
+ {
+ mech = &pg_scram_mech;
+ }
++ else if (strcmp(method, "oauth") == 0)
++ {
++ mech = &pg_oauth_mech;
++ }
+
+ /*
+ * Final group: meta-options.
@@ src/interfaces/libpq/fe-connect.c: keep_going: /* We will come back to here until there is
- /* Check to see if we should mention pgpassfile */
- pgpassfileWarning(conn);
+ conn->inStart = conn->inCursor;
+ if (res != STATUS_OK)
++ {
+ /*
+ * 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)
++ if (conn->sasl == &pg_oauth_mech && conn->oauth_want_retry)
+ {
-+ /* Only allow retry once. */
-+ conn->oauth_want_retry = PG_BOOL_NO;
+ need_new_connection = true;
+ goto keep_going;
+ }
+
- CONNECTION_FAILED();
+ goto error_return;
++ }
+
+ /*
+ * Just make sure that any data sent by pg_fe_sendauth is
+@@ src/interfaces/libpq/fe-connect.c: keep_going: /* We will come back to here until there is
+ }
}
- else if (beresp == PqMsg_NegotiateProtocolVersion)
+
++ /* Don't hold onto any OAuth tokens longer than necessary. */
++ pqClearOAuthToken(conn);
++
+ /*
+ * For non cancel requests we can release the address list
+ * now. For cancel requests we never actually resolve
@@ src/interfaces/libpq/fe-connect.c: freePGconn(PGconn *conn)
- free(conn->rowBuf);
- free(conn->target_session_attrs);
free(conn->load_balance_hosts);
+ free(conn->scram_client_key);
+ free(conn->scram_server_key);
+ free(conn->oauth_issuer);
+ free(conn->oauth_issuer_id);
+ free(conn->oauth_discovery_uri);
@@ src/interfaces/libpq/fe-connect.c: freePGconn(PGconn *conn)
termPQExpBuffer(&conn->errorMessage);
termPQExpBuffer(&conn->workBuffer);
+@@ src/interfaces/libpq/fe-connect.c: pqClosePGconn(PGconn *conn)
+ conn->asyncStatus = PGASYNC_IDLE;
+ conn->xactStatus = PQTRANS_IDLE;
+ conn->pipelineStatus = PQ_PIPELINE_OFF;
++ pqClearOAuthToken(conn);
+ pqClearAsyncResult(conn); /* deallocate result */
+ pqClearConnErrorState(conn);
+
## src/interfaces/libpq/libpq-fe.h ##
@@ src/interfaces/libpq/libpq-fe.h: extern "C"
@@ src/interfaces/libpq/libpq-int.h: struct pg_conn
+ 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? */
++ char *oauth_token; /* access token */
++ bool oauth_want_retry; /* should we retry on failure? */
+
/* Optional file to write trace info to */
FILE *Pfdebug;
int traceFlags;
+@@ src/interfaces/libpq/libpq-int.h: struct pg_conn
+ * the server? */
+ uint32 allowed_auth_methods; /* bitmask of acceptable AuthRequest
+ * codes */
+- const pg_fe_sasl_mech *allowed_sasl_mechs[1]; /* and acceptable SASL
++ const pg_fe_sasl_mech *allowed_sasl_mechs[2]; /* and acceptable SASL
+ * mechanisms */
+ bool client_finished_auth; /* have we finished our half of the
+ * authentication exchange? */
## src/interfaces/libpq/meson.build ##
@@
@@ src/makefiles/meson.build: pgxs_deps = {
'libxslt': libxslt,
'llvm': llvm,
+ ## src/test/authentication/t/001_password.pl ##
+@@ src/test/authentication/t/001_password.pl: $node->connect_fails(
+ $node->connect_fails(
+ "user=scram_role require_auth=!scram-sha-256",
+ "SCRAM authentication forbidden, fails with SCRAM auth",
+- expected_stderr => qr/server requested SASL authentication/);
++ expected_stderr => qr/server requested SCRAM-SHA-256 authentication/);
+ $node->connect_fails(
+ "user=scram_role require_auth=!password,!md5,!scram-sha-256",
+ "multiple authentication types forbidden, fails with SCRAM auth",
+- expected_stderr => qr/server requested SASL authentication/);
++ expected_stderr => qr/server requested SCRAM-SHA-256 authentication/);
+
+ # Test that bad passwords are rejected.
+ $ENV{"PGPASSWORD"} = 'badpass';
+@@ src/test/authentication/t/001_password.pl: $node->connect_fails(
+ "user=scram_role require_auth=!scram-sha-256",
+ "password authentication forbidden, fails with SCRAM auth",
+ expected_stderr =>
+- qr/authentication method requirement "!scram-sha-256" failed: server requested SASL authentication/
++ qr/authentication method requirement "!scram-sha-256" failed: server requested SCRAM-SHA-256 authentication/
+ );
+ $node->connect_fails(
+ "user=scram_role require_auth=!password,!md5,!scram-sha-256",
+ "multiple authentication types forbidden, fails with SCRAM auth",
+ expected_stderr =>
+- qr/authentication method requirement "!password,!md5,!scram-sha-256" failed: server requested SASL authentication/
++ qr/authentication method requirement "!password,!md5,!scram-sha-256" failed: server requested SCRAM-SHA-256 authentication/
+ );
+
+ # Test SYSTEM_USER <> NULL with parallel workers.
+
## src/test/modules/Makefile ##
@@ src/test/modules/Makefile: SUBDIRS = \
dummy_index_am \
@@ src/test/modules/oauth_validator/oauth_hook_client.c (new)
+static PostgresPollingStatusType async_cb(PGconn *conn,
+ PGoauthBearerRequest *req,
+ pgsocket *altsock);
++static PostgresPollingStatusType misbehave_cb(PGconn *conn,
++ PGoauthBearerRequest *req,
++ pgsocket *altsock);
+
+static void
+usage(char *argv[])
@@ src/test/modules/oauth_validator/oauth_hook_client.c (new)
+ printf(" -h, --help show this message\n");
+ printf(" --expected-scope SCOPE fail if received scopes do not match SCOPE\n");
+ printf(" --expected-uri URI fail if received configuration link does not match URI\n");
++ printf(" --misbehave=MODE have the hook fail required postconditions\n"
++ " (MODEs: no-hook, fail-async, no-token, no-socket)\n");
+ printf(" --no-hook don't install OAuth hooks (connection will fail)\n");
+ printf(" --hang-forever don't ever return a token (combine with connect_timeout)\n");
+ printf(" --token TOKEN use the provided TOKEN value\n");
@@ src/test/modules/oauth_validator/oauth_hook_client.c (new)
+static bool hang_forever = false;
+static const char *expected_uri = NULL;
+static const char *expected_scope = NULL;
++static const char *misbehave_mode = NULL;
+static char *token = NULL;
+
+int
@@ src/test/modules/oauth_validator/oauth_hook_client.c (new)
+ {"no-hook", no_argument, NULL, 1002},
+ {"token", required_argument, NULL, 1003},
+ {"hang-forever", no_argument, NULL, 1004},
++ {"misbehave", required_argument, NULL, 1005},
+ {0}
+ };
+
@@ src/test/modules/oauth_validator/oauth_hook_client.c (new)
+ hang_forever = true;
+ break;
+
++ case 1005: /* --misbehave */
++ misbehave_mode = optarg;
++ break;
++
+ default:
+ usage(argv);
+ return 1;
@@ src/test/modules/oauth_validator/oauth_hook_client.c (new)
+ return 1;
+ }
+
++ if (misbehave_mode)
++ {
++ if (strcmp(misbehave_mode, "no-hook") != 0)
++ req->async = misbehave_cb;
++ return 1;
++ }
++
+ if (expected_uri)
+ {
+ if (!req->openid_configuration)
@@ src/test/modules/oauth_validator/oauth_hook_client.c (new)
+
+ req->token = token;
+ return PGRES_POLLING_OK;
++}
++
++static PostgresPollingStatusType
++misbehave_cb(PGconn *conn, PGoauthBearerRequest *req, pgsocket *altsock)
++{
++ if (strcmp(misbehave_mode, "fail-async") == 0)
++ {
++ /* Just fail "normally". */
++ return PGRES_POLLING_FAILED;
++ }
++ else if (strcmp(misbehave_mode, "no-token") == 0)
++ {
++ /* Callbacks must assign req->token before returning OK. */
++ return PGRES_POLLING_OK;
++ }
++ else if (strcmp(misbehave_mode, "no-socket") == 0)
++ {
++ /* Callbacks must assign *altsock before asking for polling. */
++ return PGRES_POLLING_READING;
++ }
++ else
++ {
++ fprintf(stderr, "unrecognized --misbehave mode: %s\n", misbehave_mode);
++ exit(1);
++ }
+}
## src/test/modules/oauth_validator/t/001_server.pl (new) ##
@@ src/test/modules/oauth_validator/t/001_server.pl (new)
+ 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\)@
+);
+
++# Test require_auth settings against OAUTHBEARER.
++my @cases = (
++ { require_auth => "oauth" },
++ { require_auth => "oauth,scram-sha-256" },
++ { require_auth => "password,oauth" },
++ { require_auth => "none,oauth" },
++ { require_auth => "!scram-sha-256" },
++ { require_auth => "!none" },
++
++ {
++ require_auth => "!oauth",
++ failure => qr/server requested OAUTHBEARER authentication/
++ },
++ {
++ require_auth => "scram-sha-256",
++ failure => qr/server requested OAUTHBEARER authentication/
++ },
++ {
++ require_auth => "!password,!oauth",
++ failure => qr/server requested OAUTHBEARER authentication/
++ },
++ {
++ require_auth => "none",
++ failure => qr/server requested SASL authentication/
++ },
++ {
++ require_auth => "!oauth,!scram-sha-256",
++ failure => qr/server requested SASL authentication/
++ });
++
++$user = "test";
++foreach my $c (@cases)
++{
++ my $connstr =
++ "user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635 require_auth=$c->{'require_auth'}";
++
++ if (defined $c->{'failure'})
++ {
++ $node->connect_fails(
++ $connstr,
++ "require_auth=$c->{'require_auth'} fails",
++ expected_stderr => $c->{'failure'});
++ }
++ else
++ {
++ $node->connect_ok(
++ $connstr,
++ "require_auth=$c->{'require_auth'} succeeds",
++ expected_stderr =>
++ qr@Visit https://example\.com/ and enter the code: postgresuser@
++ );
++ }
++}
++
+# 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
@@ src/test/modules/oauth_validator/t/001_server.pl (new)
+ " !\"#\$%&\\'()*+,-./0123456789:;<=>?\@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\\\]^_`abcdefghijklmnopqrstuvwxyz{|}~";
+
+$node->connect_ok(
-+ "user=$user dbname=postgres oauth_issuer=$issuer/alternate oauth_client_id='$vschars_esc'",
++ "user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id='$vschars_esc'",
+ "escapable characters: client_id",
+ expected_stderr =>
-+ qr@Visit https://example\.org/ and enter the code: postgresuser@);
++ qr@Visit https://example\.com/ 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'",
++ "user=$user dbname=postgres oauth_issuer=$issuer 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@);
++ qr@Visit https://example\.com/ and enter the code: postgresuser@);
+
+#
+# Further tests rely on support for specific behaviors in oauth_server.py. To
@@ src/test/modules/oauth_validator/t/001_server.pl (new)
+$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"
++local all testalt oauth validator=fail_validator issuer="$issuer/.well-known/oauth-authorization-server/alternate" scope="openid postgres alt"
+});
+$node->restart;
+
@@ src/test/modules/oauth_validator/t/002_client.pl (new)
+ flags => ["--hang-forever"],
+ expected_stderr => qr/failed: timeout expired/);
+
++# Test various misbehaviors of the client hook.
++my @cases = (
++ {
++ flag => "--misbehave=no-hook",
++ expected_error =>
++ qr/user-defined OAuth flow provided neither a token nor an async callback/,
++ },
++ {
++ flag => "--misbehave=fail-async",
++ expected_error => qr/user-defined OAuth flow failed/,
++ },
++ {
++ flag => "--misbehave=no-token",
++ expected_error => qr/user-defined OAuth flow did not provide a token/,
++ },
++ {
++ flag => "--misbehave=no-socket",
++ expected_error =>
++ qr/user-defined OAuth flow did not provide a socket for polling/,
++ });
++
++foreach my $c (@cases)
++{
++ test(
++ "hook misbehavior: $c->{'flag'}",
++ flags => [ $c->{'flag'} ],
++ expected_stderr => $c->{'expected_error'});
++}
++
+done_testing();
## src/test/modules/oauth_validator/t/OAuth/Server.pm (new) ##
5: 18507c6978b < -: ----------- squash! Add OAUTHBEARER SASL mechanism
6: 8e82059700b = 5: 97e0a2aae26 XXX fix libcurl link error
7: 5339b3f2617 ! 6: db0167009b9 DO NOT MERGE: Add pytest suite for OAuth
@@ src/test/python/client/conftest.py (new)
+ client.start()
+
+ sock, _ = server_socket.accept()
++ sock.settimeout(BLOCKING_TIMEOUT)
+ return sock, client
+
+ yield factory
@@ src/test/python/client/test_oauth.py (new)
+ )
+
+
++def handle_discovery_connection(sock, discovery=None, *, response=None):
++ """
++ Helper for all tests that expect an initial discovery connection from the
++ client. The provided discovery URI will be used in a standard error response
++ from the server (or response may be set, to provide a custom dictionary),
++ and the SASL exchange will be failed.
++
++ By default, the client is expected to complete the entire handshake. Set
++ finish to False if the client should immediately disconnect when it receives
++ the error response.
++ """
++ if response is None:
++ response = {"status": "invalid_token"}
++ if discovery is not None:
++ response["openid-configuration"] = discovery
++
++ with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
++ # Initiate a handshake.
++ initial = start_oauth_handshake(conn)
++
++ # For discovery, the client should send an empty auth header. See RFC
++ # 7628, Sec. 4.3.
++ auth = get_auth_value(initial)
++ assert auth == b""
++
++ # The discovery handshake is doomed to fail.
++ fail_oauth_handshake(conn, response)
++
++
+class RawResponse(str):
+ """
+ Returned by registered endpoint callbacks to take full control of the
@@ src/test/python/client/test_oauth.py (new)
+ libpq.PQsetAuthDataHook(None)
+
+
-+@pytest.mark.parametrize("success", [True, False])
++@pytest.mark.parametrize(
++ "success, abnormal_failure",
++ [
++ pytest.param(True, False, id="success"),
++ pytest.param(False, False, id="normal failure"),
++ pytest.param(False, True, id="abnormal failure"),
++ ],
++)
+@pytest.mark.parametrize("secret", [None, "", "hunter2"])
+@pytest.mark.parametrize("scope", [None, "", "openid email"])
+@pytest.mark.parametrize("retries", [0, 1])
@@ src/test/python/client/test_oauth.py (new)
+ secret,
+ auth_data_cb,
+ success,
++ abnormal_failure,
+):
+ client_id = secrets.token_hex()
+ openid_provider.content_type = content_type
@@ src/test/python/client/test_oauth.py (new)
+ "token_endpoint", "POST", "/token", token_endpoint
+ )
+
++ # First connection is a discovery request, which should result in the above
++ # endpoints being called.
++ with sock:
++ handle_discovery_connection(sock, openid_provider.discovery_uri)
++
++ # Client should reconnect.
++ sock, _ = accept()
+ with sock:
+ with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
-+ # Initiate a handshake, which should result in the above endpoints
-+ # being called.
+ initial = start_oauth_handshake(conn)
+
+ # Validate and accept the token.
@@ src/test/python/client/test_oauth.py (new)
+ assert auth == f"Bearer {access_token}".encode("ascii")
+
+ if success:
-+ pq3.send(conn, pq3.types.AuthnRequest, type=pq3.authn.SASLFinal)
+ finish_handshake(conn)
+
++ elif abnormal_failure:
++ # Send an empty error response, which should result in a
++ # mechanism-level failure in the client. This test ensures that
++ # the client doesn't try a third connection for this case.
++ expected_error = "server sent error response without a status"
++ fail_oauth_handshake(conn, {})
++
+ else:
+ # Simulate token validation failure.
+ resp = {
@@ src/test/python/client/test_oauth.py (new)
+ "openid-configuration": openid_provider.discovery_uri,
+ }
+ expected_error = "test token validation failure"
-+
+ fail_oauth_handshake(conn, resp, errmsg=expected_error)
+
+ if retries:
@@ src/test/python/client/test_oauth.py (new)
+
+ sock, client = accept(**kwargs)
+
-+ if server_discovery:
-+ with sock:
-+ with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
-+ initial = start_oauth_handshake(conn)
-+
-+ # For discovery, the client should send an empty auth header.
-+ # See RFC 7628, Sec. 4.3.
-+ auth = get_auth_value(initial)
-+ assert auth == b""
-+
-+ # Always fail the discovery exchange.
-+ fail_oauth_handshake(
-+ conn,
-+ {
-+ "status": "invalid_token",
-+ "openid-configuration": discovery_uri,
-+ },
-+ )
-+
-+ # Expect the client to connect again.
-+ sock, client = accept()
++ with sock:
++ handle_discovery_connection(sock, discovery_uri)
+
++ # Expect the client to connect again.
++ sock, _ = accept()
+ with sock:
+ with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+ initial = start_oauth_handshake(conn)
@@ src/test/python/client/test_oauth.py (new)
+ auth = get_auth_value(initial)
+ assert auth == f"Bearer {access_token}".encode("ascii")
+
-+ pq3.send(conn, pq3.types.AuthnRequest, type=pq3.authn.SASLFinal)
+ finish_handshake(conn)
+
+
@@ src/test/python/client/test_oauth.py (new)
+ "{issuer}",
+ "/.well-known/oauth-authorization-server/",
+ None,
-+ id="extra empty segment",
++ id="extra empty segment (no path)",
++ ),
++ pytest.param(
++ "{issuer}/path",
++ "/.well-known/oauth-authorization-server/path/",
++ None,
++ id="extra empty segment (with path)",
+ ),
+ pytest.param(
+ "{issuer}",
@@ src/test/python/client/test_oauth.py (new)
+ kwargs.update(oauth_issuer=discovery_uri)
+
+ sock, client = accept(**kwargs)
-+
-+ if server_discovery:
-+ with sock:
-+ with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
-+ initial = start_oauth_handshake(conn)
-+
-+ # For discovery, the client should send an empty auth header.
-+ # See RFC 7628, Sec. 4.3.
-+ auth = get_auth_value(initial)
-+ assert auth == b""
-+
-+ # Always fail the discovery exchange.
-+ resp = {
-+ "status": "invalid_token",
-+ "openid-configuration": discovery_uri,
-+ }
-+ pq3.send(
-+ conn,
-+ pq3.types.AuthnRequest,
-+ type=pq3.authn.SASLContinue,
-+ body=json.dumps(resp).encode("utf-8"),
-+ )
-+
-+ # FIXME: the client disconnects at this point; it'd be nicer if
-+ # it completed the exchange.
-+
-+ # The client should not reconnect.
-+
-+ else:
-+ expect_disconnected_handshake(sock)
++ with sock:
++ if expected_error and not server_discovery:
++ # If the client already knows the URL, it should disconnect as soon
++ # as it realizes it's not valid.
++ expect_disconnected_handshake(sock)
++ else:
++ # Otherwise, it should complete the connection.
++ handle_discovery_connection(sock, discovery_uri)
++
++ # The client should not reconnect.
+
+ if expected_error is None:
+ if server_discovery:
@@ src/test/python/client/test_oauth.py (new)
+ the client to have an oauth_issuer set so that it doesn't try to go through
+ discovery.
+ """
-+ with sock:
-+ with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
-+ # Initiate a handshake.
-+ startup = pq3.recv1(conn, cls=pq3.Startup)
-+ assert startup.proto == pq3.protocol(3, 0)
++ with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
++ # Initiate a handshake.
++ startup = pq3.recv1(conn, cls=pq3.Startup)
++ assert startup.proto == pq3.protocol(3, 0)
+
-+ pq3.send(
-+ conn,
-+ pq3.types.AuthnRequest,
-+ type=pq3.authn.SASL,
-+ body=[b"OAUTHBEARER", b""],
-+ )
++ pq3.send(
++ conn,
++ pq3.types.AuthnRequest,
++ type=pq3.authn.SASL,
++ body=[b"OAUTHBEARER", b""],
++ )
+
-+ # The client should disconnect at this point.
-+ assert not conn.read(1), "client sent unexpected data"
++ # The client should disconnect at this point.
++ assert not conn.read(1), "client sent unexpected data"
+
+
+@pytest.mark.parametrize(
@@ src/test/python/client/test_oauth.py (new)
+ del params[k]
+
+ sock, client = accept(**params)
-+ expect_disconnected_handshake(sock)
++ with sock:
++ expect_disconnected_handshake(sock)
+
+ expected_error = "oauth_issuer and oauth_client_id are not both set"
+ with pytest.raises(psycopg2.OperationalError, match=expected_error):
@@ src/test/python/client/test_oauth.py (new)
+ "token_endpoint", "POST", "/token", token_endpoint
+ )
+
++ # First connection is a discovery request, which should result in the above
++ # endpoints being called.
++ with sock:
++ handle_discovery_connection(sock, openid_provider.discovery_uri)
++
++ # Second connection sends the token.
++ sock, _ = accept()
+ with sock:
+ with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
-+ # Initiate a handshake, which should result in the above endpoints
-+ # being called.
+ initial = start_oauth_handshake(conn)
+
+ # Validate and accept the token.
+ auth = get_auth_value(initial)
+ assert auth == f"Bearer {access_token}".encode("ascii")
+
-+ pq3.send(conn, pq3.types.AuthnRequest, type=pq3.authn.SASLFinal)
+ finish_handshake(conn)
+
+
@@ src/test/python/client/test_oauth.py (new)
+ attempts = 0
+ last_retry = None
+ retry_lock = threading.Lock()
++ token_sent = threading.Event()
+
+ def token_endpoint(headers, params):
+ now = time.monotonic()
@@ src/test/python/client/test_oauth.py (new)
+
+ return 400, {"error": error_code}
+
-+ # Successfully finish the request by sending the access bearer token.
++ # Successfully finish the request by sending the access bearer token,
++ # and signal the main thread to continue.
+ resp = {
+ "access_token": access_token,
+ "token_type": "bearer",
+ }
++ token_sent.set()
+
+ return 200, resp
+
@@ src/test/python/client/test_oauth.py (new)
+ "token_endpoint", "POST", "/token", token_endpoint
+ )
+
++ # First connection is a discovery request, which should result in the above
++ # endpoints being called.
++ with sock:
++ handle_discovery_connection(sock, openid_provider.discovery_uri)
++
++ # At this point the client is talking to the authorization server. Wait for
++ # that to succeed so we don't run into the accept() timeout.
++ token_sent.wait()
++
++ # Client should reconnect and send the token.
++ sock, _ = accept()
+ with sock:
+ with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
-+ # Initiate a handshake, which should result in the above endpoints
-+ # being called.
+ initial = start_oauth_handshake(conn)
+
+ # Validate and accept the token.
+ auth = get_auth_value(initial)
+ assert auth == f"Bearer {access_token}".encode("ascii")
+
-+ pq3.send(conn, pq3.types.AuthnRequest, type=pq3.authn.SASLFinal)
+ finish_handshake(conn)
+
+
@@ src/test/python/client/test_oauth.py (new)
+ auth_data_cb.impl = bearer_hook
+
+ # Now drive the server side.
++ if retries >= 0:
++ # First connection is a discovery request, which should result in the
++ # hook being invoked.
++ with sock:
++ handle_discovery_connection(sock, discovery_uri)
++
++ # Client should reconnect to send the token.
++ sock, _ = accept()
++
+ with sock:
+ with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+ # Initiate a handshake, which should result in our custom callback
@@ src/test/python/client/test_oauth.py (new)
+ auth = get_auth_value(initial)
+ assert auth == f"Bearer {access_token}".encode("ascii")
+
-+ pq3.send(conn, pq3.types.AuthnRequest, type=pq3.authn.SASLFinal)
+ finish_handshake(conn)
+
+ # Check the data provided to the hook.
@@ src/test/python/client/test_oauth.py (new)
+ "token_endpoint", "POST", "/token", token_endpoint
+ )
+
-+ expect_disconnected_handshake(sock)
++ with sock:
++ handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+ # Now make sure the client correctly failed.
+ with pytest.raises(psycopg2.OperationalError, match=error_pattern):
@@ src/test/python/client/test_oauth.py (new)
+ "token_endpoint", "POST", "/token", token_endpoint
+ )
+
-+ expect_disconnected_handshake(sock)
++ with sock:
++ handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+ # Now make sure the client correctly failed.
+ if bad_value is Missing:
@@ src/test/python/client/test_oauth.py (new)
+ "token_endpoint", "POST", "/token", token_endpoint
+ )
+
-+ expect_disconnected_handshake(sock)
++ with sock:
++ handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+ # Now make sure the client correctly failed.
+ with pytest.raises(psycopg2.OperationalError, match=error_pattern):
@@ src/test/python/client/test_oauth.py (new)
+ "token_endpoint", "POST", "/token", token_endpoint
+ )
+
-+ expect_disconnected_handshake(sock)
++ with sock:
++ handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+ # Now make sure the client correctly failed.
+ error_pattern = "failed to parse access token response: "
@@ src/test/python/client/test_oauth.py (new)
+ fail_resp["scope"] = scope
+
+ with sock:
-+ with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
-+ initial = start_oauth_handshake(conn)
-+
-+ # For discovery, the client should send an empty auth header. See
-+ # RFC 7628, Sec. 4.3.
-+ auth = get_auth_value(initial)
-+ assert auth == b""
-+
-+ # Always fail the first SASL exchange.
-+ fail_oauth_handshake(conn, fail_resp)
++ handle_discovery_connection(sock, response=fail_resp)
+
+ # The client will connect to us a second time, using the parameters we sent
+ # it.
@@ src/test/python/client/test_oauth.py (new)
+ assert auth == f"Bearer {access_token}".encode("ascii")
+
+ if success:
-+ pq3.send(conn, pq3.types.AuthnRequest, type=pq3.authn.SASLFinal)
+ finish_handshake(conn)
+
+ else:
@@ src/test/python/client/test_oauth.py (new)
+ failing_discovery_handler,
+ )
+
-+ expect_disconnected_handshake(sock)
++ with sock:
++ handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+ with pytest.raises(psycopg2.OperationalError, match=expected_error):
+ client.check_completed()
@@ src/test/python/client/test_oauth.py (new)
+ dict(
+ fields=[b"SFATAL", b"C28000", b"Mexpected error message", b""],
+ ),
-+ "expected error message",
++ "server rejected OAuth bearer token: invalid_request",
+ id="standard server error: invalid_request",
+ ),
+ pytest.param(
@@ src/test/python/client/test_oauth.py (new)
+ id="standard server error: invalid_token without discovery URI",
+ ),
+ pytest.param(
-+ {"status": "invalid_request"},
++ {"status": "invalid_token", "openid-configuration": ""},
+ pq3.types.AuthnRequest,
+ dict(type=pq3.authn.SASLContinue, body=b""),
+ "server sent additional OAuth data",
+ id="broken server: additional challenge after error",
+ ),
+ pytest.param(
-+ {"status": "invalid_request"},
++ {"status": "invalid_token", "openid-configuration": ""},
+ pq3.types.AuthnRequest,
+ dict(type=pq3.authn.SASLFinal),
+ "server sent additional OAuth data",
+ id="broken server: SASL success after error",
+ ),
+ pytest.param(
-+ {"status": "invalid_request"},
++ {"status": "invalid_token", "openid-configuration": ""},
+ pq3.types.AuthnRequest,
+ dict(type=pq3.authn.SASL, body=[b"OAUTHBEARER", b""]),
+ "duplicate SASL authentication request",
@@ src/test/python/client/test_oauth.py (new)
+ ),
+ ],
+)
-+def test_oauth_server_error(accept, sasl_err, resp_type, resp_payload, expected_error):
++def test_oauth_server_error(
++ accept, auth_data_cb, sasl_err, resp_type, resp_payload, expected_error
++):
++ wkuri = f"https://256.256.256.256/.well-known/openid-configuration"
+ sock, client = accept(
-+ oauth_issuer="https://example.com",
++ oauth_issuer=wkuri,
+ oauth_client_id="some-id",
+ )
+
++ def bearer_hook(typ, pgconn, request):
++ """
++ Implementation of the PQAuthDataHook, which returns a token directly so
++ we don't need an openid_provider instance.
++ """
++ assert typ == PQAUTHDATA_OAUTH_BEARER_TOKEN
++ request.token = secrets.token_urlsafe().encode()
++ return 1
++
++ auth_data_cb.impl = bearer_hook
++
+ with sock:
+ with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
+ start_oauth_handshake(conn)
+
+ # Ignore the client data. Return an error "challenge".
++ if "openid-configuration" in sasl_err:
++ sasl_err["openid-configuration"] = wkuri
++
+ resp = json.dumps(sasl_err)
+ resp = resp.encode("utf-8")
+
@@ src/test/python/client/test_oauth.py (new)
+ assert pkt.type == pq3.types.PasswordMessage
+ assert pkt.payload == b"\x01"
+
-+ # Now fail the SASL exchange (in either a valid way, or an invalid
-+ # one, depending on the test).
++ # Now fail the SASL exchange (in either a valid way, or an
++ # invalid one, depending on the test).
+ pq3.send(conn, resp_type, **resp_payload)
+
+ with pytest.raises(psycopg2.OperationalError, match=expected_error):
@@ src/test/python/client/test_oauth.py (new)
+ "token_endpoint", "POST", "/token", token_endpoint
+ )
+
-+ expect_disconnected_handshake(sock)
++ with sock:
++ handle_discovery_connection(sock, openid_provider.discovery_uri)
+
+ expected_error = "slow_down interval overflow"
+ with pytest.raises(psycopg2.OperationalError, match=expected_error):
@@ src/test/python/client/test_oauth.py (new)
+ # No provider callbacks necessary; we should fail immediately.
+
+ with sock:
-+ with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
-+ initial = start_oauth_handshake(conn)
-+
-+ resp = {
-+ "status": "invalid_token",
-+ "openid-configuration": to_http(openid_provider.discovery_uri),
-+ }
-+ pq3.send(
-+ conn,
-+ pq3.types.AuthnRequest,
-+ type=pq3.authn.SASLContinue,
-+ body=json.dumps(resp).encode("utf-8"),
-+ )
-+
-+ # FIXME: the client disconnects at this point; it'd be nicer if
-+ # it completed the exchange.
++ handle_discovery_connection(sock, to_http(openid_provider.discovery_uri))
+
+ expected_error = r'OAuth discovery URI ".*" must use HTTPS'
+ with pytest.raises(psycopg2.OperationalError, match=expected_error):
++ client.check_completed()
++
++
++@pytest.mark.parametrize("auth_type", [pq3.authn.OK, pq3.authn.SASLFinal])
++def test_discovery_incorrectly_permits_connection(accept, auth_type):
++ """
++ Incorrectly responds to a client's discovery request with AuthenticationOK
++ or AuthenticationSASLFinal. require_auth=oauth should catch the former, and
++ the mechanism itself should catch the latter.
++ """
++ issuer = "https://256.256.256.256"
++ sock, client = accept(
++ oauth_issuer=issuer,
++ oauth_client_id=secrets.token_hex(),
++ require_auth="oauth",
++ )
++
++ with sock:
++ with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
++ initial = start_oauth_handshake(conn)
++
++ auth = get_auth_value(initial)
++ assert auth == b""
++
++ # Incorrectly log the client in. It should immediately disconnect.
++ pq3.send(conn, pq3.types.AuthnRequest, type=auth_type)
++ assert not conn.read(1), "client sent unexpected data"
++
++ if auth_type == pq3.authn.OK:
++ expected_error = "server did not complete authentication"
++ else:
++ expected_error = "server sent unexpected additional OAuth data"
++
++ with pytest.raises(psycopg2.OperationalError, match=expected_error):
++ client.check_completed()
++
++
++def test_no_discovery_url_provided(accept):
++ """
++ Tests what happens when the client doesn't know who to contact and the
++ server doesn't tell it.
++ """
++ issuer = "https://256.256.256.256"
++ sock, client = accept(
++ oauth_issuer=issuer,
++ oauth_client_id=secrets.token_hex(),
++ )
++
++ with sock:
++ handle_discovery_connection(sock, discovery=None)
++
++ expected_error = "no discovery metadata was provided"
++ with pytest.raises(psycopg2.OperationalError, match=expected_error):
++ client.check_completed()
++
++
++@pytest.mark.parametrize("change_between_connections", [False, True])
++def test_discovery_url_changes(accept, openid_provider, change_between_connections):
++ """
++ Ensures that the client complains if the server agrees on the issuer, but
++ disagrees on the discovery URL to be used.
++ """
++
++ # Set up our provider callbacks.
++ # NOTE that these callbacks will be called on a background thread. Don't do
++ # any unprotected state mutation here.
++
++ def authorization_endpoint(headers, params):
++ resp = {
++ "device_code": "DEV",
++ "user_code": "USER",
++ "interval": 0,
++ "verification_uri": "https://example.org",
++ "expires_in": 5,
++ }
++
++ return 200, resp
++
++ openid_provider.register_endpoint(
++ "device_authorization_endpoint", "POST", "/device", authorization_endpoint
++ )
++
++ def token_endpoint(headers, params):
++ resp = {
++ "access_token": secrets.token_urlsafe(),
++ "token_type": "bearer",
++ }
++
++ return 200, resp
++
++ openid_provider.register_endpoint(
++ "token_endpoint", "POST", "/token", token_endpoint
++ )
++
++ # Have the client connect.
++ sock, client = accept(
++ oauth_issuer=openid_provider.discovery_uri,
++ oauth_client_id="some-id",
++ )
++
++ other_wkuri = f"{openid_provider.issuer}/.well-known/oauth-authorization-server"
++
++ if not change_between_connections:
++ # Immediately respond with the wrong URL.
++ with sock:
++ handle_discovery_connection(sock, other_wkuri)
++
++ else:
++ # First connection; use the right URL to begin with.
++ with sock:
++ handle_discovery_connection(sock, openid_provider.discovery_uri)
++
++ # Second connection. Reject the token and switch the URL.
++ sock, _ = accept()
++ with sock:
++ with pq3.wrap(sock, debug_stream=sys.stdout) as conn:
++ initial = start_oauth_handshake(conn)
++ get_auth_value(initial)
++
++ # Ignore the token; fail with a different discovery URL.
++ resp = {
++ "status": "invalid_token",
++ "openid-configuration": other_wkuri,
++ }
++ fail_oauth_handshake(conn, resp)
++
++ expected_error = rf"server's discovery document has moved to {other_wkuri} \(previous location was {openid_provider.discovery_uri}\)"
++ with pytest.raises(psycopg2.OperationalError, match=expected_error):
+ client.check_completed()
## src/test/python/conftest.py (new) ##