1: ce06c03e2b = 1: 231c6fb165 common/jsonapi: support FRONTEND clients 2: 6989b75153 = 2: f78c79ea68 Refactor SASL exchange to return tri-state status 3: 783bfe0b95 = 3: 10b6d2a6b9 Explicitly require password for SCRAM exchange 4: 77550a47db = 4: 2a55d9c806 libpq: add OAUTHBEARER SASL mechanism 5: 12ae7c4355 ! 5: 5488ac25f5 backend: add OAUTHBEARER SASL mechanism @@ Commit message - port to platforms other than "modern Linux/BSD" - overhaul the communication with oauth_validator_command, which is currently a bad hack on OpenPipeStream() - - implement more sanity checks on the OAUTHBEARER message format and - tokens sent by the client - implement more helpful handling of HBA misconfigurations - - properly interpolate JSON when generating error responses - use logdetail during auth failures - deal with role names that can't be safely passed to system() without shell-escaping @@ src/backend/libpq/auth-oauth.c (new) +#include "libpq/oauth.h" +#include "libpq/sasl.h" +#include "storage/fd.h" ++#include "utils/json.h" + +/* GUC */ +char *oauth_validator_command; @@ src/backend/libpq/auth-oauth.c (new) +} + +/* ++ * Performs syntactic validation of a key and value from the initial client ++ * response. (Semantic validation of interesting values must be performed ++ * later.) ++ */ ++static void ++validate_kvpair(const char *key, const char *val) ++{ ++ /*----- ++ * From Sec 3.1: ++ * key = 1*(ALPHA) ++ */ ++ static const char *key_allowed_set = ++ "abcdefghijklmnopqrstuvwxyz" ++ "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; ++ ++ size_t span; ++ ++ if (!key[0]) ++ ereport(ERROR, ++ (errcode(ERRCODE_PROTOCOL_VIOLATION), ++ errmsg("malformed OAUTHBEARER message"), ++ errdetail("Message contains an empty key name."))); ++ ++ span = strspn(key, key_allowed_set); ++ if (key[span] != '\0') ++ ereport(ERROR, ++ (errcode(ERRCODE_PROTOCOL_VIOLATION), ++ errmsg("malformed OAUTHBEARER message"), ++ errdetail("Message contains an invalid key name."))); ++ ++ /*----- ++ * From Sec 3.1: ++ * value = *(VCHAR / SP / HTAB / CR / LF ) ++ * ++ * The VCHAR (visible character) class is large; a loop is more ++ * straightforward than strspn(). ++ */ ++ for (; *val; ++val) ++ { ++ if (0x21 <= *val && *val <= 0x7E) ++ continue; /* VCHAR */ ++ ++ switch (*val) ++ { ++ case ' ': ++ case '\t': ++ case '\r': ++ case '\n': ++ continue; /* SP, HTAB, CR, LF */ ++ ++ default: ++ ereport(ERROR, ++ (errcode(ERRCODE_PROTOCOL_VIOLATION), ++ errmsg("malformed OAUTHBEARER message"), ++ errdetail("Message contains an invalid value."))); ++ } ++ } ++} ++ ++/* + * Consumes all kvpairs in an OAUTHBEARER exchange message. If the "auth" key is + * found, its value is returned. + */ @@ src/backend/libpq/auth-oauth.c (new) + + /* + * Find the end of the key name. -+ * -+ * TODO further validate the key/value grammar? empty keys, bad -+ * chars... + */ + sep = strchr(pos, '='); + if (!sep) @@ src/backend/libpq/auth-oauth.c (new) + /* Both key and value are now safely terminated. */ + key = pos; + value = sep + 1; ++ validate_kvpair(key, value); + + if (!strcmp(key, AUTH_KEY)) + { @@ src/backend/libpq/auth-oauth.c (new) +generate_error_response(struct oauth_ctx *ctx, char **output, int *outputlen) +{ + StringInfoData buf; ++ StringInfoData issuer; + + /* + * The admin needs to set an issuer and scope for OAuth to work. There's @@ 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. ++ */ ++ initStringInfo(&issuer); ++ appendStringInfoString(&issuer, ctx->issuer); ++ appendStringInfoString(&issuer, "/.well-known/openid-configuration"); + + initStringInfo(&buf); + + /* -+ * TODO: JSON escaping ++ * TODO: note that escaping here should be belt-and-suspenders, since ++ * escapable characters aren't valid in either the issuer URI or the scope ++ * list, but the HBA doesn't enforce that yet. + */ -+ appendStringInfo(&buf, -+ "{ " -+ "\"status\": \"invalid_token\", " -+ "\"openid-configuration\": \"%s/.well-known/openid-configuration\", " -+ "\"scope\": \"%s\" " -+ "}", -+ ctx->issuer, ctx->scope); ++ appendStringInfoString(&buf, "{ \"status\": \"invalid_token\", "); ++ ++ appendStringInfoString(&buf, "\"openid-configuration\": "); ++ escape_json(&buf, issuer.data); ++ pfree(issuer.data); ++ ++ appendStringInfoString(&buf, ", \"scope\": "); ++ escape_json(&buf, ctx->scope); ++ ++ appendStringInfoString(&buf, " }"); + + *output = buf.data; + *outputlen = buf.len; 6: 707edf9314 ! 6: fdbad1976a Introduce OAuth validator libraries @@ src/backend/libpq/auth-oauth.c #include "libpq/sasl.h" #include "storage/fd.h" +#include "storage/ipc.h" + #include "utils/json.h" /* GUC */ -char *oauth_validator_command; @@ src/backend/libpq/auth-oauth.c: validate(Port *port, const char *auth, const cha + + /* Ensure that we have a correct token to validate */ + if (!(token = validate_token_format(auth))) - return false; - ++ return false; ++ + /* Call the validation function from the validator module */ + ret = ValidatorCallbacks->validate_cb(validator_module_state, + token, port->user_name); + -+ if (!ret->authenticated) -+ return false; -+ ++ if (!ret->authorized) + return false; + + if (ret->authn_id) + set_authn_id(port, ret->authn_id); + @@ src/backend/libpq/auth-oauth.c: validate(Port *port, const char *auth, const cha - ret = check_usermap(port->hba->usermap, port->user_name, - MyClientConnectionInfo.authn_id, false); - return (ret == STATUS_OK); -+ map_status = check_usermap(port->hba->usermap, port->user_name, -+ MyClientConnectionInfo.authn_id, false); -+ return (map_status == STATUS_OK); - } - +-} +- -static bool -run_validator_command(Port *port, const char *token) -{ @@ src/backend/libpq/auth-oauth.c: validate(Port *port, const char *auth, const cha - pfree(command.data); - - return success; --} -- ++ map_status = check_usermap(port->hba->usermap, port->user_name, ++ MyClientConnectionInfo.authn_id, false); ++ return (map_status == STATUS_OK); + } + -static bool -check_exit(FILE **fh, const char *command) +static void +load_validator_library(void) { - int rc; -+ OAuthValidatorModuleInit validator_init; - +- - rc = ClosePipeStream(*fh); - *fh = NULL; - @@ src/backend/libpq/auth-oauth.c: validate(Port *port, const char *auth, const cha - else if (rc != 0) - { - char *reason = wait_result_to_str(rc); -- ++ OAuthValidatorModuleInit validator_init; + - ereport(COMMERROR, - (errmsg("failed to execute command \"%s\": %s", - command, reason))); -- -- pfree(reason); -- } + if (OAuthValidatorLibrary[0] == '\0') + ereport(ERROR, + (errcode(ERRCODE_INVALID_PARAMETER_VALUE), + errmsg("oauth_validator_library is not set"))); -- return (rc == 0); --} +- pfree(reason); +- } + validator_init = (OAuthValidatorModuleInit) + load_external_function(OAuthValidatorLibrary, + "_PG_oauth_validator_module_init", false, NULL); --static bool --set_cloexec(int fd) --{ -- int flags; -- int rc; +- return (rc == 0); +-} + if (validator_init == NULL) + ereport(ERROR, + (errmsg("%s module \"%s\" have to define the symbol %s", + "OAuth validator", OAuthValidatorLibrary, "_PG_oauth_validator_module_init"))); +-static bool +-set_cloexec(int fd) +-{ +- int flags; +- int rc; ++ ValidatorCallbacks = (*validator_init) (); + - flags = fcntl(fd, F_GETFD); - if (flags == -1) - { @@ src/backend/libpq/auth-oauth.c: validate(Port *port, const char *auth, const cha - errmsg("could not get fd flags for child pipe: %m"))); - return false; - } -+ ValidatorCallbacks = (*validator_init) (); - -- rc = fcntl(fd, F_SETFD, flags | FD_CLOEXEC); -- if (rc < 0) -- { -- ereport(COMMERROR, -- (errcode_for_file_access(), -- errmsg("could not set FD_CLOEXEC for child pipe: %m"))); -- return false; -- } + validator_module_state = (ValidatorModuleState *) palloc0(sizeof(ValidatorModuleState)); + if (ValidatorCallbacks->startup_cb != NULL) + ValidatorCallbacks->startup_cb(validator_module_state); +- rc = fcntl(fd, F_SETFD, flags | FD_CLOEXEC); +- if (rc < 0) +- { +- ereport(COMMERROR, +- (errcode_for_file_access(), +- errmsg("could not set FD_CLOEXEC for child pipe: %m"))); +- return false; +- } +- - return true; + before_shmem_exit(shutdown_validator_library, 0); } @@ src/include/libpq/oauth.h + +typedef struct ValidatorModuleResult +{ -+ bool authenticated; ++ bool authorized; + char *authn_id; +} ValidatorModuleResult; + @@ src/test/modules/oauth_validator/t/001_server.pl (new) +use PostgreSQL::Test::OAuthServer; +use Test::More; + -+# Delete pg_hba.conf from the given node, add a new entry to it -+# and then execute a reload to refresh it. -+# XXX: this is copied from authentication/t/001_password and should be made -+# generic functionality if we end up using it. -+sub reset_pg_hba -+{ -+ my $node = shift; -+ my $database = shift; -+ my $role = shift; -+ my $hba_method = shift; -+ -+ unlink($node->data_dir . '/pg_hba.conf'); -+ # just for testing purposes, use a continuation line -+ $node->append_conf('pg_hba.conf', -+ "local $database $role\\\n $hba_method"); -+ $node->reload; -+ return; -+} -+ +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->start; + -+reset_pg_hba($node, 'all', 'all', 'oauth issuer="127.0.0.1:18080" scope="openid postgres"'); ++$node->safe_psql('postgres', 'CREATE USER test;'); ++$node->safe_psql('postgres', 'CREATE USER testalt;'); ++ ++my $issuer = "127.0.0.1:18080"; ++ ++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" ++}); ++$node->reload; + +my $webserver = PostgreSQL::Test::OAuthServer->new(18080); + @@ src/test/modules/oauth_validator/t/001_server.pl (new) +$webserver->setup(); +$webserver->run(); + -+$node->connect_ok("dbname=postgres oauth_client_id=f02c6361-0635", "connect", ++my ($log_start, $log_end); ++$log_start = $node->wait_for_log(qr/reloading configuration files/); ++ ++my $user = "test"; ++$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@); + ++$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/, ++ ]); ++$log_start = $log_end; ++ ++# The /alternate issuer uses slightly different parameters. ++$user = "testalt"; ++$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@); ++ ++$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/, ++ ]); ++$log_start = $log_end; ++ +$node->stop; + +done_testing(); @@ src/test/modules/oauth_validator/validator.c (new) + +#include "fmgr.h" +#include "libpq/oauth.h" ++#include "miscadmin.h" +#include "utils/memutils.h" + +PG_MODULE_MAGIC; @@ src/test/modules/oauth_validator/validator.c (new) + return &validator_callbacks; +} + ++#define PRIVATE_COOKIE ((void *) 13579) ++ +static void +validator_startup(ValidatorModuleState *state) +{ -+ /* do nothing */ ++ state->private_data = PRIVATE_COOKIE; +} + +static void @@ src/test/modules/oauth_validator/validator.c (new) +{ + ValidatorModuleResult *res; + ++ /* Check to make sure our private state still exists. */ ++ if (state->private_data != PRIVATE_COOKIE) ++ elog(ERROR, "oauth_validator: private state cookie changed to %p", ++ state->private_data); ++ + res = palloc(sizeof(ValidatorModuleResult)); + -+ elog(LOG, "XXX: validating %s for %s", token, role); ++ elog(LOG, "oauth_validator: token=\"%s\", role=\"%s\"", token, role); ++ elog(LOG, "oauth_validator: issuer=\"%s\", scope=\"%s\"", ++ MyProcPort->hba->oauth_issuer, ++ MyProcPort->hba->oauth_scope); + -+ res->authenticated = true; ++ res->authorized = true; + res->authn_id = pstrdup(role); + + return res; @@ src/test/perl/PostgreSQL/Test/OAuthServer.pm (new) + #} + #printf ": POST: " . $request{'content'} . "\n" if defined $request{'content'}; + ++ my $alternate = 0; ++ if ($request{'object'} =~ qr|^/alternate(/.*)$|) ++ { ++ $alternate = 1; ++ $request{'object'} = $1; ++ } ++ + if ($request{'object'} eq '/.well-known/openid-configuration') + { ++ my $issuer = "http://localhost:$self->{'port'}"; ++ if ($alternate) ++ { ++ $issuer .= "/alternate"; ++ } ++ + print $fh "HTTP/1.0 200 OK\r\nServer: Postgres Regress\r\n"; + print $fh "Content-Type: application/json\r\n"; + print $fh "\r\n"; + print $fh <{'port'}", -+ "token_endpoint": "http://localhost:$self->{'port'}/token", -+ "device_authorization_endpoint": "http://localhost:$self->{'port'}/authorize", ++ "issuer": "$issuer", ++ "token_endpoint": "$issuer/token", ++ "device_authorization_endpoint": "$issuer/authorize", + "response_types_supported": ["token"], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["RS256"], @@ src/test/perl/PostgreSQL/Test/OAuthServer.pm (new) + } + elsif ($request{'object'} eq '/authorize') + { ++ my $uri = "https://example.com/"; ++ if ($alternate) ++ { ++ $uri = "https://example.org/"; ++ } ++ + print ": returning device_code\n"; + print $fh "HTTP/1.0 200 OK\r\nServer: Postgres Regress\r\n"; + print $fh "Content-Type: application/json\r\n"; @@ src/test/perl/PostgreSQL/Test/OAuthServer.pm (new) + "device_code": "postgres", + "user_code" : "postgresuser", + "interval" : 0, -+ "verification_uri" : "https://example.com/", ++ "verification_uri" : "$uri", + "expires-in": 5 + } +EOR + } + elsif ($request{'object'} eq '/token') + { ++ my $token = "9243959234"; ++ if ($alternate) ++ { ++ $token .= "-alt"; ++ } ++ + print ": returning token\n"; + print $fh "HTTP/1.0 200 OK\r\nServer: Postgres Regress\r\n"; + print $fh "Content-Type: application/json\r\n"; + print $fh "\r\n"; + print $fh <authenticated = true; ++ res->authorized = true; + res->authn_id = pstrdup(role); /* TODO: constify? */ + } + else + { + if (*expected_bearer && !strcmp(token, expected_bearer)) -+ res->authenticated = true; ++ res->authorized = true; + if (set_authn_id) + res->authn_id = authn_id; + } @@ src/test/python/server/test_oauth.py (new) + +import psycopg2 +import pytest ++from construct import Container +from psycopg2 import sql + +import pq3 @@ src/test/python/server/test_oauth.py (new) + + +@contextlib.contextmanager -+def prepend_file(path, lines): ++def prepend_file(path, lines, *, suffix=".bak"): + """ + A context manager that prepends a file on disk with the desired lines of + text. When the context manager is exited, the file will be restored to its + original contents. + """ + # First make a backup of the original file. -+ bak = path + ".bak" ++ bak = path + suffix + shutil.copy2(path, bak) + + try: @@ src/test/python/server/test_oauth.py (new) + b"Bearer trailingtab\t", + b"Bearer me@example.com", + b"Beare abcd", ++ b" Bearer leadingspace", + b'OAuth realm="Example"', + b"", + ], @@ src/test/python/server/test_oauth.py (new) + id="error response in initial message", + ), + pytest.param( -+ pq3.types.PasswordMessage, -+ b"x" * (MAX_SASL_MESSAGE_LENGTH + 1), ++ None, ++ # Sending an actual 65k packet results in ECONNRESET on Windows, and ++ # it floods the tests' connection log uselessly, so just fake the ++ # length and send a smaller number of bytes. ++ dict( ++ type=pq3.types.PasswordMessage, ++ len=MAX_SASL_MESSAGE_LENGTH + 1, ++ payload=b"x" * 512, ++ ), + ExpectedError( + INVALID_AUTHORIZATION_ERRCODE, "bearer authentication failed" + ), @@ src/test/python/server/test_oauth.py (new) + ), + id="multiple auth values", + ), ++ pytest.param( ++ pq3.types.PasswordMessage, ++ pq3.SASLInitialResponse.build( ++ dict( ++ name=b"OAUTHBEARER", ++ data=b"y,,\x01=\x01\x01", ++ ) ++ ), ++ ExpectedError( ++ PROTOCOL_VIOLATION_ERRCODE, ++ "malformed OAUTHBEARER message", ++ "empty key name", ++ ), ++ id="empty key", ++ ), ++ pytest.param( ++ pq3.types.PasswordMessage, ++ pq3.SASLInitialResponse.build( ++ dict( ++ name=b"OAUTHBEARER", ++ data=b"y,,\x01my key= \x01\x01", ++ ) ++ ), ++ ExpectedError( ++ PROTOCOL_VIOLATION_ERRCODE, ++ "malformed OAUTHBEARER message", ++ "invalid key name", ++ ), ++ id="whitespace in key name", ++ ), ++ pytest.param( ++ pq3.types.PasswordMessage, ++ pq3.SASLInitialResponse.build( ++ dict( ++ name=b"OAUTHBEARER", ++ data=b"y,,\x01key=a\x05b\x01\x01", ++ ) ++ ), ++ ExpectedError( ++ PROTOCOL_VIOLATION_ERRCODE, ++ "malformed OAUTHBEARER message", ++ "invalid value", ++ ), ++ id="junk in value", ++ ), + ], +) +def test_oauth_bad_initial_response(conn, oauth_ctx, type, payload, err): + begin_oauth_handshake(conn, oauth_ctx) + + # The server expects a SASL response; give it something else instead. -+ if not isinstance(payload, dict): -+ payload = dict(payload_data=payload) -+ pq3.send(conn, type, **payload) ++ if type is not None: ++ # Build a new packet of the desired type. ++ if not isinstance(payload, dict): ++ payload = dict(payload_data=payload) ++ pq3.send(conn, type, **payload) ++ else: ++ # The test has a custom packet to send. (The only reason to do this is ++ # if the packet is corrupt or otherwise unbuildable/unparsable, so we ++ # don't use the standard pq3.send().) ++ conn.write(pq3.Pq3.build(payload)) ++ conn.end_packet(Container(payload)) + + resp = pq3.recv1(conn) + err.match(resp) @@ src/test/python/server/test_oauth.py (new) + row = resp.payload + expected = b"oauth:" + username.encode("utf-8") + assert row.columns == [expected] ++ ++ ++@pytest.fixture ++def odd_oauth_ctx(postgres_instance, oauth_ctx): ++ """ ++ Adds an HBA entry with messed up issuer/scope settings, to pin the server ++ behavior. ++ ++ TODO: these should really be rejected in the HBA rather than passed through ++ by the server. ++ """ ++ id = secrets.token_hex(4) ++ ++ class Context: ++ user = oauth_ctx.user ++ dbname = oauth_ctx.dbname ++ ++ # Both of these embedded double-quotes are invalid; they're prohibited ++ # in both URLs and OAuth scope identifiers. ++ issuer = oauth_ctx.issuer + '/"/' ++ scope = oauth_ctx.scope + ' quo"ted' ++ ++ ctx = Context() ++ hba_issuer = ctx.issuer.replace('"', '""') ++ hba_scope = ctx.scope.replace('"', '""') ++ hba_lines = [ ++ f'host {ctx.dbname} {ctx.user} samehost oauth issuer="{hba_issuer}" scope="{hba_scope}"\n', ++ ] ++ ++ if platform.system() == "Windows": ++ # XXX why is 'samehost' not behaving as expected on Windows? ++ for l in list(hba_lines): ++ hba_lines.append(l.replace("samehost", "::1/128")) ++ ++ host, port = postgres_instance ++ conn = psycopg2.connect(host=host, port=port) ++ conn.autocommit = True ++ ++ with contextlib.closing(conn): ++ c = conn.cursor() ++ ++ # Replace pg_hba. Note that it's already been replaced once by ++ # oauth_ctx, so use a different backup prefix in prepend_file(). ++ c.execute("SHOW hba_file;") ++ hba = c.fetchone()[0] ++ ++ with prepend_file(hba, hba_lines, suffix=".bak2"): ++ c.execute("SELECT pg_reload_conf();") ++ ++ yield ctx ++ ++ # Put things back the way they were. ++ c.execute("SELECT pg_reload_conf();") ++ ++ ++def test_odd_server_response(odd_oauth_ctx, connect): ++ """ ++ Verifies that the server is correctly escaping the JSON in its failure ++ response. ++ """ ++ conn = connect() ++ begin_oauth_handshake(conn, odd_oauth_ctx, user=odd_oauth_ctx.user) ++ ++ # Send an empty auth initial response, which will force an authn failure. ++ send_initial_response(conn, auth=b"") ++ ++ expect_handshake_failure(conn, odd_oauth_ctx) ## src/test/python/server/test_server.py (new) ## @@ @@ src/test/python/test_pq3.py (new) + [ + pytest.param( + dict(type=b"*", len=5), -+ b"*\x00\x00\x00\x05\x00", ++ b"*\x00\x00\x00\x05", + id="type and len set explicitly", + ), + pytest.param( @@ src/test/python/test_pq3.py (new) + id="implied len with payload", + ), + pytest.param( ++ dict(type=b"*", len=12, payload=b"1234"), ++ b"*\x00\x00\x00\x0C1234", ++ id="overridden len (payload underflow)", ++ ), ++ pytest.param( ++ dict(type=b"*", len=5, payload=b"1234"), ++ b"*\x00\x00\x00\x051234", ++ id="overridden len (payload overflow)", ++ ), ++ pytest.param( + dict(type=pq3.types.AuthnRequest, payload=dict(type=pq3.authn.OK)), + b"R\x00\x00\x00\x08\x00\x00\x00\x00", + id="implied len/type for AuthenticationOK", 8: 116e17eeee = 8: 185f9902fd XXX temporary patches to build and test 9: 28756eda1c ! 9: c4d850a7c4 WIP: Python OAuth provider implementation @@ src/test/modules/oauth_validator/meson.build: tests += { } ## src/test/modules/oauth_validator/t/001_server.pl ## -@@ src/test/modules/oauth_validator/t/001_server.pl: $node->append_conf('postgresql.conf', "shared_preload_libraries = 'validator'\n" - $node->append_conf('postgresql.conf', "oauth_validator_library = 'validator'\n"); - $node->start; +@@ src/test/modules/oauth_validator/t/001_server.pl: $node->start; + $node->safe_psql('postgres', 'CREATE USER test;'); + $node->safe_psql('postgres', 'CREATE USER testalt;'); --reset_pg_hba($node, 'all', 'all', 'oauth issuer="127.0.0.1:18080" scope="openid postgres"'); -- --my $webserver = PostgreSQL::Test::OAuthServer->new(18080); +-my $issuer = "127.0.0.1:18080"; +my $webserver = PostgreSQL::Test::OAuthServer->new(); +$webserver->run(); ++ ++my $port = $webserver->port(); ++my $issuer = "127.0.0.1:$port"; - my $port = $webserver->port(); + unlink($node->data_dir . '/pg_hba.conf'); + $node->append_conf('pg_hba.conf', qq{ +@@ src/test/modules/oauth_validator/t/001_server.pl: local all testalt oauth issuer="$issuer/alternate" scope="openid postgres alt" + }); + $node->reload; + +-my $webserver = PostgreSQL::Test::OAuthServer->new(18080); +- +-my $port = $webserver->port(); - -is($port, 18080, "Port is 18080"); - -$webserver->setup(); -$webserver->run(); -+reset_pg_hba($node, 'all', 'all', 'oauth issuer="127.0.0.1:' . $port . '" scope="openid postgres"'); +- + my ($log_start, $log_end); + $log_start = $node->wait_for_log(qr/reloading configuration files/); - $node->connect_ok("dbname=postgres oauth_client_id=f02c6361-0635", "connect", - expected_stderr => qr@Visit https://example\.com/ and enter the code: postgresuser@); +@@ src/test/modules/oauth_validator/t/001_server.pl: $node->log_check("user $user: validator sets authenticated identity", $log_start + ]); + $log_start = $log_end; +$webserver->stop(); $node->stop; @@ src/test/modules/oauth_validator/t/oauth_server.py (new) +class OAuthHandler(http.server.BaseHTTPRequestHandler): + JsonObject = dict[str, object] # TypeAlias is not available until 3.10 + ++ def _check_issuer(self): ++ """ ++ Switches the behavior of the provider depending on the issuer URI. ++ """ ++ self._alt_issuer = self.path.startswith("/alternate/") ++ if self._alt_issuer: ++ self.path = self.path.removeprefix("/alternate") ++ + def do_GET(self): ++ self._check_issuer() ++ + if self.path == "/.well-known/openid-configuration": + resp = self.config() + else: @@ src/test/modules/oauth_validator/t/oauth_server.py (new) + self._send_json(resp) + + def do_POST(self): ++ self._check_issuer() ++ + if self.path == "/authorize": + resp = self.authorization() + elif self.path == "/token": @@ src/test/modules/oauth_validator/t/oauth_server.py (new) + + def config(self) -> JsonObject: + port = self.server.socket.getsockname()[1] ++ issuer = f"http://localhost:{port}" ++ if self._alt_issuer: ++ issuer += "/alternate" + + return { -+ "issuer": f"http://localhost:{port}", -+ "token_endpoint": f"http://localhost:{port}/token", -+ "device_authorization_endpoint": f"http://localhost:{port}/authorize", ++ "issuer": issuer, ++ "token_endpoint": issuer + "/token", ++ "device_authorization_endpoint": issuer + "/authorize", + "response_types_supported": ["token"], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["RS256"], @@ src/test/modules/oauth_validator/t/oauth_server.py (new) + } + + def authorization(self) -> JsonObject: ++ uri = "https://example.com/" ++ if self._alt_issuer: ++ uri = "https://example.org/" ++ + return { + "device_code": "postgres", + "user_code": "postgresuser", + "interval": 0, -+ "verification_uri": "https://example.com/", ++ "verification_uri": uri, + "expires-in": 5, + } + + def token(self) -> JsonObject: ++ token = "9243959234" ++ if self._alt_issuer: ++ token += "-alt" ++ + return { -+ "access_token": "9243959234", ++ "access_token": token, + "token_type": "bearer", + } + @@ src/test/perl/PostgreSQL/Test/OAuthServer.pm: sub port - #} - #printf ": POST: " . $request{'content'} . "\n" if defined $request{'content'}; - +- my $alternate = 0; +- if ($request{'object'} =~ qr|^/alternate(/.*)$|) +- { +- $alternate = 1; +- $request{'object'} = $1; +- } +- - if ($request{'object'} eq '/.well-known/openid-configuration') - { +- my $issuer = "http://localhost:$self->{'port'}"; +- if ($alternate) +- { +- $issuer .= "/alternate"; +- } +- - print $fh "HTTP/1.0 200 OK\r\nServer: Postgres Regress\r\n"; - print $fh "Content-Type: application/json\r\n"; - print $fh "\r\n"; - print $fh <{'port'}", -- "token_endpoint": "http://localhost:$self->{'port'}/token", -- "device_authorization_endpoint": "http://localhost:$self->{'port'}/authorize", +- "issuer": "$issuer", +- "token_endpoint": "$issuer/token", +- "device_authorization_endpoint": "$issuer/authorize", - "response_types_supported": ["token"], - "subject_types_supported": ["public"], - "id_token_signing_alg_values_supported": ["RS256"], @@ src/test/perl/PostgreSQL/Test/OAuthServer.pm: sub port - } - elsif ($request{'object'} eq '/authorize') - { +- my $uri = "https://example.com/"; +- if ($alternate) +- { +- $uri = "https://example.org/"; +- } +- - print ": returning device_code\n"; - print $fh "HTTP/1.0 200 OK\r\nServer: Postgres Regress\r\n"; - print $fh "Content-Type: application/json\r\n"; @@ src/test/perl/PostgreSQL/Test/OAuthServer.pm: sub port - "device_code": "postgres", - "user_code" : "postgresuser", - "interval" : 0, -- "verification_uri" : "https://example.com/", +- "verification_uri" : "$uri", - "expires-in": 5 - } -EOR - } - elsif ($request{'object'} eq '/token') - { +- my $token = "9243959234"; +- if ($alternate) +- { +- $token .= "-alt"; +- } +- - print ": returning token\n"; - print $fh "HTTP/1.0 200 OK\r\nServer: Postgres Regress\r\n"; - print $fh "Content-Type: application/json\r\n"; - print $fh "\r\n"; - print $fh <