1: 36409a76ce = 1: 0ff053cf31 common/jsonapi: support FRONTEND clients 2: 1356b729db ! 2: 26a341e1de libpq: add OAUTHBEARER SASL mechanism @@ Commit message Visit https://oauth.example.org/login and enter the code: FPQ2-M4BG The OAuth issuer must support device authorization. No other OAuth flows - are currently implemented. + are currently implemented (but clients may provide their own flows; see + below). The client implementation requires either libcurl or libiddawc and their development headers. Pass `curl` or `iddawc` to --with-oauth/-Doauth @@ Commit message Thomas Munro wrote the kqueue() implementation for oauth-curl; thanks! + = PQauthDataHook = + + Clients may override two pieces of OAuth handling using the new + PQsetAuthDataHook(): + + - PQAUTHDATA_PROMPT_OAUTH_DEVICE: replaces the default user prompt to + standard error when using the builtin device authorization flow + + - PQAUTHDATA_OAUTH_BEARER_TOKEN: replaces the entire OAuth flow with a + custom asynchronous implementation + + In general, a hook implementation should examine the incoming `type` to + decide whether or not to handle a specific piece of authdata; if not, it + should delegate to the previous hook in the chain (retrievable via + PQgetAuthDataHook()). Otherwise, it should return an integer > 0 and + follow the authdata-specific instructions. Returning an integer < 0 + signals an error condition and abandons the connection attempt. + + == PQAUTHDATA_PROMPT_OAUTH_DEVICE == + + The hook should display the device prompt (URL + code) using whatever + method it prefers. + + == PQAUTHDATA_OAUTH_BEARER_TOKEN == + + The hook should either directly return a Bearer token for the current + user/issuer/scope combination, if one is available without blocking, or + else set up an asynchronous callback to retrieve one. See the + documentation for PQoauthBearerRequest. + Several TODOs: - don't retry forever if the server won't accept our token - perform several sanity checks on the OAuth issuer's responses - handle cases where the client has been set up with an issuer and scope, but the Postgres server wants to use something different - improve error debuggability during the OAuth handshake - - migrate JSON parsing to the new JSON_SEM_ACTION_FAILED API convention - fix libcurl initialization thread-safety - harden the libcurl flow implementation - - figure out how to report the user code and URL without the notice - processor + - figure out pgsocket/int difference on Windows - ...and more. ## configure ## @@ src/interfaces/libpq/Makefile: endif SHLIB_LINK += $(filter -lcrypt -ldes -lcom_err -lcrypto -lk5crypto -lkrb5 -lgssapi32 -lssl -lsocket -lnsl -lresolv -lintl -lm $(PTHREAD_LIBS), $(LIBS)) $(LDAP_LIBS_FE) endif + ## src/interfaces/libpq/exports.txt ## +@@ src/interfaces/libpq/exports.txt: PQclosePrepared 188 + PQclosePortal 189 + PQsendClosePrepared 190 + PQsendClosePortal 191 ++PQsetAuthDataHook 192 ++PQgetAuthDataHook 193 ++PQdefaultAuthDataHook 194 + ## src/interfaces/libpq/fe-auth-oauth-curl.c (new) ## @@ +/*------------------------------------------------------------------------- @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new) +#include + +#include "common/jsonapi.h" ++#include "fe-auth.h" +#include "fe-auth-oauth.h" +#include "libpq-int.h" +#include "mb/pg_wchar.h" @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new) + */ + struct provider provider; + struct device_authz authz; ++ ++ bool user_prompted; /* have we already sent the authz prompt? */ +}; + +/* -+ * Exported function to free the async_ctx, which is stored directly on the -+ * PGconn. This is called during pqDropConnection() so that we don't leak -+ * resources even if PQconnectPoll() never calls us back. ++ * Frees the async_ctx, which is stored directly on the PGconn. This is called ++ * during pqDropConnection() so that we don't leak resources even if ++ * PQconnectPoll() never calls us back. + * + * TODO: we should probably call this at the end of a successful authentication, + * too, to proactively free up resources. + */ -+void -+pg_fe_free_oauth_async_ctx(PGconn *conn, void *ctx) ++static void ++free_curl_async_ctx(PGconn *conn, void *ctx) +{ + struct async_ctx *actx = ctx; + @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new) +#endif + + state->async_ctx = actx; ++ state->free_async_ctx = free_curl_async_ctx; + + initPQExpBuffer(&actx->work_data); + initPQExpBuffer(&actx->errbuf); @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new) + if (!finish_token_request(actx, &tok)) + goto error_return; + -+ /* -+ * Now that we know the token endpoint isn't broken, give the user -+ * the login instructions. -+ * -+ * TODO: allow client applications to override this handling. -+ */ -+ fprintf(stderr, "Visit %s and enter the code: %s", -+ actx->authz.verification_uri, actx->authz.user_code); ++ if (!actx->user_prompted) ++ { ++ int res; ++ PQpromptOAuthDevice prompt = { ++ .verification_uri = actx->authz.verification_uri, ++ .user_code = actx->authz.user_code, ++ /* TODO: optional fields */ ++ }; ++ ++ /* ++ * Now that we know the token endpoint isn't broken, give the ++ * user the login instructions. ++ */ ++ res = PQauthDataHook(PQAUTHDATA_PROMPT_OAUTH_DEVICE, conn, ++ &prompt); ++ ++ if (!res) ++ { ++ fprintf(stderr, "Visit %s and enter the code: %s", ++ prompt.verification_uri, prompt.user_code); ++ } ++ else if (res < 0) ++ { ++ actx_error(actx, "device prompt failed"); ++ goto error_return; ++ } ++ ++ actx->user_prompted = true; ++ } + + if (tok.access_token) + { @@ src/interfaces/libpq/fe-auth-oauth-iddawc.c (new) + + if (!user_prompted) + { ++ int res; ++ PQpromptOAuthDevice prompt = { ++ .verification_uri = verification_uri, ++ .user_code = user_code, ++ /* TODO: optional fields */ ++ }; ++ + /* + * Now that we know the token endpoint isn't broken, give the user + * the login instructions. + */ -+ pqInternalNotice(&conn->noticeHooks, -+ "Visit %s and enter the code: %s", -+ verification_uri, user_code); ++ res = PQauthDataHook(PQAUTHDATA_PROMPT_OAUTH_DEVICE, conn, ++ &prompt); ++ ++ if (!res) ++ { ++ fprintf(stderr, "Visit %s and enter the code: %s", ++ prompt.verification_uri, prompt.user_code); ++ } ++ else if (res < 0) ++ { ++ appendPQExpBufferStr(&conn->errorMessage, ++ libpq_gettext("device prompt failed\n")); ++ goto cleanup; ++ } + + user_prompted = true; + } @@ src/interfaces/libpq/fe-auth-oauth-iddawc.c (new) + /* TODO: actually make this asynchronous */ + state->token = run_iddawc_auth_flow(conn, conn->oauth_discovery_uri); + return state->token ? PGRES_POLLING_OK : PGRES_POLLING_FAILED; -+} -+ -+void -+pg_fe_free_oauth_async_ctx(PGconn *conn, void *ctx) -+{ -+ /* We currently have no async_ctx, so this should not be called. */ -+ Assert(false); +} ## src/interfaces/libpq/fe-auth-oauth.c (new) ## @@ src/interfaces/libpq/fe-auth-oauth.c (new) + Assert(sasl_mechanism != NULL); + Assert(!strcmp(sasl_mechanism, OAUTHBEARER_NAME)); + -+ state = malloc(sizeof(*state)); ++ state = calloc(1, sizeof(*state)); + if (!state) + return NULL; + + state->state = FE_OAUTH_INIT; + state->conn = conn; -+ state->token = NULL; -+ state->async_ctx = NULL; + + return state; +} @@ src/interfaces/libpq/fe-auth-oauth.c (new) +{ + struct json_ctx *ctx = state; + -+ if (oauth_json_has_error(ctx)) -+ return JSON_SUCCESS; /* short-circuit */ -+ + if (ctx->target_field) + { + Assert(ctx->nested == 1); @@ src/interfaces/libpq/fe-auth-oauth.c (new) + } + + ++ctx->nested; -+ return JSON_SUCCESS; /* TODO: switch all of these to JSON_SEM_ACTION_FAILED */ ++ return oauth_json_has_error(ctx) ? JSON_SEM_ACTION_FAILED : JSON_SUCCESS; +} + +static JsonParseErrorType @@ src/interfaces/libpq/fe-auth-oauth.c (new) +{ + struct json_ctx *ctx = state; + -+ if (oauth_json_has_error(ctx)) -+ return JSON_SUCCESS; /* short-circuit */ -+ + --ctx->nested; + return JSON_SUCCESS; +} @@ src/interfaces/libpq/fe-auth-oauth.c (new) +{ + struct json_ctx *ctx = state; + -+ if (oauth_json_has_error(ctx)) -+ { -+ /* short-circuit */ -+ free(name); -+ return JSON_SUCCESS; -+ } -+ + if (ctx->nested == 1) + { + if (!strcmp(name, ERROR_STATUS_FIELD)) @@ src/interfaces/libpq/fe-auth-oauth.c (new) +{ + struct json_ctx *ctx = state; + -+ if (oauth_json_has_error(ctx)) -+ return JSON_SUCCESS; /* short-circuit */ -+ + if (!ctx->nested) + { + ctx->errmsg = libpq_gettext("top-level element must be an object"); @@ src/interfaces/libpq/fe-auth-oauth.c (new) + ctx->target_field_name); + } + -+ return JSON_SUCCESS; ++ return oauth_json_has_error(ctx) ? JSON_SEM_ACTION_FAILED : JSON_SUCCESS; +} + +static JsonParseErrorType @@ src/interfaces/libpq/fe-auth-oauth.c (new) +{ + struct json_ctx *ctx = state; + -+ if (oauth_json_has_error(ctx)) -+ { -+ /* short-circuit */ -+ free(token); -+ return JSON_SUCCESS; -+ } -+ + if (!ctx->nested) + { + ctx->errmsg = libpq_gettext("top-level element must be an object"); @@ src/interfaces/libpq/fe-auth-oauth.c (new) + } + + free(token); -+ return JSON_SUCCESS; ++ return oauth_json_has_error(ctx) ? JSON_SEM_ACTION_FAILED : JSON_SUCCESS; +} + +static bool @@ src/interfaces/libpq/fe-auth-oauth.c (new) + + err = pg_parse_json(&lex, &sem); + -+ if (err != JSON_SUCCESS) -+ { -+ errmsg = json_errdetail(err, &lex); -+ } -+ else if (PQExpBufferDataBroken(ctx.errbuf)) -+ { -+ errmsg = libpq_gettext("out of memory"); -+ } -+ else if (ctx.errmsg) ++ if (err == JSON_SEM_ACTION_FAILED) + { -+ errmsg = ctx.errmsg; ++ if (PQExpBufferDataBroken(ctx.errbuf)) ++ errmsg = libpq_gettext("out of memory"); ++ else if (ctx.errmsg) ++ errmsg = ctx.errmsg; ++ else ++ { ++ /* ++ * Developer error: one of the action callbacks didn't call ++ * oauth_json_set_error() before erroring out. ++ */ ++ Assert(oauth_json_has_error(&ctx)); ++ errmsg = ""; ++ } + } ++ else if (err != JSON_SUCCESS) ++ errmsg = json_errdetail(err, &lex); + + if (errmsg) + appendPQExpBuffer(&conn->errorMessage, @@ src/interfaces/libpq/fe-auth-oauth.c (new) + return true; +} + ++static void ++free_request(PGconn *conn, void *vreq) ++{ ++ PQoauthBearerRequest *request = vreq; ++ ++ if (request->cleanup) ++ request->cleanup(conn, request); ++ ++ free(request); ++} ++ ++static PostgresPollingStatusType ++run_user_oauth_flow(PGconn *conn, pgsocket *altsock) ++{ ++ fe_oauth_state *state = conn->sasl_state; ++ PQoauthBearerRequest *request = state->async_ctx; ++ PostgresPollingStatusType status; ++ ++ if (!request->async) ++ { ++ libpq_append_conn_error(conn, "user-defined OAuth flow provided neither a token nor an async callback"); ++ return PGRES_POLLING_FAILED; ++ } ++ ++ status = request->async(conn, request, altsock); ++ if (status == PGRES_POLLING_FAILED) ++ { ++ libpq_append_conn_error(conn, "user-defined OAuth flow failed"); ++ return status; ++ } ++ else if (status == PGRES_POLLING_OK) ++ { ++ /* ++ * We already have a token, so copy it into the state. (We can't ++ * hold onto the original string, since it may not be safe for us to ++ * free() it.) ++ */ ++ PQExpBufferData token; ++ ++ if (!request->token) ++ { ++ libpq_append_conn_error(conn, "user-defined OAuth flow did not provide a token"); ++ return PGRES_POLLING_FAILED; ++ } ++ ++ initPQExpBuffer(&token); ++ appendPQExpBuffer(&token, "Bearer %s", request->token); ++ ++ if (PQExpBufferDataBroken(token)) ++ { ++ libpq_append_conn_error(conn, "out of memory"); ++ return PGRES_POLLING_FAILED; ++ } ++ ++ state->token = token.data; ++ return PGRES_POLLING_OK; ++ } ++ ++ /* TODO: what if no altsock was set? */ ++ return status; ++} ++ ++static bool ++setup_token_request(PGconn *conn, fe_oauth_state *state) ++{ ++ int res; ++ PQoauthBearerRequest request = { ++ .openid_configuration = conn->oauth_discovery_uri, ++ .scope = conn->oauth_scope, ++ }; ++ ++ Assert(request.openid_configuration); ++ ++ /* The client may have overridden the OAuth flow. */ ++ res = PQauthDataHook(PQAUTHDATA_OAUTH_BEARER_TOKEN, conn, &request); ++ if (res > 0) ++ { ++ PQoauthBearerRequest *request_copy; ++ ++ if (request.token) ++ { ++ /* ++ * We already have a token, so copy it into the state. (We can't ++ * hold onto the original string, since it may not be safe for us to ++ * free() it.) ++ */ ++ PQExpBufferData token; ++ ++ initPQExpBuffer(&token); ++ appendPQExpBuffer(&token, "Bearer %s", request.token); ++ ++ if (PQExpBufferDataBroken(token)) ++ { ++ libpq_append_conn_error(conn, "out of memory"); ++ goto fail; ++ } ++ ++ state->token = token.data; ++ ++ /* short-circuit */ ++ if (request.cleanup) ++ request.cleanup(conn, &request); ++ return true; ++ } ++ ++ request_copy = malloc(sizeof(*request_copy)); ++ if (!request_copy) ++ { ++ libpq_append_conn_error(conn, "out of memory"); ++ goto fail; ++ } ++ ++ memcpy(request_copy, &request, sizeof(request)); ++ ++ conn->async_auth = run_user_oauth_flow; ++ state->async_ctx = request_copy; ++ state->free_async_ctx = free_request; ++ } ++ else if (res < 0) ++ { ++ libpq_append_conn_error(conn, "user-defined OAuth flow failed"); ++ goto fail; ++ } ++ else ++ { ++ /* Use our built-in OAuth flow. */ ++ conn->async_auth = pg_fe_run_oauth_flow; ++ } ++ ++ return true; ++ ++fail: ++ if (request.cleanup) ++ request.cleanup(conn, &request); ++ return false; ++} ++ +static bool +derive_discovery_uri(PGconn *conn) +{ @@ src/interfaces/libpq/fe-auth-oauth.c (new) + } + + /* -+ * At this point 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. ++ * Decide whether we're using a user-provided OAuth flow, or the ++ * one we have built in. + */ -+ conn->async_auth = pg_fe_run_oauth_flow; -+ state->state = FE_OAUTH_REQUESTING_TOKEN; -+ return SASL_ASYNC; -+ } ++ if (!setup_token_request(conn, state)) ++ return SASL_FAILED; + -+ /* -+ * If we don't have a discovery URI to be able to request a token, -+ * we ask the server for one explicitly with an empty token. This -+ * doesn't require any asynchronous work. -+ */ -+ state->token = strdup(""); -+ if (!state->token) ++ if (state->token) ++ { ++ /* ++ * A really smart user implementation may have already given ++ * us the token (e.g. if there was an unexpired copy already ++ * cached). In that case, we can just fall through. ++ */ ++ } ++ else ++ { ++ /* ++ * Otherwise, we have to hand the connection over to our ++ * OAuth implementation. This involves a number of HTTP ++ * connections and timed waits, so we escape the synchronous ++ * auth processing and tell PQconnectPoll to transfer ++ * control to our async implementation. ++ */ ++ Assert(conn->async_auth); /* should have been set already */ ++ state->state = FE_OAUTH_REQUESTING_TOKEN; ++ return SASL_ASYNC; ++ } ++ } ++ else + { -+ libpq_append_conn_error(conn, "out of memory"); -+ return SASL_FAILED; ++ /* ++ * If we don't have a discovery URI to be able to request a ++ * token, we ask the server for one explicitly with an empty ++ * token. This doesn't require any asynchronous work. ++ */ ++ state->token = strdup(""); ++ if (!state->token) ++ { ++ libpq_append_conn_error(conn, "out of memory"); ++ return SASL_FAILED; ++ } + } + + /* fall through */ @@ src/interfaces/libpq/fe-auth-oauth.c (new) + + free(state->token); + if (state->async_ctx) -+ pg_fe_free_oauth_async_ctx(state->conn, state->async_ctx); ++ state->free_async_ctx(state->conn, state->async_ctx); + + free(state); +} @@ src/interfaces/libpq/fe-auth-oauth.h (new) + + PGconn *conn; + char *token; ++ + void *async_ctx; ++ void (*free_async_ctx) (PGconn *conn, void *ctx); +} fe_oauth_state; + +extern PostgresPollingStatusType pg_fe_run_oauth_flow(PGconn *conn, pgsocket *altsock); -+extern void pg_fe_free_oauth_async_ctx(PGconn *conn, void *ctx); + +#endif /* FE_AUTH_OAUTH_H */ @@ src/interfaces/libpq/fe-auth.c: pg_fe_sendauth(AuthRequest areq, int payloadlen, { /* Use this message if pg_SASL_continue didn't supply one */ if (conn->errorMessage.len == oldmsglen) +@@ src/interfaces/libpq/fe-auth.c: PQencryptPasswordConn(PGconn *conn, const char *passwd, const char *user, + + return crypt_pwd; + } ++ ++PQauthDataHook_type PQauthDataHook = PQdefaultAuthDataHook; ++ ++PQauthDataHook_type ++PQgetAuthDataHook(void) ++{ ++ return PQauthDataHook; ++} ++ ++void ++PQsetAuthDataHook(PQauthDataHook_type hook) ++{ ++ PQauthDataHook = hook ? hook : PQdefaultAuthDataHook; ++} ++ ++int ++PQdefaultAuthDataHook(PGAuthData type, PGconn *conn, void *data) ++{ ++ return 0; /* handle nothing */ ++} ## src/interfaces/libpq/fe-auth.h ## @@ + #include "libpq-int.h" ++extern PQauthDataHook_type PQauthDataHook; ++ ++ /* Prototypes for functions in fe-auth.c */ -extern int pg_fe_sendauth(AuthRequest areq, int payloadlen, PGconn *conn); +extern int pg_fe_sendauth(AuthRequest areq, int payloadlen, PGconn *conn, @@ src/interfaces/libpq/fe-connect.c: keep_going: /* We will come back to here + case CONNECTION_AUTHENTICATING: + { + PostgresPollingStatusType status; -+ pgsocket altsock; ++ pgsocket altsock = PGINVALID_SOCKET; + + if (!conn->async_auth) + { @@ src/interfaces/libpq/fe-misc.c: pqSocketCheck(PGconn *conn, int forRead, int for if (result < 0) ## src/interfaces/libpq/libpq-fe.h ## +@@ src/interfaces/libpq/libpq-fe.h: extern "C" + #define LIBPQ_HAS_TRACE_FLAGS 1 + /* Indicates that PQsslAttribute(NULL, "library") is useful */ + #define LIBPQ_HAS_SSL_LIBRARY_DETECTION 1 ++/* Indicates presence of the PQAUTHDATA_PROMPT_OAUTH_DEVICE authdata hook */ ++#define LIBPQ_HAS_PROMPT_OAUTH_DEVICE 1 + + /* + * Option flags for PQcopyResult @@ src/interfaces/libpq/libpq-fe.h: typedef enum CONNECTION_CONSUME, /* Consuming any extra messages. */ CONNECTION_GSS_STARTUP, /* Negotiating GSSAPI. */ @@ src/interfaces/libpq/libpq-fe.h: typedef enum } ConnStatusType; typedef enum +@@ src/interfaces/libpq/libpq-fe.h: typedef enum + PQ_PIPELINE_ABORTED + } PGpipelineStatus; + ++typedef enum ++{ ++ PQAUTHDATA_PROMPT_OAUTH_DEVICE, /* user must visit a device-authorization URL */ ++ PQAUTHDATA_OAUTH_BEARER_TOKEN, /* server requests an OAuth Bearer token */ ++} PGAuthData; ++ + /* PGconn encapsulates a connection to the backend. + * The contents of this struct are not supposed to be known to applications. + */ +@@ src/interfaces/libpq/libpq-fe.h: extern int PQenv2encoding(void); + + /* === in fe-auth.c === */ + ++typedef struct _PQpromptOAuthDevice ++{ ++ const char *verification_uri; /* verification URI to visit */ ++ const char *user_code; /* user code to enter */ ++} PQpromptOAuthDevice; ++ ++typedef struct _PQoauthBearerRequest ++{ ++ /* Hook inputs (constant across all calls) */ ++ const char * const openid_configuration; /* OIDC discovery URI */ ++ const char * const scope; /* required scope(s), or NULL */ ++ ++ /* Hook outputs */ ++ ++ /* ++ * Callback implementing a custom asynchronous OAuth flow. ++ * ++ * The callback may return ++ * - PGRES_POLLING_READING/WRITING, to indicate that a file descriptor has ++ * been stored in *altsock and libpq should wait until it is readable or ++ * writable before calling back; ++ * - PGRES_POLLING_OK, to indicate that the flow is complete and ++ * request->token has been set; or ++ * - PGRES_POLLING_FAILED, to indicate that token retrieval has failed. ++ * ++ * This callback is optional. If the token can be obtained without blocking ++ * during the original call to the PQAUTHDATA_OAUTH_BEARER_TOKEN hook, it ++ * may be returned directly, but one of request->async or request->token ++ * must be set by the hook. ++ */ ++ PostgresPollingStatusType (*async) (PGconn *conn, ++ struct _PQoauthBearerRequest *request, ++ int *altsock); ++ ++ /* ++ * Callback to clean up custom allocations. A hook implementation may use ++ * this to free request->token and any resources in request->user. ++ * ++ * This is technically optional, but highly recommended, because there is no ++ * other indication as to when it is safe to free the token. ++ */ ++ void (*cleanup) (PGconn *conn, struct _PQoauthBearerRequest *request); ++ ++ /* ++ * The hook should set this to the Bearer token contents for the connection, ++ * once the flow is completed. The token contents must remain available to ++ * libpq until the hook's cleanup callback is called. ++ */ ++ char *token; ++ ++ /* ++ * Hook-defined data. libpq will not modify this pointer across calls to the ++ * async callback, so it can be used to keep track of application-specific ++ * state. Resources allocated here should be freed by the cleanup callback. ++ */ ++ void *user; ++} PQoauthBearerRequest; ++ + extern char *PQencryptPassword(const char *passwd, const char *user); + extern char *PQencryptPasswordConn(PGconn *conn, const char *passwd, const char *user, const char *algorithm); + ++typedef int (*PQauthDataHook_type) (PGAuthData type, PGconn *conn, void *data); ++extern void PQsetAuthDataHook(PQauthDataHook_type hook); ++extern PQauthDataHook_type PQgetAuthDataHook(void); ++extern int PQdefaultAuthDataHook(PGAuthData type, PGconn *conn, void *data); ++ + /* === in encnames.c === */ + + extern int pg_char_to_encoding(const char *name); ## src/interfaces/libpq/libpq-int.h ## @@ src/interfaces/libpq/libpq-int.h: typedef struct pg_conn_host 3: 863a49f863 = 3: d02bc9a466 backend: add OAUTHBEARER SASL mechanism 4: 348554e5f4 ! 4: fded01d22b Add pytest suite for OAuth @@ src/test/python/client/test_oauth.py (new) +# + +import base64 ++import collections ++import ctypes +import http.server +import json ++import logging ++import os ++import platform +import secrets +import sys +import threading +import time ++import traceback ++import types +import urllib.parse +from numbers import Number + @@ src/test/python/client/test_oauth.py (new) + +from .conftest import BLOCKING_TIMEOUT + ++if platform.system() == "Darwin": ++ libpq = ctypes.cdll.LoadLibrary("libpq.5.dylib") ++else: ++ libpq = ctypes.cdll.LoadLibrary("libpq.so.5") ++ + +def finish_handshake(conn): + """ @@ src/test/python/client/test_oauth.py (new) + thread.stop() + + ++# ++# PQAuthDataHook implementation, matching libpq.h ++# ++ ++ ++PQAUTHDATA_PROMPT_OAUTH_DEVICE = 0 ++PQAUTHDATA_OAUTH_BEARER_TOKEN = 1 ++ ++PGRES_POLLING_FAILED = 0 ++PGRES_POLLING_READING = 1 ++PGRES_POLLING_WRITING = 2 ++PGRES_POLLING_OK = 3 ++ ++ ++class PQPromptOAuthDevice(ctypes.Structure): ++ _fields_ = [ ++ ("verification_uri", ctypes.c_char_p), ++ ("user_code", ctypes.c_char_p), ++ ] ++ ++ ++class PQOAuthBearerRequest(ctypes.Structure): ++ pass ++ ++ ++PQOAuthBearerRequest._fields_ = [ ++ ("openid_configuration", ctypes.c_char_p), ++ ("scope", ctypes.c_char_p), ++ ( ++ "async_", ++ ctypes.CFUNCTYPE( ++ ctypes.c_int, ++ ctypes.c_void_p, ++ ctypes.POINTER(PQOAuthBearerRequest), ++ ctypes.POINTER(ctypes.c_int), ++ ), ++ ), ++ ( ++ "cleanup", ++ ctypes.CFUNCTYPE(None, ctypes.c_void_p, ctypes.POINTER(PQOAuthBearerRequest)), ++ ), ++ ("token", ctypes.c_char_p), ++ ("user", ctypes.c_void_p), ++] ++ ++ ++@pytest.fixture ++def auth_data_cb(): ++ """ ++ Tracks calls to the libpq authdata hook. The yielded object contains a calls ++ member that records the data sent to the hook. If a test needs to perform ++ custom actions during a call, it can set the yielded object's impl callback; ++ beware that the callback takes place on a different thread. ++ ++ This is done differently from the other callback implementations on purpose. ++ For the others, we can declare test-specific callbacks and have them perform ++ direct assertions on the data they receive. But that won't work for a C ++ callback, because there's no way for us to bubble up the assertion through ++ libpq. Instead, this mock-style approach is taken, where we just record the ++ calls and let the test examine them later. ++ """ ++ ++ class _Call: ++ pass ++ ++ class _cb(object): ++ def __init__(self): ++ self.calls = [] ++ ++ cb = _cb() ++ cb.impl = None ++ ++ # The callback will occur on a different thread, so protect the cb object. ++ cb_lock = threading.Lock() ++ ++ @ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_byte, ctypes.c_void_p, ctypes.c_void_p) ++ def auth_data_cb(typ, pgconn, data): ++ handle_by_default = 0 # does an implementation have to be provided? ++ ++ if typ == PQAUTHDATA_PROMPT_OAUTH_DEVICE: ++ cls = PQPromptOAuthDevice ++ handle_by_default = 1 ++ elif typ == PQAUTHDATA_OAUTH_BEARER_TOKEN: ++ cls = PQOAuthBearerRequest ++ else: ++ return 0 ++ ++ call = _Call() ++ call.type = typ ++ ++ # The lifetime of the underlying data being pointed to doesn't ++ # necessarily match the lifetime of the Python object, so we can't ++ # reference a Structure's fields after returning. Explicitly copy the ++ # contents over, field by field. ++ data = ctypes.cast(data, ctypes.POINTER(cls)) ++ for name, _ in cls._fields_: ++ setattr(call, name, getattr(data.contents, name)) ++ ++ with cb_lock: ++ cb.calls.append(call) ++ ++ if cb.impl: ++ # Pass control back to the test. ++ try: ++ return cb.impl(typ, pgconn, data.contents) ++ except Exception: ++ # This can't escape into the C stack, but we can fail the flow ++ # and hope the traceback gives us enough detail. ++ logging.error( ++ "Exception during authdata hook callback:\n" ++ + traceback.format_exc() ++ ) ++ return -1 ++ ++ return handle_by_default ++ ++ libpq.PQsetAuthDataHook(auth_data_cb) ++ try: ++ yield cb ++ finally: ++ # The callback is about to go out of scope, so make sure libpq is ++ # disconnected from it. (We wouldn't want to accidentally influence ++ # later tests anyway.) ++ libpq.PQsetAuthDataHook(None) ++ ++ +@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) + ], +) +def test_oauth_with_explicit_issuer( -+ capfd, accept, openid_provider, asynchronous, retries, scope, secret ++ accept, openid_provider, asynchronous, retries, scope, secret, auth_data_cb +): + client_id = secrets.token_hex() + @@ src/test/python/client/test_oauth.py (new) + finish_handshake(conn) + + if retries: -+ # Finally, make sure that the client prompted the user with the expected -+ # authorization URL and user code. -+ expected = f"Visit {verification_url} and enter the code: {user_code}" -+ _, stderr = capfd.readouterr() -+ assert expected in stderr ++ # Finally, make sure that the client prompted the user once with the ++ # expected authorization URL and user code. ++ assert len(auth_data_cb.calls) == 2 ++ ++ # First call should have been for a custom flow, which we ignored. ++ assert auth_data_cb.calls[0].type == PQAUTHDATA_OAUTH_BEARER_TOKEN ++ ++ # Second call is for our user prompt. ++ call = auth_data_cb.calls[1] ++ assert call.type == PQAUTHDATA_PROMPT_OAUTH_DEVICE ++ assert call.verification_uri.decode() == verification_url ++ assert call.user_code.decode() == user_code + + +def expect_disconnected_handshake(sock): @@ src/test/python/client/test_oauth.py (new) + finish_handshake(conn) + + ++@pytest.fixture ++def self_pipe(): ++ """ ++ Yields a pipe fd pair. ++ """ ++ ++ class _Pipe: ++ pass ++ ++ p = _Pipe() ++ p.readfd, p.writefd = os.pipe() ++ ++ try: ++ yield p ++ finally: ++ os.close(p.readfd) ++ os.close(p.writefd) ++ ++ ++@pytest.mark.parametrize("scope", [None, "", "openid email"]) ++@pytest.mark.parametrize( ++ "retries", ++ [ ++ -1, # no async callback ++ 0, # async callback immediately returns token ++ 1, # async callback waits on altsock once ++ 2, # async callback waits on altsock twice ++ ], ++) ++@pytest.mark.parametrize( ++ "asynchronous", ++ [ ++ pytest.param(False, id="synchronous"), ++ pytest.param(True, id="asynchronous"), ++ ], ++) ++def test_user_defined_flow( ++ accept, auth_data_cb, self_pipe, scope, retries, asynchronous ++): ++ issuer = "http://localhost" ++ discovery_uri = issuer + "/.well-known/openid-configuration" ++ access_token = secrets.token_urlsafe() ++ ++ sock, _ = accept( ++ oauth_issuer=issuer, ++ oauth_client_id="some-id", ++ oauth_scope=scope, ++ async_=asynchronous, ++ ) ++ ++ # Track callbacks. ++ attempts = 0 ++ wakeup_called = False ++ cleanup_calls = 0 ++ lock = threading.Lock() ++ ++ def wakeup(): ++ """Writes a byte to the wakeup pipe.""" ++ nonlocal wakeup_called ++ with lock: ++ wakeup_called = True ++ os.write(self_pipe.writefd, b"\0") ++ ++ def get_token(pgconn, request, p_altsock): ++ """ ++ Async token callback. While attempts < retries, libpq will be instructed ++ to wait on the self_pipe. When attempts == retries, the token will be ++ set. ++ ++ Note that assertions and exceptions raised here are allowed but not very ++ helpful, since they can't bubble through the libpq stack to be collected ++ by the test suite. Try not to rely too heavily on them. ++ """ ++ # Make sure libpq passed our user data through. ++ assert request.user == 42 ++ ++ with lock: ++ nonlocal attempts, wakeup_called ++ ++ if attempts: ++ # If we've already started the timer, we shouldn't get a ++ # call back before it trips. ++ assert wakeup_called, "authdata hook was called before the timer" ++ ++ # Drain the wakeup byte. ++ os.read(self_pipe.readfd, 1) ++ ++ if attempts < retries: ++ attempts += 1 ++ ++ # Wake up the client in a little bit of time. ++ wakeup_called = False ++ threading.Timer(0.1, wakeup).start() ++ ++ # Tell libpq to wait on the other end of the wakeup pipe. ++ p_altsock[0] = self_pipe.readfd ++ return PGRES_POLLING_READING ++ ++ # Done! ++ request.token = access_token.encode() ++ return PGRES_POLLING_OK ++ ++ @ctypes.CFUNCTYPE( ++ ctypes.c_int, ++ ctypes.c_void_p, ++ ctypes.POINTER(PQOAuthBearerRequest), ++ ctypes.POINTER(ctypes.c_int), ++ ) ++ def get_token_wrapper(pgconn, p_request, p_altsock): ++ """ ++ Translation layer between C and Python for the async callback. ++ Assertions and exceptions will be swallowed at the boundary, so make ++ sure they don't escape here. ++ """ ++ try: ++ return get_token(pgconn, p_request.contents, p_altsock) ++ except Exception: ++ logging.error("Exception during async callback:\n" + traceback.format_exc()) ++ return PGRES_POLLING_FAILED ++ ++ @ctypes.CFUNCTYPE(None, ctypes.c_void_p, ctypes.POINTER(PQOAuthBearerRequest)) ++ def cleanup(pgconn, p_request): ++ """ ++ Should be called exactly once per connection. ++ """ ++ nonlocal cleanup_calls ++ with lock: ++ cleanup_calls += 1 ++ ++ def bearer_hook(typ, pgconn, request): ++ """ ++ Implementation of the PQAuthDataHook, which either sets up an async ++ callback or returns the token directly, depending on the value of ++ retries. ++ ++ As above, try not to rely too much on assertions/exceptions here. ++ """ ++ assert typ == PQAUTHDATA_OAUTH_BEARER_TOKEN ++ request.cleanup = cleanup ++ ++ if retries < 0: ++ # Special case: return a token immediately without a callback. ++ request.token = access_token.encode() ++ return 1 ++ ++ # Tell libpq to call us back. ++ request.async_ = get_token_wrapper ++ request.user = ctypes.c_void_p(42) # will be checked in the callback ++ return 1 ++ ++ auth_data_cb.impl = bearer_hook ++ ++ # Now drive the server side. ++ with sock: ++ with pq3.wrap(sock, debug_stream=sys.stdout) as conn: ++ # Initiate a handshake, which should result in our custom callback ++ # being invoked to fetch the token. ++ 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) ++ ++ # Check the data provided to the hook. ++ assert len(auth_data_cb.calls) == 1 ++ ++ call = auth_data_cb.calls[0] ++ assert call.type == PQAUTHDATA_OAUTH_BEARER_TOKEN ++ assert call.openid_configuration.decode() == discovery_uri ++ assert call.scope == (None if scope is None else scope.encode()) ++ ++ # Make sure we cleaned up after ourselves. ++ assert cleanup_calls == 1 ++ ++ +def alt_patterns(*patterns): + """ + Just combines multiple alternative regexes into one. It's not very efficient 5: 16d3984a45 ! 5: 38a9691801 squash! Add pytest suite for OAuth @@ src/test/python/README: To make quick smoke tests possible, slow tests have been + $ py.test --temp-instance=./tmp_check ## src/test/python/client/test_oauth.py ## -@@ - import base64 - import http.server - import json -+import os - import secrets - import sys - import threading @@ src/test/python/client/test_oauth.py: import pq3 from .conftest import BLOCKING_TIMEOUT @@ src/test/python/client/test_oauth.py: import pq3 + reason="OAuth client tests require --with-oauth support", +) + - - def finish_handshake(conn): - """ + if platform.system() == "Darwin": + libpq = ctypes.cdll.LoadLibrary("libpq.5.dylib") + else: ## src/test/python/conftest.py ## @@ src/test/python/conftest.py: import os