1: 5730b875b8 ! 1: 16f1b8fc02 Add OAUTHBEARER SASL mechanism @@ doc/src/sgml/client-auth.sgml: host ... radius radiusservers="server1,server2" r + issuer + + -+ The URL of the OAuth issuing party, which the client -+ must contact to receive a bearer token. This parameter is required. ++ The issuer identifier of the authorization server, as defined by its ++ discovery document, or a well-known URI pointing to that discovery ++ document. This parameter is required. + ++ ++ When an OAuth client connects to the server, a discovery document URI ++ will be constructed using the issuer identifier. By default, the URI ++ uses the conventions of OpenID Connect Discovery: the path ++ /.well-known/openid-configuration will be appended ++ to the issuer identifier. Alternatively, if the ++ issuer contains a /.well-known/ ++ path segment, the URI will be provided to the client as-is. ++ ++ ++ ++ The OAuth client in libpq requires the server's issuer setting to ++ exactly match the issuer identifier which is provided in the discovery ++ document, which must in turn match the client's ++ setting. No variations in ++ case or format are permitted. ++ ++ + + + @@ doc/src/sgml/client-auth.sgml: host ... radius radiusservers="server1,server2" r + + + ++ validator ++ ++ ++ The library to use for validating bearer tokens. If given, the name must ++ exactly match one of the libraries listed in ++ . This parameter is ++ optional unless oauth_validator_libraries contains ++ more than one library, in which case it is required. ++ ++ ++ ++ ++ + map + + @@ doc/src/sgml/config.sgml: include_dir 'conf.d' + -+ -+ oauth_validator_library (string) ++ ++ oauth_validator_libraries (string) + -+ oauth_validator_library configuration parameter ++ oauth_validator_libraries configuration parameter + + + + -+ The library to use for validating OAuth connection tokens. If set to -+ an empty string (the default), OAuth connections will be refused. For -+ more information on implementing OAuth validators see ++ The library/libraries to use for validating OAuth connection tokens. If ++ only one validator library is provided, it will be used by default for ++ any OAuth connections; otherwise, all ++ oauth HBA entries ++ must explicitly set a validator chosen from this ++ list. If set to an empty string (the default), OAuth connections will be ++ refused. For more information on implementing OAuth validators see + . This parameter can only be set in + the postgresql.conf file. + @@ doc/src/sgml/libpq.sgml: postgresql://%2Fvar%2Flib%2Fpostgresql/dbname + ++ ++ oauth_issuer ++ ++ ++ The HTTPS URL of an issuer to contact if the server requests an OAuth ++ token for the connection. This parameter is required for all OAuth ++ connections; it should exactly match the issuer ++ setting in the server's HBA configuration. ++ ++ ++ As part of the standard authentication handshake, libpq will ask the ++ server for a discovery document: a URI providing a ++ set of OAuth configuration parameters. The server must provide a URI ++ that is directly constructed from the components of the ++ oauth_issuer, and this value must exactly match the ++ issuer identifier that is declared in the discovery document itself, or ++ the connection will fail. This is required to prevent a class of "mix-up ++ 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 URI, you may set oauth_issuer to a ++ /.well-known/ URI used for OAuth discovery. (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 ++ connect.) libpq currently supports the following well-known endpoints: ++ ++ /.well-known/openid-configuration ++ /.well-known/oauth-authorization-server ++ ++ ++ ++ ++ + + oauth_client_id + @@ doc/src/sgml/libpq.sgml: postgresql://%2Fvar%2Flib%2Fpostgresql/dbname + + + -+ -+ oauth_issuer -+ -+ -+ The HTTPS URL of an issuer to contact if the server requests an OAuth -+ token for the connection. This parameter is optional and intended for -+ advanced usage; see also . -+ -+ -+ If no oauth_issuer is provided, the client will ask -+ the PostgreSQL server to provide an -+ acceptable issuer URL (as configured in its -+ HBA settings). This is convenient, but -+ it requires two separate network connections to the server per attempt. -+ -+ -+ Providing an explicit oauth_issuer (and, typically, -+ an accompanying oauth_scope) skips this initial -+ "discovery" phase, which may speed up certain custom OAuth flows. -+ -+ This parameter may also be set defensively, to prevent the backend -+ server from directing the client to arbitrary URLs. -+ However: if the client's issuer setting differs -+ from the server's expected issuer, the server is likely to reject the -+ issued token, and the connection will fail. -+ -+ -+ -+ + + oauth_scope + @@ doc/src/sgml/libpq.sgml: postgresql://%2Fvar%2Flib%2Fpostgresql/dbname + identifiers. This parameter is optional and intended for advanced usage. + + -+ -+ If neither nor -+ oauth_scope is specified, the client will obtain -+ appropriate scope settings from the -+ PostgreSQL server. Otherwise, the value of -+ this parameter will be used. Similarly to -+ oauth_issuer, if the client's scope setting does not -+ contain the server's required scopes, the server is likely to reject the -+ issued token, and the connection will fail. ++ Usually the client will obtain appropriate scope settings from the ++ PostgreSQL server. If this parameter is used, ++ the server's requested scope list will be ignored. This can prevent a ++ less-trusted server from requesting inappropriate access scopes from the ++ end user. However, if the client's scope setting does not contain the ++ server's required scopes, the server is likely to reject the issued ++ token, and the connection will fail. + + + The meaning of an empty scope list is provider-dependent. An OAuth @@ doc/src/sgml/oauth-validators.sgml (new) + _PG_oauth_validator_module_init + + -+ An OAuth validator module is loaded by dynamically loading a shared library -+ with the 's name as the library -+ base name. The normal library search path is used to locate the library. To ++ An OAuth validator module is loaded by dynamically loading one of the shared ++ libraries listed in . ++ The normal library search path is used to locate the library. To + provide the validator callbacks and to indicate that the library is an OAuth + validator module a function named + _PG_oauth_validator_module_init must be provided. The @@ src/backend/libpq/auth-oauth.c (new) +#include "storage/fd.h" +#include "storage/ipc.h" +#include "utils/json.h" ++#include "utils/varlena.h" + +/* GUC */ -+char *OAuthValidatorLibrary = ""; ++char *oauth_validator_libraries_string = NULL; + +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); + -+static void load_validator_library(void); ++static void load_validator_library(const char *libname); +static void shutdown_validator_library(int code, Datum arg); + +static ValidatorModuleState *validator_module_state; @@ src/backend/libpq/auth-oauth.c (new) + ctx->issuer = port->hba->oauth_issuer; + ctx->scope = port->hba->oauth_scope; + -+ load_validator_library(); ++ load_validator_library(port->hba->oauth_validator); + + return ctx; +} @@ src/backend/libpq/auth-oauth.c (new) + 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. ++ /* ++ * Build a default .well-known URI based on our issuer, unless the HBA has ++ * already provided one. + */ + initStringInfo(&issuer); + appendStringInfoString(&issuer, ctx->issuer); -+ appendStringInfoString(&issuer, "/.well-known/openid-configuration"); ++ if (strstr(ctx->issuer, "/.well-known/") == NULL) ++ appendStringInfoString(&issuer, "/.well-known/openid-configuration"); + + initStringInfo(&buf); + @@ src/backend/libpq/auth-oauth.c (new) + * since token validation won't be possible. + */ +static void -+load_validator_library(void) ++load_validator_library(const char *libname) +{ + OAuthValidatorModuleInit validator_init; + -+ if (OAuthValidatorLibrary[0] == '\0') -+ ereport(ERROR, -+ errcode(ERRCODE_INVALID_PARAMETER_VALUE), -+ errmsg("oauth_validator_library is not set")); ++ Assert(libname && *libname); + + validator_init = (OAuthValidatorModuleInit) -+ load_external_function(OAuthValidatorLibrary, -+ "_PG_oauth_validator_module_init", false, NULL); ++ load_external_function(libname, "_PG_oauth_validator_module_init", ++ false, NULL); + + /* + * The validator init function is required since it will set the callbacks @@ src/backend/libpq/auth-oauth.c (new) + */ + if (validator_init == NULL) + ereport(ERROR, -+ errmsg("%s modules \"%s\" have to define the symbol %s", -+ "OAuth validator", OAuthValidatorLibrary, "_PG_oauth_validator_module_init")); ++ errmsg("%s module \"%s\" must define the symbol %s", ++ "OAuth validator", libname, "_PG_oauth_validator_module_init")); + + ValidatorCallbacks = (*validator_init) (); + @@ src/backend/libpq/auth-oauth.c (new) +{ + if (ValidatorCallbacks->shutdown_cb != NULL) + ValidatorCallbacks->shutdown_cb(validator_module_state); ++} ++ ++/* ++ * Ensure an OAuth validator named in the HBA is permitted by the configuration. ++ * ++ * If the validator is currently unset and exactly one library is declared in ++ * oauth_validator_libraries, then that library will be used as the validator. ++ * Otherwise the name must be present in the list of oauth_validator_libraries. ++ */ ++bool ++check_oauth_validator(HbaLine *hbaline, int elevel, char **err_msg) ++{ ++ int line_num = hbaline->linenumber; ++ char *file_name = hbaline->sourcefile; ++ char *rawstring; ++ List *elemlist = NIL; ++ ListCell *l; ++ ++ *err_msg = NULL; ++ ++ if (oauth_validator_libraries_string[0] == '\0') ++ { ++ ereport(elevel, ++ errcode(ERRCODE_CONFIG_FILE_ERROR), ++ errmsg("oauth_validator_libraries must be set for authentication method %s", ++ "oauth"), ++ errcontext("line %d of configuration file \"%s\"", ++ line_num, file_name)); ++ *err_msg = psprintf("oauth_validator_libraries must be set for authentication method %s", ++ "oauth"); ++ return false; ++ } ++ ++ /* SplitDirectoriesString needs a modifiable copy */ ++ rawstring = pstrdup(oauth_validator_libraries_string); ++ ++ if (!SplitDirectoriesString(rawstring, ',', &elemlist)) ++ { ++ /* syntax error in list */ ++ ereport(elevel, ++ errcode(ERRCODE_CONFIG_FILE_ERROR), ++ errmsg("invalid list syntax in parameter \"%s\"", ++ "oauth_validator_libraries")); ++ *err_msg = psprintf("invalid list syntax in parameter \"%s\"", ++ "oauth_validator_libraries"); ++ goto done; ++ } ++ ++ if (!hbaline->oauth_validator) ++ { ++ if (elemlist->length == 1) ++ { ++ hbaline->oauth_validator = pstrdup(linitial(elemlist)); ++ goto done; ++ } ++ ++ ereport(elevel, ++ errcode(ERRCODE_CONFIG_FILE_ERROR), ++ errmsg("authentication method \"oauth\" requires argument \"validator\" to be set when oauth_validator_libraries contains multiple options"), ++ errcontext("line %d of configuration file \"%s\"", ++ line_num, file_name)); ++ *err_msg = "authentication method \"oauth\" requires argument \"validator\" to be set when oauth_validator_libraries contains multiple options"; ++ goto done; ++ } ++ ++ foreach(l, elemlist) ++ { ++ char *allowed = lfirst(l); ++ ++ if (strcmp(allowed, hbaline->oauth_validator) == 0) ++ goto done; ++ } ++ ++ ereport(elevel, ++ errcode(ERRCODE_INVALID_PARAMETER_VALUE), ++ errmsg("validator \"%s\" is not permitted by %s", ++ hbaline->oauth_validator, "oauth_validator_libraries"), ++ errcontext("line %d of configuration file \"%s\"", ++ line_num, file_name)); ++ *err_msg = psprintf("validator \"%s\" is not permitted by %s", ++ hbaline->oauth_validator, "oauth_validator_libraries"); ++ ++done: ++ list_free_deep(elemlist); ++ pfree(rawstring); ++ ++ return (*err_msg == NULL); +} ## src/backend/libpq/auth.c ## @@ src/backend/libpq/auth.c: ClientAuthentication(Port *port) if ((status == STATUS_OK && port->hba->clientcert == clientCertFull) ## src/backend/libpq/hba.c ## +@@ + #include "libpq/hba.h" + #include "libpq/ifaddr.h" + #include "libpq/libpq-be.h" ++#include "libpq/oauth.h" + #include "postmaster/postmaster.h" + #include "regex/regex.h" + #include "replication/walsender.h" @@ src/backend/libpq/hba.c: static const char *const UserAuthName[] = "ldap", "cert", @@ src/backend/libpq/hba.c: parse_hba_line(TokenizedAuthLine *tok_line, int elevel) + MANDATORY_AUTH_ARG(parsedline->oauth_scope, "scope", "oauth"); + MANDATORY_AUTH_ARG(parsedline->oauth_issuer, "issuer", "oauth"); + ++ /* Ensure a validator library is set and permitted by the config. */ ++ if (!check_oauth_validator(parsedline, elevel, err_msg)) ++ return NULL; ++ + /* + * Supplying a usermap combined with the option to skip usermapping is + * nonsensical and indicates a configuration error. @@ src/backend/libpq/hba.c: parse_hba_auth_opt(char *name, char *val, HbaLine *hbal + REQUIRE_AUTH_OPTION(uaOAuth, "scope", "oauth"); + hbaline->oauth_scope = pstrdup(val); + } ++ else if (strcmp(name, "validator") == 0) ++ { ++ REQUIRE_AUTH_OPTION(uaOAuth, "validator", "oauth"); ++ hbaline->oauth_validator = pstrdup(val); ++ } + else if (strcmp(name, "trust_validator_authz") == 0) + { + REQUIRE_AUTH_OPTION(uaOAuth, "trust_validator_authz", "oauth"); @@ src/backend/utils/misc/guc_tables.c: struct config_string ConfigureNamesString[] }, + { -+ {"oauth_validator_library", PGC_SIGHUP, CONN_AUTH_AUTH, -+ gettext_noop("Sets the library that will be called to validate OAuth v2 bearer tokens."), ++ {"oauth_validator_libraries", PGC_SIGHUP, CONN_AUTH_AUTH, ++ gettext_noop("Lists libraries that may be called to validate OAuth v2 bearer tokens."), + NULL, -+ GUC_SUPERUSER_ONLY | GUC_NOT_IN_SAMPLE ++ GUC_LIST_INPUT | GUC_LIST_QUOTE | GUC_SUPERUSER_ONLY + }, -+ &OAuthValidatorLibrary, ++ &oauth_validator_libraries_string, + "", + NULL, NULL, NULL + }, @@ src/backend/utils/misc/guc_tables.c: struct config_string ConfigureNamesString[] { {NULL, 0, 0, NULL, NULL}, NULL, NULL, NULL, NULL, NULL + ## src/backend/utils/misc/postgresql.conf.sample ## +@@ + #ssl_passphrase_command = '' + #ssl_passphrase_command_supports_reload = off + ++# OAuth ++#oauth_validator_libraries = '' ++ + + #------------------------------------------------------------------------------ + # RESOURCE USAGE (except WAL) + ## src/include/common/oauth-common.h (new) ## @@ +/*------------------------------------------------------------------------- @@ src/include/libpq/hba.h: typedef struct HbaLine char *radiusports_s; + char *oauth_issuer; + char *oauth_scope; ++ char *oauth_validator; + bool oauth_skip_usermap; } HbaLine; @@ src/include/libpq/oauth.h (new) +#include "libpq/libpq-be.h" +#include "libpq/sasl.h" + -+extern PGDLLIMPORT char *OAuthValidatorLibrary; ++extern PGDLLIMPORT char *oauth_validator_libraries_string; + +typedef struct ValidatorModuleState +{ @@ src/include/libpq/oauth.h (new) +/* Implementation */ +extern const pg_be_sasl_mech pg_be_oauth_mech; + ++/* ++ * Ensure a validator named in the HBA is permitted by the configuration. ++ */ ++extern bool check_oauth_validator(HbaLine *hba, int elevel, char **err_msg); ++ +#endif /* PG_OAUTH_H */ ## src/include/pg_config.h.in ## @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new) + CHECK_SETOPT(actx, popt, protos, return false); + } + ++ /* TODO: would anyone use this in "real" situations, or just testing? */ ++ if (actx->debugging) ++ { ++ const char *env; ++ ++ if ((env = getenv("PGOAUTHCAFILE")) != NULL) ++ CHECK_SETOPT(actx, CURLOPT_CAINFO, env, return false); ++ } ++ + /* + * Suppress the Accept header to make our request as minimal as possible. + * (Ideally we would set it to "application/json" instead, but OpenID is @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new) + + if (actx->running) + { -+ /* ++ /*--- + * There's an async request in progress. Pump the multi handle. + * -+ * TODO: curl_multi_socket_all() is deprecated, presumably because -+ * it's inefficient and pointless if your event loop has already -+ * handed you the exact sockets that are ready. But that's not our use -+ * case -- our client has no way to tell us which sockets are ready. -+ * (They don't even know there are sockets to begin with.) ++ * curl_multi_socket_all() is officially deprecated, because it's ++ * inefficient and pointless if your event loop has already handed you ++ * the exact sockets that are ready. But that's not our use case -- ++ * our client has no way to tell us which sockets are ready. (They ++ * don't even know there are sockets to begin with.) + * + * We can grab the list of triggered events from the multiplexer + * ourselves, but that's effectively what curl_multi_socket_all() is -+ * going to do... so it appears to be exactly the API we need. ++ * going to do. And there are currently no plans for the Curl project ++ * to remove or break this API, so ignore the deprecation. See ++ * ++ * https://curl.se/mail/lib-2024-11/0028.html + * -+ * Ignore the deprecation for now. This needs a followup on -+ * curl-library@, to make sure we're not shooting ourselves in the -+ * foot in some other way. + */ + CURL_IGNORE_DEPRECATION( + err = curl_multi_socket_all(actx->curlm, &actx->running); @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new) + return true; +} + ++/* ++ * Ensure that the discovery document is provided by the expected issuer. ++ * Currently, issuers are statically configured in the connection string. ++ */ ++static bool ++check_issuer(struct async_ctx *actx, PGconn *conn) ++{ ++ const struct provider *provider = &actx->provider; ++ ++ Assert(conn->oauth_issuer_id); /* ensured by setup_oauth_parameters() */ ++ Assert(provider->issuer); /* ensured by parse_provider() */ ++ ++ /*--- ++ * We require strict equality for issuer identifiers -- no path or case ++ * normalization, no substitution of default ports and schemes, etc. This ++ * is done to match the rules in OIDC Discovery Sec. 4.3 for config ++ * validation: ++ * ++ * The issuer value returned MUST be identical to the Issuer URL that ++ * was used as the prefix to /.well-known/openid-configuration to ++ * retrieve the configuration information. ++ * ++ * as well as the rules set out in RFC 9207 for avoiding mix-up attacks: ++ * ++ * Clients MUST then [...] compare the result to the issuer identifier ++ * of the authorization server where the authorization request was ++ * sent to. This comparison MUST use simple string comparison as defined ++ * in Section 6.2.1 of [RFC3986]. ++ * ++ * TODO: Encoding support? ++ */ ++ if (strcmp(conn->oauth_issuer_id, provider->issuer) != 0) ++ { ++ actx_error(actx, ++ "the issuer identifier (%s) does not match oauth_issuer (%s)", ++ provider->issuer, conn->oauth_issuer_id); ++ return false; ++ } ++ ++ return true; ++} ++ +#define OAUTH_GRANT_TYPE_DEVICE_CODE "urn:ietf:params:oauth:grant-type:device_code" + +/* @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new) + const struct curl_slist *grant; + bool device_grant_found = false; + -+ Assert(provider->issuer); /* ensured by get_discovery_document() */ ++ Assert(provider->issuer); /* ensured by parse_provider() */ + + /*------ + * First, sanity checks for discovery contents that are OPTIONAL in the @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new) + const char *device_authz_uri = actx->provider.device_authorization_endpoint; + PQExpBuffer work_buffer = &actx->work_data; + -+ Assert(conn->oauth_client_id); /* ensured by get_auth_token() */ ++ Assert(conn->oauth_client_id); /* ensured by setup_oauth_parameters() */ + Assert(device_authz_uri); /* ensured by check_for_device_flow() */ + + /* Construct our request body. */ @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new) + const char *device_code = actx->authz.device_code; + PQExpBuffer work_buffer = &actx->work_data; + -+ Assert(conn->oauth_client_id); /* ensured by get_auth_token() */ -+ Assert(token_uri); /* ensured by get_discovery_document() */ -+ Assert(device_code); /* ensured by run_device_authz() */ ++ Assert(conn->oauth_client_id); /* ensured by setup_oauth_parameters() */ ++ Assert(token_uri); /* ensured by parse_provider() */ ++ Assert(device_code); /* ensured by parse_device_authz() */ + + /* Construct our request body. */ + resetPQExpBuffer(work_buffer); @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new) + + if (!state->async_ctx) + { -+ const char *env; -+ + /* + * Create our asynchronous state, and hook it into the upper-level + * OAuth state immediately, so any failures below won't leak the @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new) +#endif + + /* Should we enable unsafe features? */ -+ env = getenv("PGOAUTHDEBUG"); -+ if (env && strcmp(env, "UNSAFE") == 0) -+ actx->debugging = true; ++ actx->debugging = oauth_unsafe_debugging_enabled(); + + state->async_ctx = actx; + state->free_async_ctx = free_curl_async_ctx; @@ src/interfaces/libpq/fe-auth-oauth-curl.c (new) + if (!finish_discovery(actx)) + goto error_return; + -+ /* TODO: check issuer */ ++ if (!check_issuer(actx, conn)) ++ goto error_return; + + actx->errctx = "cannot run OAuth device authorization"; + if (!check_for_device_flow(actx)) @@ src/interfaces/libpq/fe-auth-oauth.c (new) + * TODO: users shouldn't see this; what action should they take if + * they do? + */ -+ libpq_append_conn_error(conn, "no OAuth token was set for the connection"); ++ libpq_append_conn_error(conn, ++ "no OAuth token was set for the connection"); + return NULL; + } + } @@ src/interfaces/libpq/fe-auth-oauth.c (new) + return oauth_json_has_error(ctx) ? JSON_SEM_ACTION_FAILED : JSON_SUCCESS; +} + ++#define HTTPS_SCHEME "https://" ++#define HTTP_SCHEME "http://" ++ ++/* We support both well-known suffixes defined by RFC 8414. */ ++#define WK_PREFIX "/.well-known/" ++#define OPENID_WK_SUFFIX "openid-configuration" ++#define OAUTH_WK_SUFFIX "oauth-authorization-server" ++ ++/* ++ * Derives an issuer identifier from one of our recognized .well-known URIs, ++ * using the rules in RFC 8414. ++ */ ++static char * ++issuer_from_well_known_uri(PGconn *conn, const char *wkuri) ++{ ++ const char *authority_start = NULL; ++ const char *wk_start; ++ const char *wk_end; ++ char *issuer; ++ ptrdiff_t start_offset, ++ end_offset; ++ size_t end_len; ++ ++ /* ++ * https:// is required for issuer identifiers (RFC 8414, Sec. 2; OIDC ++ * Discovery 1.0, Sec. 3). This is a case-insensitive comparison at this ++ * level (but issuer identifier comparison at the level above this is ++ * case-sensitive, so in practice it's probably moot). ++ */ ++ if (pg_strncasecmp(wkuri, HTTPS_SCHEME, strlen(HTTPS_SCHEME)) == 0) ++ authority_start = wkuri + strlen(HTTPS_SCHEME); ++ ++ if (!authority_start ++ && oauth_unsafe_debugging_enabled() ++ && pg_strncasecmp(wkuri, HTTP_SCHEME, strlen(HTTP_SCHEME)) == 0) ++ { ++ /* Allow http:// for testing only. */ ++ authority_start = wkuri + strlen(HTTP_SCHEME); ++ } ++ ++ if (!authority_start) ++ { ++ libpq_append_conn_error(conn, ++ "OAuth discovery URI \"%s\" must use HTTPS", ++ wkuri); ++ return NULL; ++ } ++ ++ /* ++ * Well-known URIs in general may support queries and fragments, but the ++ * two types we support here do not. (They must be constructed from the ++ * components of issuer identifiers, which themselves may not contain any ++ * queries or fragments.) ++ * ++ * It's important to check this first, to avoid getting tricked later by a ++ * prefix buried inside a query or fragment. ++ */ ++ if (strpbrk(authority_start, "?#") != NULL) ++ { ++ libpq_append_conn_error(conn, ++ "OAuth discovery URI \"%s\" must not contain query or fragment components", ++ wkuri); ++ return NULL; ++ } ++ ++ /* ++ * Find the start of the .well-known prefix. IETF rules state this must be ++ * at the beginning of the path component, but OIDC defined it at the end ++ * instead, so we have to search for it anywhere. ++ */ ++ wk_start = strstr(authority_start, WK_PREFIX); ++ if (!wk_start) ++ { ++ libpq_append_conn_error(conn, ++ "OAuth discovery URI \"%s\" is not a .well-known URI", ++ wkuri); ++ return NULL; ++ } ++ ++ /* ++ * Now find the suffix type. We only support the two defined in OIDC ++ * Discovery 1.0 and RFC 8414. ++ */ ++ wk_end = wk_start + strlen(WK_PREFIX); ++ ++ if (strncmp(wk_end, OPENID_WK_SUFFIX, strlen(OPENID_WK_SUFFIX)) == 0) ++ wk_end += strlen(OPENID_WK_SUFFIX); ++ else if (strncmp(wk_end, OAUTH_WK_SUFFIX, strlen(OAUTH_WK_SUFFIX)) == 0) ++ wk_end += strlen(OAUTH_WK_SUFFIX); ++ else ++ wk_end = NULL; ++ ++ /* ++ * Even if there's a match, we still need to check to make sure the suffix ++ * takes up the entire path segment, to weed out constructions like ++ * "/.well-known/openid-configuration-bad". ++ */ ++ if (!wk_end || (*wk_end != '/' && *wk_end != '\0')) ++ { ++ libpq_append_conn_error(conn, ++ "OAuth discovery URI \"%s\" uses an unsupported .well-known suffix", ++ wkuri); ++ return NULL; ++ } ++ ++ /* ++ * Finally, make sure the .well-known components are provided either as a ++ * prefix (IETF style) or as a postfix (OIDC style). In other words, ++ * "https://localhost/a/.well-known/openid-configuration/b" is not allowed ++ * to claim association with "https://localhost/a/b". ++ */ ++ if (*wk_end != '\0') ++ { ++ /* ++ * It's not at the end, so it's required to be at the beginning at the ++ * path. Find the starting slash. ++ */ ++ const char *path_start; ++ ++ path_start = strchr(authority_start, '/'); ++ Assert(path_start); /* otherwise we wouldn't have found WK_PREFIX */ ++ ++ if (wk_start != path_start) ++ { ++ libpq_append_conn_error(conn, ++ "OAuth discovery URI \"%s\" uses an invalid format", ++ wkuri); ++ return NULL; ++ } ++ } ++ ++ /* Checks passed! Now build the issuer. */ ++ issuer = strdup(wkuri); ++ if (!issuer) ++ { ++ libpq_append_conn_error(conn, "out of memory"); ++ return NULL; ++ } ++ ++ /* ++ * The .well-known components are from [wk_start, wk_end). Remove those to ++ * form the issuer ID, by shifting the path suffix (which may be empty) ++ * leftwards. ++ */ ++ start_offset = wk_start - wkuri; ++ end_offset = wk_end - wkuri; ++ end_len = strlen(wk_end) + 1; /* move the NULL terminator too */ ++ ++ memmove(issuer + start_offset, issuer + end_offset, end_len); ++ ++ return issuer; ++} ++ +static bool +handle_oauth_sasl_error(PGconn *conn, char *msg, int msglen) +{ @@ src/interfaces/libpq/fe-auth-oauth.c (new) + /* Sanity check. */ + if (strlen(msg) != msglen) + { -+ appendPQExpBufferStr(&conn->errorMessage, -+ libpq_gettext("server's error message contained an embedded NULL, and was discarded")); ++ libpq_append_conn_error(conn, ++ "server's error message contained an embedded NULL, and was discarded"); + return false; + } + @@ src/interfaces/libpq/fe-auth-oauth.c (new) + errmsg = json_errdetail(err, &lex); + + if (errmsg) -+ appendPQExpBuffer(&conn->errorMessage, -+ libpq_gettext("failed to parse server's error response: %s"), -+ errmsg); ++ libpq_append_conn_error(conn, ++ "failed to parse server's error response: %s", ++ errmsg); + + /* Don't need the error buffer or the JSON lexer anymore. */ + termPQExpBuffer(&ctx.errbuf); @@ src/interfaces/libpq/fe-auth-oauth.c (new) + return false; + + /* 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; ++ ++ /* The URI must correspond to our existing issuer, to avoid mix-ups. */ ++ discovery_issuer = issuer_from_well_known_uri(conn, ctx.discovery_uri); ++ if (!discovery_issuer) ++ return false; /* error message already set */ ++ ++ if (strcmp(conn->oauth_issuer_id, discovery_issuer) != 0) ++ { ++ libpq_append_conn_error(conn, ++ "server's discovery document at %s (issuer \"%s\") is incompatible with oauth_issuer (%s)", ++ ctx.discovery_uri, discovery_issuer, ++ conn->oauth_issuer_id); ++ ++ free(discovery_issuer); ++ return false; ++ } ++ ++ free(discovery_issuer); ++ + if (conn->oauth_discovery_uri) + free(conn->oauth_discovery_uri); + @@ src/interfaces/libpq/fe-auth-oauth.c (new) + + if (!ctx.status) + { -+ appendPQExpBuffer(&conn->errorMessage, -+ libpq_gettext("server sent error response without a status")); ++ libpq_append_conn_error(conn, ++ "server sent error response without a status"); + return false; + } + @@ src/interfaces/libpq/fe-auth-oauth.c (new) + + if (!request->async) + { -+ libpq_append_conn_error(conn, "user-defined OAuth flow provided neither a token nor an async callback"); ++ libpq_append_conn_error(conn, ++ "user-defined OAuth flow provided neither a token nor an async callback"); + return PGRES_POLLING_FAILED; + } + @@ src/interfaces/libpq/fe-auth-oauth.c (new) + */ + if (!request->token) + { -+ libpq_append_conn_error(conn, "user-defined OAuth flow did not provide a token"); ++ libpq_append_conn_error(conn, ++ "user-defined OAuth flow did not provide a token"); + return PGRES_POLLING_FAILED; + } + @@ src/interfaces/libpq/fe-auth-oauth.c (new) + return false; +} + ++/* ++ * Fill in our issuer identifier and discovery URI, if possible, using the ++ * connection parameters. If conn->oauth_discovery_uri can't be populated in ++ * this function, it will be requested from the server. ++ */ +static bool -+derive_discovery_uri(PGconn *conn) ++setup_oauth_parameters(PGconn *conn) +{ -+ PQExpBufferData discovery_buf; -+ -+ if (conn->oauth_discovery_uri || !conn->oauth_issuer) ++ /*--- ++ * To talk to a server, we require the user to provide issuer and client ++ * identifiers. ++ * ++ * While it's possible for an OAuth client to support multiple issuers, it ++ * requires additional effort to make sure the flows in use are safe -- to ++ * quote RFC 9207, ++ * ++ * OAuth clients that interact with only one authorization server are ++ * not vulnerable to mix-up attacks. However, when such clients decide ++ * to add support for a second authorization server in the future, they ++ * become vulnerable and need to apply countermeasures to mix-up ++ * attacks. ++ * ++ * For now, we allow only one. ++ */ ++ if (!conn->oauth_issuer || !conn->oauth_client_id) + { -+ /* -+ * Either we already have one, or we aren't able to derive one -+ * ourselves. The latter case is not an error condition; we'll just -+ * ask the server to provide one for us. -+ */ -+ return true; ++ libpq_append_conn_error(conn, ++ "server requires OAuth authentication, but oauth_issuer and oauth_client_id are not both set"); ++ return false; + } + -+ initPQExpBuffer(&discovery_buf); -+ -+ Assert(!conn->oauth_discovery_uri); -+ Assert(conn->oauth_issuer); -+ + /* -+ * If we don't yet have a discovery URI, but the user gave us an explicit -+ * issuer, use the .well-known discovery URI for that issuer. ++ * oauth_issuer is interpreted differently if it's a well-known discovery ++ * URI rather than just an issuer identifier. + */ -+ appendPQExpBufferStr(&discovery_buf, conn->oauth_issuer); -+ appendPQExpBufferStr(&discovery_buf, "/.well-known/openid-configuration"); -+ -+ if (PQExpBufferDataBroken(discovery_buf)) -+ goto cleanup; ++ if (strstr(conn->oauth_issuer, WK_PREFIX) != NULL) ++ { ++ /* ++ * Convert the URI back to an issuer identifier. (This also performs ++ * validation of the URI format.) ++ */ ++ conn->oauth_issuer_id = issuer_from_well_known_uri(conn, ++ conn->oauth_issuer); ++ if (!conn->oauth_issuer_id) ++ return false; /* error message already set */ + -+ conn->oauth_discovery_uri = strdup(discovery_buf.data); ++ conn->oauth_discovery_uri = strdup(conn->oauth_issuer); ++ if (!conn->oauth_discovery_uri) ++ { ++ libpq_append_conn_error(conn, "out of memory"); ++ return false; ++ } ++ } ++ else ++ { ++ /* ++ * Treat oauth_issuer as an issuer identifier. We'll ask the server ++ * for the discovery URI. ++ */ ++ conn->oauth_issuer_id = strdup(conn->oauth_issuer); ++ if (!conn->oauth_issuer_id) ++ { ++ libpq_append_conn_error(conn, "out of memory"); ++ return false; ++ } ++ } + -+cleanup: -+ termPQExpBuffer(&discovery_buf); -+ return (conn->oauth_discovery_uri != NULL); ++ return true; +} + +static SASLStatus @@ src/interfaces/libpq/fe-auth-oauth.c (new) + /* We begin in the initial response phase. */ + Assert(inputlen == -1); + -+ if (!derive_discovery_uri(conn)) ++ if (!setup_oauth_parameters(conn)) + return SASL_FAILED; + + if (conn->oauth_discovery_uri) + { -+ if (!conn->oauth_client_id) -+ { -+ /* We can't talk to a server without a client identifier. */ -+ libpq_append_conn_error(conn, "no oauth_client_id is set for the connection"); -+ return SASL_FAILED; -+ } -+ + /* + * Decide whether we're using a user-provided OAuth flow, or + * the one we have built in. @@ src/interfaces/libpq/fe-auth-oauth.c (new) + * successfully after telling us it was going to fail. Neither is + * acceptable. + */ -+ appendPQExpBufferStr(&conn->errorMessage, -+ libpq_gettext("server sent additional OAuth data after error\n")); ++ libpq_append_conn_error(conn, ++ "server sent additional OAuth data after error"); + return SASL_FAILED; + + default: -+ appendPQExpBufferStr(&conn->errorMessage, -+ libpq_gettext("invalid OAuth exchange state\n")); ++ libpq_append_conn_error(conn, "invalid OAuth exchange state"); + break; + } + @@ src/interfaces/libpq/fe-auth-oauth.c (new) + state->free_async_ctx(state->conn, state->async_ctx); + + free(state); ++} ++ ++/* ++ * Returns true if the PGOAUTHDEBUG=UNSAFE flag is set in the environment. ++ */ ++bool ++oauth_unsafe_debugging_enabled(void) ++{ ++ const char *env = getenv("PGOAUTHDEBUG"); ++ ++ return (env && strcmp(env, "UNSAFE") == 0); +} ## src/interfaces/libpq/fe-auth-oauth.h (new) ## @@ src/interfaces/libpq/fe-auth-oauth.h (new) +} fe_oauth_state; + +extern PostgresPollingStatusType pg_fe_run_oauth_flow(PGconn *conn, pgsocket *altsock); ++extern bool oauth_unsafe_debugging_enabled(void); + +#endif /* FE_AUTH_OAUTH_H */ @@ src/interfaces/libpq/fe-connect.c: freePGconn(PGconn *conn) free(conn->target_session_attrs); free(conn->load_balance_hosts); + free(conn->oauth_issuer); ++ free(conn->oauth_issuer_id); + free(conn->oauth_discovery_uri); + free(conn->oauth_client_id); + free(conn->oauth_client_secret); @@ src/interfaces/libpq/libpq-int.h: struct pg_conn * connection that's used for queries */ + /* OAuth v2 */ -+ char *oauth_issuer; /* token issuer URL */ ++ char *oauth_issuer; /* token issuer/URL */ ++ char *oauth_issuer_id; /* token issuer identifier */ + char *oauth_discovery_uri; /* URI of the issuer's discovery + * document */ + char *oauth_client_id; /* client identifier */ @@ src/test/modules/oauth_validator/Makefile (new) +# +#------------------------------------------------------------------------- + -+MODULES = validator ++MODULES = validator fail_validator +PGFILEDESC = "validator - test OAuth validator module" + +PROGRAM = oauth_hook_client @@ src/test/modules/oauth_validator/Makefile (new) + +endif + ## src/test/modules/oauth_validator/fail_validator.c (new) ## +@@ ++/*------------------------------------------------------------------------- ++ * ++ * fail_validator.c ++ * Test module for serverside OAuth token validation callbacks, which always ++ * fails ++ * ++ * Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group ++ * Portions Copyright (c) 1994, Regents of the University of California ++ * ++ * src/test/modules/oauth_validator/fail_validator.c ++ * ++ *------------------------------------------------------------------------- ++ */ ++ ++#include "postgres.h" ++ ++#include "fmgr.h" ++#include "libpq/oauth.h" ++#include "miscadmin.h" ++#include "utils/guc.h" ++#include "utils/memutils.h" ++ ++PG_MODULE_MAGIC; ++ ++static ValidatorModuleResult *fail_token(ValidatorModuleState *state, ++ const char *token, ++ const char *role); ++ ++static const OAuthValidatorCallbacks validator_callbacks = { ++ .validate_cb = fail_token, ++}; ++ ++const OAuthValidatorCallbacks * ++_PG_oauth_validator_module_init(void) ++{ ++ return &validator_callbacks; ++} ++ ++static ValidatorModuleResult * ++fail_token(ValidatorModuleState *state, const char *token, const char *role) ++{ ++ elog(FATAL, "fail_validator: sentinel error"); ++ pg_unreachable(); ++} + ## src/test/modules/oauth_validator/meson.build (new) ## @@ +# Copyright (c) 2024, PostgreSQL Global Development Group @@ src/test/modules/oauth_validator/meson.build (new) +) +test_install_libs += validator + ++fail_validator_sources = files( ++ 'fail_validator.c', ++) ++ ++if host_system == 'windows' ++ fail_validator_sources += rc_lib_gen.process(win32ver_rc, extra_args: [ ++ '--NAME', 'fail_validator', ++ '--FILEDESC', 'fail_validator - failing OAuth validator module',]) ++endif ++ ++fail_validator = shared_module('fail_validator', ++ fail_validator_sources, ++ kwargs: pg_test_mod_args, ++) ++test_install_libs += fail_validator ++ +oauth_hook_client_sources = files( + 'oauth_hook_client.c', +) @@ src/test/modules/oauth_validator/t/001_server.pl (new) +use strict; +use warnings FATAL => 'all'; + -+use JSON::PP qw(encode_json); ++use JSON::PP qw(encode_json); +use MIME::Base64 qw(encode_base64); +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; @@ src/test/modules/oauth_validator/t/001_server.pl (new) +my $node = PostgreSQL::Test::Cluster->new('primary'); +$node->init; +$node->append_conf('postgresql.conf', "log_connections = on\n"); -+$node->append_conf('postgresql.conf', "shared_preload_libraries = 'validator'\n"); -+$node->append_conf('postgresql.conf', "oauth_validator_library = 'validator'\n"); ++$node->append_conf('postgresql.conf', ++ "oauth_validator_libraries = 'validator'\n"); +$node->start; + +$node->safe_psql('postgres', 'CREATE USER test;'); @@ src/test/modules/oauth_validator/t/001_server.pl (new) +{ + my $exit_code = $?; + -+ $webserver->stop() if defined $webserver; # might have been SKIP'd ++ $webserver->stop() if defined $webserver; # might have been SKIP'd + + $? = $exit_code; +} + +my $port = $webserver->port(); -+my $issuer = "127.0.0.1:$port"; ++my $issuer = "http://localhost:$port"; + +unlink($node->data_dir . '/pg_hba.conf'); -+$node->append_conf('pg_hba.conf', qq{ -+local all test oauth issuer="$issuer" scope="openid postgres" -+local all testalt oauth issuer="$issuer/alternate" scope="openid postgres alt" -+local all testparam oauth issuer="$issuer/param" scope="openid postgres" ++$node->append_conf( ++ 'pg_hba.conf', qq{ ++local all test oauth issuer="$issuer" scope="openid postgres" ++local all testalt oauth issuer="$issuer/.well-known/oauth-authorization-server/alternate" scope="openid postgres alt" ++local all testparam oauth issuer="$issuer/param" scope="openid postgres" +}); +$node->reload; + @@ src/test/modules/oauth_validator/t/001_server.pl (new) + +# To test against HTTP rather than HTTPS, we need to enable PGOAUTHDEBUG. But +# first, check to make sure the client refuses such connections by default. -+$node->connect_fails("user=test dbname=postgres oauth_client_id=f02c6361-0635", -+ "HTTPS is required without debug mode", -+ expected_stderr => qr/failed to fetch OpenID discovery document: Unsupported protocol/); ++$node->connect_fails( ++ "user=test dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635", ++ "HTTPS is required without debug mode", ++ expected_stderr => ++ qr@OAuth discovery URI "\Q$issuer\E/.well-known/openid-configuration" must use HTTPS@ ++); + +$ENV{PGOAUTHDEBUG} = "UNSAFE"; + +my $user = "test"; -+if ($node->connect_ok("user=$user dbname=postgres oauth_client_id=f02c6361-0635", "connect", -+ expected_stderr => qr@Visit https://example\.com/ and enter the code: postgresuser@)) ++if ($node->connect_ok( ++ "user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635", ++ "connect", ++ expected_stderr => ++ qr@Visit https://example\.com/ and enter the code: postgresuser@)) +{ + $log_end = $node->wait_for_log(qr/connection authorized/, $log_start); -+ $node->log_check("user $user: validator receives correct parameters", $log_start, -+ log_like => [ -+ qr/oauth_validator: token="9243959234", role="$user"/, -+ qr/oauth_validator: issuer="\Q$issuer\E", scope="openid postgres"/, -+ ]); -+ $node->log_check("user $user: validator sets authenticated identity", $log_start, -+ log_like => [ -+ qr/connection authenticated: identity="test" method=oauth/, -+ ]); ++ $node->log_check( ++ "user $user: validator receives correct parameters", ++ $log_start, ++ log_like => [ ++ qr/oauth_validator: token="9243959234", role="$user"/, ++ qr/oauth_validator: issuer="\Q$issuer\E", scope="openid postgres"/, ++ ]); ++ $node->log_check( ++ "user $user: validator sets authenticated identity", ++ $log_start, ++ log_like => ++ [ qr/connection authenticated: identity="test" method=oauth/, ]); + $log_start = $log_end; +} + -+# The /alternate issuer uses slightly different parameters. ++# The /alternate issuer uses slightly different parameters, along with an ++# OAuth-style discovery document. +$user = "testalt"; -+if ($node->connect_ok("user=$user dbname=postgres oauth_client_id=f02c6361-0636", "connect", -+ expected_stderr => qr@Visit https://example\.org/ and enter the code: postgresuser@)) ++if ($node->connect_ok( ++ "user=$user dbname=postgres oauth_issuer=$issuer/alternate oauth_client_id=f02c6361-0636", ++ "connect", ++ expected_stderr => ++ qr@Visit https://example\.org/ and enter the code: postgresuser@)) +{ + $log_end = $node->wait_for_log(qr/connection authorized/, $log_start); -+ $node->log_check("user $user: validator receives correct parameters", $log_start, -+ log_like => [ -+ qr/oauth_validator: token="9243959234-alt", role="$user"/, -+ qr|oauth_validator: issuer="\Q$issuer/alternate\E", scope="openid postgres alt"|, -+ ]); -+ $node->log_check("user $user: validator sets authenticated identity", $log_start, -+ log_like => [ -+ qr/connection authenticated: identity="testalt" method=oauth/, -+ ]); ++ $node->log_check( ++ "user $user: validator receives correct parameters", ++ $log_start, ++ log_like => [ ++ qr/oauth_validator: token="9243959234-alt", role="$user"/, ++ qr|oauth_validator: issuer="\Q$issuer/alternate\E", scope="openid postgres alt"|, ++ ]); ++ $node->log_check( ++ "user $user: validator sets authenticated identity", ++ $log_start, ++ log_like => ++ [ qr/connection authenticated: identity="testalt" method=oauth/, ]); + $log_start = $log_end; +} + ++# The issuer linked by the server must match the client's oauth_issuer setting. ++$node->connect_fails( ++ "user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0636", ++ "oauth_issuer must match discovery", ++ expected_stderr => ++ 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\)@ ++); ++ +# 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_client_id='$vschars_esc'", ++ "user=$user dbname=postgres oauth_issuer=$issuer/alternate oauth_client_id='$vschars_esc'", + "escapable characters: client_id", + expected_stderr => + qr@Visit https://example\.org/ and enter the code: postgresuser@); +$node->connect_ok( -+ "user=$user dbname=postgres oauth_client_id='$vschars_esc' oauth_client_secret='$vschars_esc'", ++ "user=$user dbname=postgres oauth_issuer=$issuer/alternate 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@); @@ src/test/modules/oauth_validator/t/001_server.pl (new) +# oauth_client_id. +# + -+my $common_connstr = "user=testparam dbname=postgres "; ++my $common_connstr = ++ "user=testparam dbname=postgres oauth_issuer=$issuer/param "; +my $base_connstr = $common_connstr; + +sub connstr @@ src/test/modules/oauth_validator/t/001_server.pl (new) +$node->connect_ok( + connstr(), + "connect to /param", -+ expected_stderr => qr@Visit https://example\.com/ and enter the code: postgresuser@ -+); ++ expected_stderr => ++ qr@Visit https://example\.com/ and enter the code: postgresuser@); + +$node->connect_ok( + connstr(stage => 'token', retries => 1), + "token retry", -+ expected_stderr => qr@Visit https://example\.com/ and enter the code: postgresuser@ -+); ++ expected_stderr => ++ qr@Visit https://example\.com/ and enter the code: postgresuser@); +$node->connect_ok( + connstr(stage => 'token', retries => 2), + "token retry (twice)", -+ expected_stderr => qr@Visit https://example\.com/ and enter the code: postgresuser@ -+); ++ expected_stderr => ++ qr@Visit https://example\.com/ and enter the code: postgresuser@); +$node->connect_ok( + connstr(stage => 'all', retries => 1, interval => 2), + "token retry (two second interval)", -+ expected_stderr => qr@Visit https://example\.com/ and enter the code: postgresuser@ -+); ++ expected_stderr => ++ qr@Visit https://example\.com/ and enter the code: postgresuser@); +$node->connect_ok( + connstr(stage => 'all', retries => 1, interval => JSON::PP::null), + "token retry (default interval)", -+ expected_stderr => qr@Visit https://example\.com/ and enter the code: postgresuser@ -+); ++ expected_stderr => ++ qr@Visit https://example\.com/ and enter the code: postgresuser@); + +$node->connect_ok( + connstr(stage => 'all', content_type => 'application/json;charset=utf-8'), + "content type with charset", -+ expected_stderr => qr@Visit https://example\.com/ and enter the code: postgresuser@ -+); ++ expected_stderr => ++ qr@Visit https://example\.com/ and enter the code: postgresuser@); +$node->connect_ok( -+ connstr(stage => 'all', content_type => "application/json \t;\t charset=utf-8"), ++ connstr( ++ stage => 'all', ++ content_type => "application/json \t;\t charset=utf-8"), + "content type with charset (whitespace)", -+ expected_stderr => qr@Visit https://example\.com/ and enter the code: postgresuser@ -+); ++ expected_stderr => ++ qr@Visit https://example\.com/ and enter the code: postgresuser@); +$node->connect_ok( + connstr(stage => 'device', uri_spelling => "verification_url"), + "alternative spelling of verification_uri", -+ expected_stderr => qr@Visit https://example\.com/ and enter the code: postgresuser@ -+); ++ expected_stderr => ++ qr@Visit https://example\.com/ and enter the code: postgresuser@); + +$node->connect_fails( + connstr(stage => 'device', huge_response => JSON::PP::true), + "bad device authz response: overlarge JSON", -+ expected_stderr => qr/failed to obtain device authorization: response is too large/ -+); ++ expected_stderr => ++ qr/failed to obtain device authorization: response is too large/); +$node->connect_fails( + connstr(stage => 'token', huge_response => JSON::PP::true), + "bad token response: overlarge JSON", -+ expected_stderr => qr/failed to obtain access token: response is too large/ -+); ++ expected_stderr => ++ qr/failed to obtain access token: response is too large/); + +$node->connect_fails( + connstr(stage => 'device', content_type => 'text/plain'), + "bad device authz response: wrong content type", -+ expected_stderr => qr/failed to parse device authorization: unexpected content type/ -+); ++ expected_stderr => ++ qr/failed to parse device authorization: unexpected content type/); +$node->connect_fails( + connstr(stage => 'token', content_type => 'text/plain'), + "bad token response: wrong content type", -+ expected_stderr => qr/failed to parse access token response: unexpected content type/ -+); ++ expected_stderr => ++ qr/failed to parse access token response: unexpected content type/); +$node->connect_fails( + connstr(stage => 'token', content_type => 'application/jsonx'), + "bad token response: wrong content type (correct prefix)", -+ expected_stderr => qr/failed to parse access token response: unexpected content type/ -+); ++ expected_stderr => ++ qr/failed to parse access token response: unexpected content type/); + +$node->connect_fails( -+ connstr(stage => 'all', interval => ~0, retries => 1, retry_code => "slow_down"), ++ connstr( ++ stage => 'all', ++ interval => ~0, ++ retries => 1, ++ retry_code => "slow_down"), + "bad token response: server overflows the device authz interval", -+ expected_stderr => qr/failed to obtain access token: slow_down interval overflow/ -+); ++ expected_stderr => ++ qr/failed to obtain access token: slow_down interval overflow/); + +$node->connect_fails( + connstr(stage => 'token', error_code => "invalid_grant"), @@ src/test/modules/oauth_validator/t/001_server.pl (new) +# + +# Searching the logs is easier if OAuth parameter discovery isn't cluttering -+# things up; hardcode the issuer. (Scope is hardcoded to empty to cover that -+# case as well.) ++# things up; hardcode the discovery URI. (Scope is hardcoded to empty to cover ++# that case as well.) +$common_connstr = -+ "user=test dbname=postgres oauth_issuer=$issuer oauth_scope=''"; ++ "user=test dbname=postgres oauth_issuer=$issuer/.well-known/openid-configuration oauth_scope=''"; + +$bgconn->query_safe("ALTER SYSTEM SET oauth_validator.authn_id TO ''"); +$node->reload; @@ src/test/modules/oauth_validator/t/001_server.pl (new) +$log_start = + $node->wait_for_log(qr/reloading configuration files/, $log_start); + ++# ++# Test multiple validators. ++# ++ ++$node->append_conf('postgresql.conf', ++ "oauth_validator_libraries = 'validator, fail_validator'\n"); ++ ++# With multiple validators, every HBA line must explicitly declare one. ++my $result = $node->restart(fail_ok => 1); ++is($result, 0, ++ 'restart fails without explicit validators in oauth HBA entries'); ++ ++$log_start = $node->wait_for_log( ++ qr/authentication method "oauth" requires argument "validator" to be set/, ++ $log_start); ++ ++unlink($node->data_dir . '/pg_hba.conf'); ++$node->append_conf( ++ 'pg_hba.conf', qq{ ++local all test oauth validator=validator issuer="$issuer" scope="openid postgres" ++local all testalt oauth validator=fail_validator issuer="$issuer/alternate" scope="openid postgres alt" ++}); ++$node->restart; ++ ++$log_start = $node->wait_for_log(qr/ready to accept connections/, $log_start); ++ ++# The test user should work as before. ++$user = "test"; ++if ($node->connect_ok( ++ "user=$user dbname=postgres oauth_issuer=$issuer oauth_client_id=f02c6361-0635", ++ "validator is used for $user", ++ expected_stderr => ++ qr@Visit https://example\.com/ and enter the code: postgresuser@)) ++{ ++ $log_start = $node->wait_for_log(qr/connection authorized/, $log_start); ++} ++ ++# testalt should be routed through the fail_validator. ++$user = "testalt"; ++$node->connect_fails( ++ "user=$user dbname=postgres oauth_issuer=$issuer/.well-known/oauth-authorization-server/alternate oauth_client_id=f02c6361-0636", ++ "fail_validator is used for $user", ++ expected_stderr => qr/FATAL:\s+fail_validator: sentinel error/); ++ +$node->stop; + +done_testing(); @@ src/test/modules/oauth_validator/t/002_client.pl (new) +use MIME::Base64 qw(encode_base64); +use PostgreSQL::Test::Cluster; +use PostgreSQL::Test::Utils; -+use PostgreSQL::Test::OAuthServer; +use Test::More; + +if (!$ENV{PG_TEST_EXTRA} || $ENV{PG_TEST_EXTRA} !~ /\boauth\b/) @@ src/test/modules/oauth_validator/t/002_client.pl (new) +$node->init; +$node->append_conf('postgresql.conf', "log_connections = on\n"); +$node->append_conf('postgresql.conf', -+ "oauth_validator_library = 'validator'\n"); ++ "oauth_validator_libraries = 'validator'\n"); +$node->start; + +$node->safe_psql('postgres', 'CREATE USER test;'); + -+my $issuer = "https://127.0.0.1:54321"; ++# These tests don't use the builtin flow, and we don't have an authorization ++# server running, so the address used here shouldn't matter. Use an invalid IP ++# address, so if there's some cascade of errors that causes the client to ++# attempt a connection, we'll fail noisily. ++my $issuer = "https://256.256.256.256"; +my $scope = "openid postgres"; + +unlink($node->data_dir . '/pg_hba.conf'); @@ src/test/modules/oauth_validator/t/002_client.pl (new) + +my $user = "test"; +my $base_connstr = $node->connstr() . " user=$user"; -+my $common_connstr = "$base_connstr oauth_client_id=myID"; ++my $common_connstr = ++ "$base_connstr oauth_issuer=$issuer oauth_client_id=myID"; + +sub test +{ @@ src/test/modules/oauth_validator/t/oauth_server.py (new) + """ + Switches the behavior of the provider depending on the issuer URI. + """ -+ self._alt_issuer = self.path.startswith("/alternate/") ++ self._alt_issuer = ( ++ self.path.startswith("/alternate/") ++ or self.path == "/.well-known/oauth-authorization-server/alternate" ++ ) + self._parameterized = self.path.startswith("/param/") + + if self._alt_issuer: -+ self.path = self.path.removeprefix("/alternate") ++ # The /alternate issuer uses IETF-style .well-known URIs. ++ if self.path.startswith("/.well-known/"): ++ self.path = self.path.removesuffix("/alternate") ++ else: ++ self.path = self.path.removeprefix("/alternate") + elif self._parameterized: + self.path = self.path.removeprefix("/param") + @@ src/test/modules/oauth_validator/t/oauth_server.py (new) + self._response_code = 200 + self._check_issuer() + -+ if self.path == "/.well-known/openid-configuration": ++ config_path = "/.well-known/openid-configuration" ++ if self._alt_issuer: ++ config_path = "/.well-known/oauth-authorization-server" ++ ++ if self.path == config_path: + resp = self.config() + else: + self.send_error(404, "Not Found") 2: 01df79980b ! 2: 3d169848db DO NOT MERGE: Add pytest suite for OAuth @@ .cirrus.tasks.yml: task: matrix: - name: Linux - Debian Bookworm - Autoconf @@ .cirrus.tasks.yml: task: - - # Also build & test in a 32bit build - it's gotten rare to test that - # locally. + # can easily provide some here by running one of the sets of tests that + # way. Newer versions of python insist on changing the LC_CTYPE away + # from C, prevent that with PYTHONCOERCECLOCALE. + # XXX 32-bit Python tests are currently disabled, as the system's 64-bit + # Python modules can't link against libpq. - configure_32_script: | + test_world_32_script: | su postgres <<-EOF - export CC='ccache gcc -m32' + export PG_TEST_EXTRA="${PG_TEST_EXTRA//python}" - meson setup \ - --buildtype=debug \ - -Dcassert=true -Dinjection_points=true \ + ulimit -c unlimited + PYTHONCOERCECLOCALE=0 LANG=C meson test $MTEST_ARGS -C build-32 --num-processes ${TEST_JOBS} + EOF ## meson.build ## @@ meson.build: else @@ meson.build: foreach test_dir : tests + env.set(name, value) + endforeach + -+ if get_option('PG_TEST_EXTRA').contains('python') -+ reqs = files(t['requirements']) -+ test('install_' + venv_name, -+ python, -+ args: [ make_venv, '--requirements', reqs, venv_path ], -+ env: env, -+ priority: setup_tests_priority - 1, # must run after tmp_install -+ is_parallel: false, -+ suite: ['setup'], -+ timeout: 60, # 30s is too short for the cryptography package compile -+ ) -+ endif ++ reqs = files(t['requirements']) ++ test('install_' + venv_name, ++ python, ++ args: [ make_venv, '--requirements', reqs, venv_path ], ++ env: env, ++ priority: setup_tests_priority - 1, # must run after tmp_install ++ is_parallel: false, ++ suite: ['setup'], ++ timeout: 60, # 30s is too short for the cryptography package compile ++ ) + + test_group = test_dir['name'] + test_output = test_result_dir / test_group / kind @@ meson.build: foreach test_dir : tests + 'timeout': 1000, + 'depends': test_deps, + 'env': env, -+ } ++ } + t.get('test_kwargs', {}) + + if fs.is_dir(venv_path / 'Scripts') + # Windows virtualenv layout @@ meson.build: foreach test_dir : tests + testwrap_pytest = testwrap_base + [ + '--testgroup', test_group, + '--testname', pyt_p, ++ '--skip-without-extra', 'python', + ] -+ if not get_option('PG_TEST_EXTRA').contains('python') -+ testwrap_pytest += ['--skip', '"python" tests not enabled in PG_TEST_EXTRA'] -+ endif + + test(test_group / pyt_p, + python, @@ src/test/python/client/conftest.py (new) +# + +import contextlib ++import datetime ++import functools ++import ipaddress ++import os +import socket +import sys +import threading @@ src/test/python/client/conftest.py (new) +import psycopg2 +import psycopg2.extras +import pytest ++from cryptography import x509 ++from cryptography.hazmat.primitives import hashes, serialization ++from cryptography.hazmat.primitives.asymmetric import rsa ++from cryptography.x509.oid import NameOID + +import pq3 + @@ src/test/python/client/conftest.py (new) + with sock: + with pq3.wrap(sock, debug_stream=sys.stdout) as conn: + yield conn ++ ++ ++@pytest.fixture(scope="session") ++def certpair(tmp_path_factory): ++ """ ++ Yields a (cert, key) pair of file paths that can be used by a TLS server. ++ The certificate is issued for "localhost" and its standard IPv4/6 addresses. ++ """ ++ ++ tmpdir = tmp_path_factory.mktemp("certs") ++ now = datetime.datetime.now(datetime.timezone.utc) ++ ++ # https://cryptography.io/en/latest/x509/tutorial/#creating-a-self-signed-certificate ++ key = rsa.generate_private_key(public_exponent=65537, key_size=2048) ++ ++ subject = issuer = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "localhost")]) ++ altNames = [ ++ x509.DNSName("localhost"), ++ x509.IPAddress(ipaddress.IPv4Address("127.0.0.1")), ++ x509.IPAddress(ipaddress.IPv6Address("::1")), ++ ] ++ cert = ( ++ x509.CertificateBuilder() ++ .subject_name(subject) ++ .issuer_name(issuer) ++ .public_key(key.public_key()) ++ .serial_number(x509.random_serial_number()) ++ .not_valid_before(now) ++ .not_valid_after(now + datetime.timedelta(minutes=10)) ++ .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True) ++ .add_extension(x509.SubjectAlternativeName(altNames), critical=False) ++ ).sign(key, hashes.SHA256()) ++ ++ # Writing the key with mode 0600 lets us use this from the server side, too. ++ keypath = str(tmpdir / "key.pem") ++ with open(keypath, "wb", opener=functools.partial(os.open, mode=0o600)) as f: ++ f.write( ++ key.private_bytes( ++ encoding=serialization.Encoding.PEM, ++ format=serialization.PrivateFormat.PKCS8, ++ encryption_algorithm=serialization.NoEncryption(), ++ ) ++ ) ++ ++ certpath = str(tmpdir / "cert.pem") ++ with open(certpath, "wb") as f: ++ f.write(cert.public_bytes(serialization.Encoding.PEM)) ++ ++ return certpath, keypath ## src/test/python/client/test_client.py (new) ## @@ @@ src/test/python/client/test_oauth.py (new) + +import base64 +import collections ++import contextlib +import ctypes +import http.server +import json @@ src/test/python/client/test_oauth.py (new) +import os +import platform +import secrets ++import socket ++import ssl +import sys +import threading +import time @@ src/test/python/client/test_oauth.py (new) + ) + + -+def xtest_oauth_success(conn): # TODO -+ initial = start_oauth_handshake(conn) -+ -+ auth = get_auth_value(initial) -+ assert auth.startswith(b"Bearer ") -+ -+ # Accept the token. TODO actually validate -+ pq3.send(conn, pq3.types.AuthnRequest, type=pq3.authn.SASLFinal) -+ finish_handshake(conn) -+ -+ +class RawResponse(str): + """ + Returned by registered endpoint callbacks to take full control of the @@ src/test/python/client/test_oauth.py (new) + +class OpenIDProvider(threading.Thread): + """ -+ A thread that runs a mock OpenID provider server. ++ A thread that runs a mock OpenID provider server on an SSL-enabled socket. + """ + -+ def __init__(self, *, port): ++ def __init__(self, ssl_socket): + super().__init__() + + self.exception = None + -+ addr = ("", port) -+ self.server = self._Server(addr, self._Handler) ++ _, port = ssl_socket.getsockname() + -+ # TODO: allow HTTPS only, somehow + oauth = self._OAuthState() + oauth.host = f"localhost:{port}" -+ oauth.issuer = f"http://localhost:{port}" ++ oauth.issuer = f"https://localhost:{port}" + + # The following endpoints are required to be advertised by providers, + # even though our chosen client implementation does not actually make @@ src/test/python/client/test_oauth.py (new) + ) + oauth.register_endpoint("jwks_uri", "GET", "/keys", self._jwks_handler) + ++ self.server = self._HTTPSServer(ssl_socket, self._Handler) + self.server.oauth = oauth + + def run(self): @@ src/test/python/client/test_oauth.py (new) + + return 200, doc + -+ class _Server(http.server.HTTPServer): ++ class _HTTPSServer(http.server.HTTPServer): ++ def __init__(self, ssl_socket, handler_cls): ++ # Attach the SSL socket to the server. We don't bind/activate since ++ # the socket is already listening. ++ super().__init__(None, handler_cls, bind_and_activate=False) ++ self.socket = ssl_socket ++ self.server_address = self.socket.getsockname() ++ ++ def shutdown_request(self, request): ++ # Cleanly unwrap the SSL socket before shutting down the connection; ++ # otherwise careful clients will complain about truncation. ++ try: ++ request = request.unwrap() ++ except (ssl.SSLEOFError, ConnectionResetError, BrokenPipeError): ++ # The client already closed (or aborted) the connection without ++ # a clean shutdown. This is seen on some platforms during tests ++ # that break the HTTP protocol. Just return and have the server ++ # close the socket. ++ return ++ ++ super().shutdown_request(request) ++ + def handle_error(self, request, addr): + self.shutdown_request(request) + raise @@ src/test/python/client/test_oauth.py (new) + oauth = self.server.oauth + assert self.headers["Host"] == oauth.host + ++ # XXX: BaseHTTPRequestHandler collapses leading slashes in the path ++ # to work around an open redirection vuln (gh-87389) in ++ # SimpleHTTPServer. But we're not using SimpleHTTPServer, and we ++ # want to test repeating leading slashes, so that's not very ++ # helpful. Put them back. ++ orig_path = self.raw_requestline.split()[1] ++ orig_path = str(orig_path, "iso-8859-1") ++ assert orig_path.endswith(self.path) # sanity check ++ self.path = orig_path ++ + if handler is None: + handler = oauth.endpoint(self.command, self.path) + assert ( @@ src/test/python/client/test_oauth.py (new) + monkeypatch.setenv("PGOAUTHDEBUG", "UNSAFE") + + ++@pytest.fixture(autouse=True) ++def trust_certpair_in_client(monkeypatch, certpair): ++ """ ++ Set a trusted CA file for OAuth client connections. ++ """ ++ monkeypatch.setenv("PGOAUTHCAFILE", certpair[0]) ++ ++ ++@pytest.fixture(scope="session") ++def ssl_socket(certpair): ++ """ ++ A listening server-side socket for SSL connections, using the certpair ++ fixture. ++ """ ++ sock = socket.create_server(("", 0)) ++ ++ # The TLS connections we're making are incredibly sensitive to delayed ACKs ++ # from the client. (Without TCP_NODELAY, test performance degrades 4-5x.) ++ sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) ++ ++ with contextlib.closing(sock): ++ # Wrap the server socket for TLS. ++ ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) ++ ctx.load_cert_chain(*certpair) ++ ++ yield ctx.wrap_socket(sock, server_side=True) ++ ++ +@pytest.fixture -+def openid_provider(unused_tcp_port_factory): ++def openid_provider(ssl_socket): + """ + A fixture that returns the OAuth state of a running OpenID provider server. The + server will be stopped when the fixture is torn down. + """ -+ thread = OpenIDProvider(port=unused_tcp_port_factory()) ++ thread = OpenIDProvider(ssl_socket) + thread.start() + + try: @@ src/test/python/client/test_oauth.py (new) + pytest.param(True, id="asynchronous"), + ], +) -+def test_oauth_with_explicit_issuer( ++def test_oauth_with_explicit_discovery_uri( + accept, + openid_provider, + asynchronous, @@ src/test/python/client/test_oauth.py (new) + openid_provider.content_type = content_type + + sock, client = accept( -+ oauth_issuer=openid_provider.issuer, ++ oauth_issuer=openid_provider.discovery_uri, + oauth_client_id=client_id, + oauth_client_secret=secret, + oauth_scope=scope, @@ src/test/python/client/test_oauth.py (new) + client.check_completed() + + ++@pytest.mark.parametrize( ++ "server_discovery", ++ [ ++ pytest.param(True, id="server discovery"), ++ pytest.param(False, id="direct discovery"), ++ ], ++) ++@pytest.mark.parametrize( ++ "issuer, path", ++ [ ++ pytest.param( ++ "{issuer}", ++ "/.well-known/oauth-authorization-server", ++ id="oauth", ++ ), ++ pytest.param( ++ "{issuer}/alt", ++ "/.well-known/oauth-authorization-server/alt", ++ id="oauth with path, IETF style", ++ ), ++ pytest.param( ++ "{issuer}/alt", ++ "/alt/.well-known/oauth-authorization-server", ++ id="oauth with path, broken OIDC style", ++ ), ++ pytest.param( ++ "{issuer}/alt", ++ "/alt/.well-known/openid-configuration", ++ id="openid with path, OIDC style", ++ ), ++ pytest.param( ++ "{issuer}/alt", ++ "/.well-known/openid-configuration/alt", ++ id="openid with path, IETF style", ++ ), ++ pytest.param( ++ "{issuer}/", ++ "//.well-known/openid-configuration", ++ id="empty path segment, OIDC style", ++ ), ++ pytest.param( ++ "{issuer}/", ++ "/.well-known/openid-configuration/", ++ id="empty path segment, IETF style", ++ ), ++ ], ++) ++def test_alternate_well_known_paths( ++ accept, openid_provider, issuer, path, server_discovery ++): ++ issuer = issuer.format(issuer=openid_provider.issuer) ++ discovery_uri = openid_provider.issuer + path ++ ++ client_id = secrets.token_hex() ++ access_token = secrets.token_urlsafe() ++ ++ def discovery_handler(*args): ++ """ ++ Pass-through implementation of the discovery handler. Modifies the ++ default document to contain this test's issuer identifier. ++ """ ++ code, doc = openid_provider._default_discovery_handler(*args) ++ doc["issuer"] = issuer ++ return code, doc ++ ++ openid_provider.register_endpoint(None, "GET", path, discovery_handler) ++ ++ def authorization_endpoint(headers, params): ++ resp = { ++ "device_code": "12345", ++ "user_code": "ABCDE", ++ "interval": 0, ++ "verification_url": "https://example.com/device", ++ "expires_in": 5, ++ } ++ ++ return 200, resp ++ ++ openid_provider.register_endpoint( ++ "device_authorization_endpoint", "POST", "/device", authorization_endpoint ++ ) ++ ++ def token_endpoint(headers, params): ++ # Successfully finish the request by sending the access bearer token. ++ resp = { ++ "access_token": access_token, ++ "token_type": "bearer", ++ } ++ ++ return 200, resp ++ ++ openid_provider.register_endpoint( ++ "token_endpoint", "POST", "/token", token_endpoint ++ ) ++ ++ kwargs = dict(oauth_client_id=client_id) ++ if server_discovery: ++ kwargs.update(oauth_issuer=issuer) ++ else: ++ 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. ++ fail_oauth_handshake( ++ conn, ++ { ++ "status": "invalid_token", ++ "openid-configuration": discovery_uri, ++ }, ++ ) ++ ++ # Expect the client to connect again. ++ sock, client = accept() ++ ++ with sock: ++ with pq3.wrap(sock, debug_stream=sys.stdout) as conn: ++ initial = start_oauth_handshake(conn) ++ ++ # Validate 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) ++ ++ ++@pytest.mark.parametrize( ++ "server_discovery", ++ [ ++ pytest.param(True, id="server discovery"), ++ pytest.param(False, id="direct discovery"), ++ ], ++) ++@pytest.mark.parametrize( ++ "issuer, path, expected_error", ++ [ ++ pytest.param( ++ "{issuer}", ++ "/.well-known/oauth-authorization-server/", ++ None, ++ id="extra empty segment", ++ ), ++ pytest.param( ++ "{issuer}", ++ "?/.well-known/oauth-authorization-server", ++ r'OAuth discovery URI ".*" must not contain query or fragment components', ++ id="query", ++ ), ++ pytest.param( ++ "{issuer}", ++ "#/.well-known/oauth-authorization-server", ++ r'OAuth discovery URI ".*" must not contain query or fragment components', ++ id="fragment", ++ ), ++ pytest.param( ++ "{issuer}/sub/path", ++ "/sub/.well-known/oauth-authorization-server/path", ++ r'OAuth discovery URI ".*" uses an invalid format', ++ id="sandwiched prefix", ++ ), ++ pytest.param( ++ "{issuer}/path", ++ "/path/openid-configuration", ++ r'OAuth discovery URI ".*" is not a .well-known URI', ++ id="not .well-known", ++ ), ++ pytest.param( ++ "{issuer}", ++ "https://.well-known/oauth-authorization-server", ++ r'OAuth discovery URI ".*" is not a .well-known URI', ++ id=".well-known prefix buried in the authority", ++ ), ++ pytest.param( ++ "{issuer}", ++ "/.well-known/oauth-protected-resource", ++ r'OAuth discovery URI ".*" uses an unsupported .well-known suffix', ++ id="unknown well-known suffix", ++ ), ++ pytest.param( ++ "{issuer}/path", ++ "/path/.well-known/openid-configuration-2", ++ r'OAuth discovery URI ".*" uses an unsupported .well-known suffix', ++ id="unknown well-known suffix, OIDC style", ++ ), ++ pytest.param( ++ "{issuer}/path", ++ "/.well-known/oauth-authorization-server-2/path", ++ r'OAuth discovery URI ".*" uses an unsupported .well-known suffix', ++ id="unknown well-known suffix, IETF style", ++ ), ++ pytest.param( ++ "{issuer}", ++ "file:///.well-known/oauth-authorization-server", ++ r'OAuth discovery URI ".*" must use HTTPS', ++ id="unsupported scheme", ++ ), ++ ], ++) ++def test_bad_well_known_paths( ++ accept, openid_provider, issuer, path, expected_error, server_discovery ++): ++ if not server_discovery and "/.well-known/" not in path: ++ # An oauth_issuer without a /.well-known/ path segment is just a normal ++ # issuer identifier, so this isn't an interesting test. ++ pytest.skip("not interesting: direct discovery requires .well-known") ++ ++ issuer = issuer.format(issuer=openid_provider.issuer) ++ discovery_uri = urllib.parse.urljoin(openid_provider.issuer, path) ++ ++ client_id = secrets.token_hex() ++ ++ def discovery_handler(*args): ++ """ ++ Pass-through implementation of the discovery handler. Modifies the ++ default document to contain this test's issuer identifier. ++ """ ++ code, doc = openid_provider._default_discovery_handler(*args) ++ doc["issuer"] = issuer ++ return code, doc ++ ++ openid_provider.register_endpoint(None, "GET", path, discovery_handler) ++ ++ def fail(*args): ++ """ ++ No other endpoints should be contacted; fail if the client tries. ++ """ ++ assert False, "endpoint unexpectedly called" ++ ++ openid_provider.register_endpoint( ++ "device_authorization_endpoint", "POST", "/device", fail ++ ) ++ openid_provider.register_endpoint("token_endpoint", "POST", "/token", fail) ++ ++ kwargs = dict(oauth_client_id=client_id) ++ if server_discovery: ++ kwargs.update(oauth_issuer=issuer) ++ else: ++ 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) ++ ++ if expected_error is None: ++ if server_discovery: ++ expected_error = rf"server's discovery document at {discovery_uri} \(issuer \".*\"\) is incompatible with oauth_issuer \({issuer}\)" ++ else: ++ expected_error = rf"the issuer identifier \({issuer}\) does not match oauth_issuer \(.*\)" ++ ++ with pytest.raises(psycopg2.OperationalError, match=expected_error): ++ client.check_completed() ++ ++ +def expect_disconnected_handshake(sock): + """ + Helper for any tests that expect the client to disconnect immediately after @@ src/test/python/client/test_oauth.py (new) + ) + + # The client should disconnect at this point. -+ assert not conn.read() ++ assert not conn.read(1), "client sent unexpected data" + + -+def test_oauth_requires_client_id(accept, openid_provider): -+ sock, client = accept( ++@pytest.mark.parametrize( ++ "missing", ++ [ ++ pytest.param(["oauth_issuer"], id="missing oauth_issuer"), ++ pytest.param(["oauth_client_id"], id="missing oauth_client_id"), ++ pytest.param(["oauth_client_id", "oauth_issuer"], id="missing both"), ++ ], ++) ++def test_oauth_requires_issuer_and_client_id(accept, openid_provider, missing): ++ params = dict( + oauth_issuer=openid_provider.issuer, -+ # Do not set a client ID; this should cause a client error after the -+ # server asks for OAUTHBEARER and the client tries to contact the -+ # issuer. ++ oauth_client_id="some-id", + ) + ++ # Remove required parameters. This should cause a client error after the ++ # server asks for OAUTHBEARER and the client tries to contact the issuer. ++ for k in missing: ++ del params[k] ++ ++ sock, client = accept(**params) + expect_disconnected_handshake(sock) + -+ expected_error = "no oauth_client_id is set" ++ expected_error = "oauth_issuer and oauth_client_id are not both set" + with pytest.raises(psycopg2.OperationalError, match=expected_error): + client.check_completed() + @@ src/test/python/client/test_oauth.py (new) +@pytest.mark.parametrize("scope", ["&", r"+=&/", all_nqchars]) +def test_url_encoding(accept, openid_provider, client_id, secret, device_code, scope): + sock, client = accept( -+ oauth_issuer=openid_provider.issuer, ++ oauth_issuer=openid_provider.discovery_uri, + oauth_client_id=client_id, + oauth_client_secret=secret, + oauth_scope=scope, @@ src/test/python/client/test_oauth.py (new) + accept, openid_provider, omit_interval, retries, error_code +): + sock, client = accept( -+ oauth_issuer=openid_provider.issuer, ++ oauth_issuer=openid_provider.discovery_uri, + oauth_client_id="some-id", + ) + @@ src/test/python/client/test_oauth.py (new) + access_token = secrets.token_urlsafe() + + sock, client = accept( -+ oauth_issuer=issuer, ++ oauth_issuer=discovery_uri, + oauth_client_id="some-id", + oauth_scope=scope, + async_=asynchronous, @@ src/test/python/client/test_oauth.py (new) + client_id = secrets.token_hex() + + sock, client = accept( -+ oauth_issuer=openid_provider.issuer, ++ oauth_issuer=openid_provider.discovery_uri, + oauth_client_id=client_id, + ) + @@ src/test/python/client/test_oauth.py (new) + pytest.skip("not interesting: correct type") + + sock, client = accept( -+ oauth_issuer=openid_provider.issuer, ++ oauth_issuer=openid_provider.discovery_uri, + oauth_client_id=secrets.token_hex(), + ) + @@ src/test/python/client/test_oauth.py (new) + client_id = secrets.token_hex() + + sock, client = accept( -+ oauth_issuer=openid_provider.issuer, ++ oauth_issuer=openid_provider.discovery_uri, + oauth_client_id=client_id, + ) + @@ src/test/python/client/test_oauth.py (new) + pytest.skip("not interesting: correct type") + + sock, client = accept( -+ oauth_issuer=openid_provider.issuer, ++ oauth_issuer=openid_provider.discovery_uri, + oauth_client_id=secrets.token_hex(), + ) + @@ src/test/python/client/test_oauth.py (new) + ], +) +def test_oauth_discovery(accept, openid_provider, base_response, scope, success): -+ sock, client = accept(oauth_client_id=secrets.token_hex()) ++ sock, client = accept( ++ oauth_issuer=openid_provider.issuer, ++ oauth_client_id=secrets.token_hex(), ++ ) + + device_code = secrets.token_hex() + user_code = f"{secrets.token_hex(2)}-{secrets.token_hex(2)}" @@ src/test/python/client/test_oauth.py (new) + ], +) +def test_oauth_discovery_server_error(accept, response, expected_error): -+ sock, client = accept(oauth_client_id=secrets.token_hex()) ++ sock, client = accept( ++ oauth_issuer="https://example.com", ++ oauth_client_id=secrets.token_hex(), ++ ) + + with sock: + with pq3.wrap(sock, debug_stream=sys.stdout) as conn: @@ src/test/python/client/test_oauth.py (new) + client.check_completed() + + ++# All of these tests are expected to fail before libpq tries to actually attempt ++# a connection to any endpoint. To avoid hitting the network in the event that a ++# test fails, an invalid IPv4 address (256.256.256.256) is used as a hostname. +@pytest.mark.parametrize( + "bad_response,expected_error", + [ @@ src/test/python/client/test_oauth.py (new) + 200, + { + "grant_types_supported": ["something"], -+ "token_endpoint": "https://example.com/", ++ "token_endpoint": "https://256.256.256.256/", + "issuer": 123, + }, + ), @@ src/test/python/client/test_oauth.py (new) + id="non-string issuer after other ignored fields", + ), + pytest.param( -+ (200, {"token_endpoint": "https://example.com/"}), ++ (200, {"token_endpoint": "https://256.256.256.256/"}), + r'failed to parse OpenID discovery document: field "issuer" is missing', + id="missing issuer", + ), + pytest.param( -+ (200, {"issuer": "https://example.com/"}), ++ (200, {"issuer": "{issuer}"}), + r'failed to parse OpenID discovery document: field "token_endpoint" is missing', + id="missing token endpoint", + ), @@ src/test/python/client/test_oauth.py (new) + ( + 200, + { -+ "issuer": "https://example.com", -+ "token_endpoint": "https://example.com/token", -+ "device_authorization_endpoint": "https://example.com/dev", ++ "issuer": "{issuer}", ++ "token_endpoint": "https://256.256.256.256/token", ++ "device_authorization_endpoint": "https://256.256.256.256/dev", + }, + ), -+ r'cannot run OAuth device authorization: issuer "https://example.com" does not support device code grants', ++ r'cannot run OAuth device authorization: issuer "https://.*" does not support device code grants', + id="missing device code grants", + ), + pytest.param( + ( + 200, + { -+ "issuer": "https://example.com", -+ "token_endpoint": "https://example.com/token", ++ "issuer": "{issuer}", ++ "token_endpoint": "https://256.256.256.256/token", + "grant_types_supported": [ + "urn:ietf:params:oauth:grant-type:device_code" + ], + }, + ), -+ r'cannot run OAuth device authorization: issuer "https://example.com" does not provide a device authorization endpoint', ++ r'cannot run OAuth device authorization: issuer "https://.*" does not provide a device authorization endpoint', + id="missing device_authorization_endpoint", + ), + pytest.param( + ( + 200, + { -+ "issuer": "https://example.com", -+ "token_endpoint": "https://example.com/token", ++ "issuer": "{issuer}", ++ "token_endpoint": "https://256.256.256.256/token", + "grant_types_supported": [ + "urn:ietf:params:oauth:grant-type:device_code" + ], -+ "device_authorization_endpoint": "https://example.com/dev", ++ "device_authorization_endpoint": "https://256.256.256.256/dev", + "filler": "x" * 1024 * 1024, + }, + ), + r"failed to fetch OpenID discovery document: response is too large", + id="gigantic discovery response", + ), ++ pytest.param( ++ ( ++ 200, ++ { ++ "issuer": "{issuer}/path", ++ "token_endpoint": "https://256.256.256.256/token", ++ "grant_types_supported": [ ++ "urn:ietf:params:oauth:grant-type:device_code" ++ ], ++ "device_authorization_endpoint": "https://256.256.256.256/dev", ++ }, ++ ), ++ r"failed to parse OpenID discovery document: the issuer identifier \(https://.*/path\) does not match oauth_issuer \(https://.*\)", ++ id="mismatched issuer identifier", ++ ), + # + # Exercise HTTP-level failures by breaking the protocol. Note that the + # error messages here are implementation-dependent. @@ src/test/python/client/test_oauth.py (new) + accept, openid_provider, bad_response, expected_error +): + sock, client = accept( -+ oauth_issuer=openid_provider.issuer, ++ oauth_issuer=openid_provider.discovery_uri, + oauth_client_id=secrets.token_hex(), + ) + + def failing_discovery_handler(headers, params): ++ try: ++ # Insert the correct issuer value if the test wants to. ++ resp = bad_response[1] ++ iss = resp["issuer"] ++ resp["issuer"] = iss.format(issuer=openid_provider.issuer) ++ except (AttributeError, KeyError, TypeError): ++ pass ++ + return bad_response + + openid_provider.register_endpoint( @@ src/test/python/client/test_oauth.py (new) + ], +) +def test_oauth_server_error(accept, sasl_err, resp_type, resp_payload, expected_error): -+ sock, client = accept() ++ sock, client = accept( ++ oauth_issuer="https://example.com", ++ oauth_client_id="some-id", ++ ) + + with sock: + with pq3.wrap(sock, debug_stream=sys.stdout) as conn: @@ src/test/python/client/test_oauth.py (new) + int_max = ctypes.c_uint(-1).value // 2 + + sock, client = accept( -+ oauth_issuer=openid_provider.issuer, ++ oauth_issuer=openid_provider.discovery_uri, + oauth_client_id=secrets.token_hex(), + ) + @@ src/test/python/client/test_oauth.py (new) + HTTP must be refused without PGOAUTHDEBUG. + """ + monkeypatch.delenv("PGOAUTHDEBUG") ++ ++ def to_http(uri): ++ """Swaps out a URI's scheme for http.""" ++ parts = urllib.parse.urlparse(uri) ++ parts = parts._replace(scheme="http") ++ return urllib.parse.urlunparse(parts) ++ + sock, client = accept( ++ oauth_issuer=to_http(openid_provider.issuer), + oauth_client_id=secrets.token_hex(), + ) + @@ src/test/python/client/test_oauth.py (new) + with pq3.wrap(sock, debug_stream=sys.stdout) as conn: + initial = start_oauth_handshake(conn) + -+ # Fail the SASL exchange and link to the HTTP provider. + resp = { + "status": "invalid_token", -+ "openid-configuration": openid_provider.discovery_uri, ++ "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"), ++ ) + -+ fail_oauth_handshake(conn, resp) -+ -+ # FIXME: We'll get a second connection, but it won't do anything. -+ sock, _ = accept() -+ expect_disconnected_handshake(sock) ++ # FIXME: the client disconnects at this point; it'd be nicer if ++ # it completed the exchange. + -+ expected_error = "failed to fetch OpenID discovery document: Unsupported protocol" ++ expected_error = r'OAuth discovery URI ".*" must use HTTPS' + with pytest.raises(psycopg2.OperationalError, match=expected_error): + client.check_completed() @@ src/test/python/meson.build (new) + './test_pq3.py', + ], + 'env': pytest_env, ++ 'test_kwargs': {'priority': 50}, # python tests are slow, start early + }, +} @@ src/test/python/server/conftest.py (new) + f"-c port={port}", + "-c listen_addresses=localhost", + "-c log_connections=on", -+ "-c shared_preload_libraries=oauthtest", -+ "-c oauth_validator_library=oauthtest", ++ "-c session_preload_libraries=oauthtest", ++ "-c oauth_validator_libraries=oauthtest", + ] + ), + "start", @@ src/tools/make_venv (new) +run(pip, 'install', 'pytest', 'pytest-tap') +if args.requirements: + run(pip, 'install', '-r', args.requirements) + + ## src/tools/testwrap ## +@@ src/tools/testwrap: parser.add_argument('--testgroup', help='test group', type=str) + parser.add_argument('--testname', help='test name', type=str) + parser.add_argument('--skip', help='skip test (with reason)', type=str) + parser.add_argument('--pg-test-extra', help='extra tests', type=str) ++parser.add_argument('--skip-without-extra', help='skip if PG_TEST_EXTRA is missing this arg', type=str) + parser.add_argument('test_command', nargs='*') + + args = parser.parse_args() +@@ src/tools/testwrap: if args.skip is not None: + print('1..0 # Skipped: ' + args.skip) + sys.exit(0) + ++if args.skip_without_extra is not None: ++ extras = os.environ.get("PG_TEST_EXTRA", args.pg_test_extra) ++ if extras is None or args.skip_without_extra not in extras.split(): ++ print(f'1..0 # Skipped: PG_TEST_EXTRA does not contain "{args.skip_without_extra}"') ++ sys.exit(0) ++ + if os.path.exists(testdir) and os.path.isdir(testdir): + shutil.rmtree(testdir) + os.makedirs(testdir)