From 9506d0403149b8445e4dbc46ba40ff17b3625ba0 Mon Sep 17 00:00:00 2001 From: Andreas Karlsson Date: Wed, 4 Feb 2026 02:03:27 +0100 Subject: [PATCH 1/6] WIP: Fix support for built-in OAuth hooks Since the buitt-in OAuth hooks in libpq can return timerfd and not jsut a socket when you ask for the current file descriptor we are waiting on we need to make sure to use the right Ruby class to wrap the file descriptor, if it is not a valid socket we should use IO. --- .gitignore | 3 ++ Gemfile | 1 + ext/pg_connection.c | 15 ++++++++- spec/helpers.rb | 19 +++++++++++- spec/oauth/Makefile | 8 +++++ spec/oauth/dummy_validator.c | 29 ++++++++++++++++++ spec/pg/connection_spec.rb | 59 ++++++++++++++++++++++++++++++++++++ 7 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 spec/oauth/Makefile create mode 100644 spec/oauth/dummy_validator.c diff --git a/.gitignore b/.gitignore index 1de9c8ee9..b82fc544c 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,9 @@ /lib/2.?/ /lib/3.?/ /pkg/ +/spec/oauth/*.bc +/spec/oauth/*.o +/spec/oauth/*.so /tmp/ /tmp_test_*/ /vendor/ diff --git a/Gemfile b/Gemfile index c22c0988b..8cd0309ad 100644 --- a/Gemfile +++ b/Gemfile @@ -15,6 +15,7 @@ group :test do gem "rake-compiler", "~> 1.0" gem "rake-compiler-dock", "~> 1.11.0" #, git: "https://github.com/rake-compiler/rake-compiler-dock" gem "rspec", "~> 3.5" + gem "webrick", "~> 1.8" # "bigdecimal" is a gem on ruby-3.4+ and it's optional for ruby-pg. # Specs should succeed without it, but 4 examples are then excluded. # With bigdecimal commented out here, corresponding tests are omitted on ruby-3.4+ but are executed on ruby < 3.4. diff --git a/ext/pg_connection.c b/ext/pg_connection.c index c8b71d67b..ec56f4c99 100644 --- a/ext/pg_connection.c +++ b/ext/pg_connection.c @@ -965,6 +965,19 @@ pgconn_socket(VALUE self) return INT2NUM(sd); } +#ifdef _WIN32 +#define is_socket(fd) rb_w32_is_socket(fd) +#else +static int +is_socket(int fd) +{ + struct stat sbuf; + + if (fstat(fd, &sbuf) < 0) + rb_sys_fail("fstat(2)"); + return S_ISSOCK(sbuf.st_mode); +} +#endif VALUE pg_wrap_socket_io(int sd, VALUE self, VALUE *p_socket_io, int *p_ruby_sd) @@ -983,7 +996,7 @@ pg_wrap_socket_io(int sd, VALUE self, VALUE *p_socket_io, int *p_ruby_sd) *p_ruby_sd = ruby_sd = sd; #endif - cSocket = rb_const_get(rb_cObject, rb_intern("BasicSocket")); + cSocket = rb_const_get(rb_cObject, rb_intern(is_socket(ruby_sd) ? "BasicSocket" : "IO")); socket_io = rb_funcall( cSocket, rb_intern("for_fd"), 1, INT2NUM(ruby_sd)); /* Disable autoclose feature */ diff --git a/spec/helpers.rb b/spec/helpers.rb index e974def5e..f0c9b707c 100644 --- a/spec/helpers.rb +++ b/spec/helpers.rb @@ -194,6 +194,7 @@ class PostgresServer attr_reader :port attr_reader :conninfo attr_reader :unix_socket + attr_reader :version ### Set up a PostgreSQL database instance for testing. def initialize(name, port: 23456, postgresql_conf: '') @@ -205,6 +206,7 @@ def initialize(name, port: 23456, postgresql_conf: '') @pgdata = @test_dir + 'data' @logfile = @test_dir + 'setup.log' @pg_bindir = pg_bindir + @version = pg_version @unix_socket = @test_dir.to_s @conninfo = "host=localhost port=#{@port} dbname=test sslrootcert=#{@pgdata + 'ruby-pg-ca-cert'} sslcert=#{@pgdata + 'ruby-pg-client-cert'} sslkey=#{@pgdata + 'ruby-pg-client-key'}" @@ -267,8 +269,13 @@ def setup_cluster(postgresql_conf) ssl_cert_file = 'ruby-pg-server-cert' ssl_key_file = 'ruby-pg-server-key' fsync = off - #{postgresql_conf} EOT + if @version >= 18 + fd.puts <<~EOT + oauth_validator_libraries = '#{TEST_DIRECTORY}/spec/oauth/dummy_validator' + EOT + end + fd.puts postgresql_conf end # Enable MD5 authentication in hba config @@ -278,6 +285,12 @@ def setup_cluster(postgresql_conf) # TYPE DATABASE USER ADDRESS METHOD host all testusermd5 ::1/128 md5 EOT + if @version >= 18 + fd.puts <<~EOT + host all testuseroauth 127.0.0.1/32 oauth scope=test issuer="http://localhost:#{@port + 3}" + host all testuseroauth ::1/32 oauth scope=test issuer="http://localhost:#{@port + 3}" + EOT + end fd.puts hba_content end @@ -340,6 +353,10 @@ def pg_bindir rescue nil end + + def pg_version + `#{pg_bin_path("pg_ctl")} --version`[/pg_ctl \(PostgreSQL\) (\d+)/, 1]&.to_i + end end class CertGenerator diff --git a/spec/oauth/Makefile b/spec/oauth/Makefile new file mode 100644 index 000000000..508292cea --- /dev/null +++ b/spec/oauth/Makefile @@ -0,0 +1,8 @@ +MODULES = dummy_validator +PGFILEDESC = "dummy_validator - dummy OAuth validator" + +OBJS = $(WIN32RES) + +PG_CONFIG = pg_config +PGXS := $(shell $(PG_CONFIG) --pgxs) +include $(PGXS) diff --git a/spec/oauth/dummy_validator.c b/spec/oauth/dummy_validator.c new file mode 100644 index 000000000..33018392a --- /dev/null +++ b/spec/oauth/dummy_validator.c @@ -0,0 +1,29 @@ +#include "postgres.h" +#include "fmgr.h" +#include "libpq/oauth.h" + +PG_MODULE_MAGIC; + +static bool +validate_token(const ValidatorModuleState *state, + const char *token, const char *role, + ValidatorModuleResult *res) +{ + if (strcmp(token, "yes") == 0) + { + res->authorized = true; + res->authn_id = pstrdup(role); + } + return true; +} + +static const OAuthValidatorCallbacks validator_callbacks = { + PG_OAUTH_VALIDATOR_MAGIC, + .validate_cb = validate_token +}; + +const OAuthValidatorCallbacks * +_PG_oauth_validator_module_init(void) +{ + return &validator_callbacks; +} diff --git a/spec/pg/connection_spec.rb b/spec/pg/connection_spec.rb index 7763493a2..24204a3cd 100644 --- a/spec/pg/connection_spec.rb +++ b/spec/pg/connection_spec.rb @@ -3010,4 +3010,63 @@ def wait_check_socket(conn) .to raise_error(TypeError) end end + + describe "OAuth support", :postgresql_18 do + before :all do + skip "requires a PostgreSQL 18 cluster" unless $pg_server.version >= 18 + + system "make", "-s", "-C", (TEST_DIRECTORY + "spec/oauth").to_s + raise "Building OAuth validator library failed!" unless $?.success? + + require 'webrick' + + PG.connect(@conninfo) do |conn| + conn.exec("DROP USER IF EXISTS testuseroauth") + conn.exec("CREATE USER testuseroauth") + end + end + + before :each do + @old_env, ENV["PGOAUTHDEBUG"] = ENV["PGOAUTHDEBUG"], "UNSAFE" + end + + def start_fake_oauth(port) + server = WEBrick::HTTPServer.new(Port: port, Logger: WEBrick::Log.new(nil, WEBrick::BasicLog::WARN)) + server.mount_proc("/.well-known/openid-configuration") do |req, res| + res["Content-Type"] = "application/json" + res.body = %!{"issuer":"http://localhost:#{port}","token_endpoint":"http://localhost:#{port}/token","device_authorization_endpoint":"http://localhost:#{@port + 3}/devauth"}! + end + server.mount_proc("/devauth") do |req, res| + res["Content-Type"] = "application/json" + res.body = %!{"device_code":"42","user_code":"666","verification_uri":"http://localhost:#{port}/verify","expires_in":60}! + end + server.mount_proc("/token") do |req, res| + res["Content-Type"] = "application/json" + res.body = %!{"access_token":"yes","token_type":""}! + end + Thread.new { server.start } + server + end + + it "should work with no hook" do + oauth_server = start_fake_oauth(@port + 3) + + begin + PG.connect("host=localhost port=#{@port} dbname=test user=testuseroauth oauth_issuer=http://localhost:#{@port + 3} oauth_client_id=foo") do |conn| + conn.exec("SELECT 1") + end + rescue PG::ConnectionBad => e + if e.message =~ /no OAuth flows are available/ + skip "requires libpq-oauth to be installed" + end + raise + ensure + oauth_server.shutdown + end + end + + after :each do + ENV["PGOAUTHDEBUG"] = @old_env + end + end end From 58435ab26b5f6a6ed4b42b06dc4a31ff87616d9a Mon Sep 17 00:00:00 2001 From: Lars Kanis Date: Wed, 11 Feb 2026 14:22:40 +0100 Subject: [PATCH 2/6] Expose libpq's OAuth hooks Async is not supported yet, --- ext/gvl_wrappers.h | 17 ++ ext/pg.c | 1 + ext/pg.h | 4 + ext/pg_auth_hooks.c | 388 +++++++++++++++++++++++++++++++++++++ spec/pg/connection_spec.rb | 86 +++++++- 5 files changed, 495 insertions(+), 1 deletion(-) create mode 100644 ext/pg_auth_hooks.c diff --git a/ext/gvl_wrappers.h b/ext/gvl_wrappers.h index f048d7055..475d6f423 100644 --- a/ext/gvl_wrappers.h +++ b/ext/gvl_wrappers.h @@ -281,17 +281,34 @@ FOR_EACH_BLOCKING_FUNCTION( DEFINE_GVL_STUB_DECL ); * Definitions of callback functions and their parameters */ +#define FOR_EACH_PARAM_OF_auth_data_hook_proxy(param) \ + param(PGauthData, type) \ + param(PGconn *, conn) + #define FOR_EACH_PARAM_OF_notice_processor_proxy(param) \ param(void *, arg) #define FOR_EACH_PARAM_OF_notice_receiver_proxy(param) \ param(void *, arg) +#ifdef LIBPQ_HAS_PROMPT_OAUTH_DEVICE + /* function( name, void_or_nonvoid, returntype, lastparamtype, lastparamname ) */ #define FOR_EACH_CALLBACK_FUNCTION(function) \ + function(auth_data_hook_proxy, GVL_TYPE_NONVOID, int, void *, data) \ function(notice_processor_proxy, GVL_TYPE_VOID, void, const char *, message) \ function(notice_receiver_proxy, GVL_TYPE_VOID, void, const PGresult *, result) \ +#else + +/* function( name, void_or_nonvoid, returntype, lastparamtype, lastparamname ) */ +#define FOR_EACH_CALLBACK_FUNCTION(function) \ + function(notice_processor_proxy, GVL_TYPE_VOID, void, const char *, message) \ + function(notice_receiver_proxy, GVL_TYPE_VOID, void, const PGresult *, result) \ + +#endif + FOR_EACH_CALLBACK_FUNCTION( DEFINE_GVL_STUB_DECL ); + #endif /* end __gvl_wrappers_h */ diff --git a/ext/pg.c b/ext/pg.c index 67969b1cd..aa1395711 100644 --- a/ext/pg.c +++ b/ext/pg.c @@ -682,6 +682,7 @@ Init_pg_ext(void) /* Initialize the main extension classes */ init_pg_connection(); + init_pg_auth_hooks(); init_pg_result(); init_pg_errors(); init_pg_type_map(); diff --git a/ext/pg.h b/ext/pg.h index 58fa630d2..be529a380 100644 --- a/ext/pg.h +++ b/ext/pg.h @@ -288,6 +288,7 @@ extern VALUE pg_typemap_all_strings; void Init_pg_ext _(( void )); void init_pg_connection _(( void )); +void init_pg_auth_hooks _(( void )); void init_pg_result _(( void )); void init_pg_errors _(( void )); void init_pg_type_map _(( void )); @@ -374,6 +375,9 @@ rb_encoding * pg_get_pg_encname_as_rb_encoding _(( const char * )); const char * pg_get_rb_encoding_as_pg_encoding _(( rb_encoding * )); rb_encoding *pg_conn_enc_get _(( PGconn * )); +#ifdef LIBPQ_HAS_PROMPT_OAUTH_DEVICE +int auth_data_hook_proxy(PGauthData type, PGconn *conn, void *data); +#endif void notice_receiver_proxy(void *arg, const PGresult *result); void notice_processor_proxy(void *arg, const char *message); diff --git a/ext/pg_auth_hooks.c b/ext/pg_auth_hooks.c new file mode 100644 index 000000000..7d376e1f7 --- /dev/null +++ b/ext/pg_auth_hooks.c @@ -0,0 +1,388 @@ +/* + * pg_auth_hooks.c - Auth hooks for PG module + * $Id$ + * + */ + +#include "pg.h" + +#if LIBPQ_HAS_PROMPT_OAUTH_DEVICE + +#ifdef TRUFFLERUBY +static VALUE auth_data_hook; +#else +/* + * On Ruby verisons which support Ractors we store the global callback once + * per Ractor. + */ +#include "ruby/ractor.h" +static rb_ractor_local_key_t auth_data_hook_key; +#endif + +static void +auth_data_hook_init(void) +{ +#ifdef TRUFFLERUBY + auth_data_hook = Qnil; + rb_gc_register_address(&auth_data_hook); +#else + auth_data_hook_key = rb_ractor_local_storage_value_newkey(); +#endif +} + +static VALUE +auth_data_hook_get(void) +{ +#ifdef TRUFFLERUBY + return auth_data_hook; +#else + VALUE hook = Qnil; + rb_ractor_local_storage_value_lookup(auth_data_hook_key, &hook); + return hook; +#endif +} + +static void +auth_data_hook_set(VALUE hook) +{ +#ifdef TRUFFLERUBY + auth_data_hook = hook; +#else + rb_ractor_local_storage_value_set(auth_data_hook_key, hook); +#endif +} + +static VALUE rb_cPromptOAuthDevice; +static VALUE rb_cOAuthBearerRequest; + +/* + * Document-class: PG::PromptOAuthDevice + */ + +typedef struct { + PGpromptOAuthDevice *prompt; +} t_pg_prompt_oauth_device; + +static size_t +pg_prompt_oauth_device_memsize(const void *_this) +{ + return sizeof(t_pg_prompt_oauth_device); +} + +static const rb_data_type_t pg_prompt_oauth_device_type = { + "PG::PromptOAuthDevice", + { + NULL, + RUBY_TYPED_DEFAULT_FREE, + pg_prompt_oauth_device_memsize, + NULL, + }, + 0, + 0, + RUBY_TYPED_WB_PROTECTED | RUBY_TYPED_FREE_IMMEDIATELY, +}; + +static t_pg_prompt_oauth_device * +pg_get_prompt_oauth_device_safe(VALUE self) +{ + t_pg_prompt_oauth_device *this; + + TypedData_Get_Struct(self, t_pg_prompt_oauth_device, &pg_prompt_oauth_device_type, this); + + if (!this->prompt) + rb_raise(rb_ePGerror, "data cannot be accessed after callback has completed"); + + return this; +} + +/* + * call-seq: + * prompt.verification_uri -> String + */ +static VALUE +pg_prompt_oauth_device_verification_uri(VALUE self) +{ + t_pg_prompt_oauth_device *this = pg_get_prompt_oauth_device_safe(self); + + if (!this->prompt->verification_uri) + rb_raise(rb_ePGerror, "internal error: verification_uri is missing"); + + return rb_str_new_cstr(this->prompt->verification_uri); +} + +/* + * call-seq: + * prompt.user_code -> String + */ +static VALUE +pg_prompt_oauth_device_user_code(VALUE self) +{ + t_pg_prompt_oauth_device *this = pg_get_prompt_oauth_device_safe(self); + + if (!this->prompt->user_code) + rb_raise(rb_ePGerror, "internal error: user_code is missing"); + + return rb_str_new_cstr(this->prompt->user_code); +} + +/* + * call-seq: + * prompt.verification_uri_complete -> String | nil + */ +static VALUE +pg_prompt_oauth_device_verification_uri_complete(VALUE self) +{ + t_pg_prompt_oauth_device *this = pg_get_prompt_oauth_device_safe(self); + + return this->prompt->verification_uri_complete ? rb_str_new_cstr(this->prompt->verification_uri_complete) : Qnil; +} + +/* + * call-seq: + * prompt.expires_in -> Integer + */ +static VALUE +pg_prompt_oauth_device_expires_in(VALUE self) +{ + t_pg_prompt_oauth_device *this = pg_get_prompt_oauth_device_safe(self); + + return INT2FIX(this->prompt->expires_in); +} + +/* + * Document-class: PG::OAuthBearerRequest + */ + +typedef struct { + PGoauthBearerRequest *request; +} t_pg_oauth_bearer_request; + +static size_t +pg_oauth_bearer_request_memsize(const void *_this) +{ + return sizeof(t_pg_oauth_bearer_request); +} + +static const rb_data_type_t pg_oauth_bearer_request_type = { + "PG::OAuthBearerRequest", + { + NULL, + RUBY_TYPED_DEFAULT_FREE, + pg_oauth_bearer_request_memsize, + NULL, + }, + 0, + 0, + RUBY_TYPED_WB_PROTECTED | RUBY_TYPED_FREE_IMMEDIATELY, +}; + +static t_pg_oauth_bearer_request * +pg_get_oauth_bearer_request_safe(VALUE self) +{ + t_pg_oauth_bearer_request *this; + + TypedData_Get_Struct(self, t_pg_oauth_bearer_request, &pg_oauth_bearer_request_type, this); + + if (!this->request) + rb_raise(rb_ePGerror, "data cannot be accessed after callback has completed"); + + return this; +} + +/* + * call-seq: + * prompt.openid_configuration -> String + */ +static VALUE +pg_oauth_bearer_request_openid_configuration(VALUE self) +{ + t_pg_oauth_bearer_request *this = pg_get_oauth_bearer_request_safe(self); + + if (!this->request->openid_configuration) + rb_raise(rb_ePGerror, "internal error: openid_configuration is missing"); + + return rb_str_new_cstr(this->request->openid_configuration); +} + +/* + * call-seq: + * request.scope -> String | nil + */ +static VALUE +pg_oauth_bearer_request_scope(VALUE self) +{ + t_pg_oauth_bearer_request *this = pg_get_oauth_bearer_request_safe(self); + + return this->request->scope ? rb_str_new_cstr(this->request->scope) : Qnil; +} + +/* + * call-seq: + * request.token = token + * + * See also #token + */ +static VALUE +pg_oauth_bearer_request_token_set(VALUE self, VALUE token) +{ + t_pg_oauth_bearer_request *this = pg_get_oauth_bearer_request_safe(self); + + /* This can throw an exception so needs to be done before free() */ + char *token_cstr = NIL_P(token) ? NULL : strdup(StringValueCStr(token)); + + if (this->request->token) + free(this->request->token); + + this->request->token = token_cstr; + + return token; +} + +/* + * call-seq: + * request.token -> String | nil + * + * See also #token= + */ +static VALUE +pg_oauth_bearer_request_token_get(VALUE self) +{ + t_pg_oauth_bearer_request *this = pg_get_oauth_bearer_request_safe(self); + + return this->request->token ? rb_str_new_cstr(this->request->token) : Qnil; +} + +static void +oauth_bearer_request_cleanup(PGconn *_conn, struct PGoauthBearerRequest *request) +{ + if (request->token) + free(request->token); +} + +static VALUE +call_auth_data_hook(VALUE data) +{ + VALUE proc = auth_data_hook_get(); + + return rb_funcall(proc, rb_intern("call"), 1, data); +} + +static VALUE +prompt_oauth_device_hook_cleanup(VALUE self, VALUE ex) +{ + t_pg_prompt_oauth_device *this = pg_get_prompt_oauth_device_safe(self); + + this->prompt = NULL; + + rb_exc_raise(ex); +} + +static VALUE +oauth_bearer_request_hook_cleanup(VALUE self, VALUE ex) +{ + t_pg_oauth_bearer_request *this = pg_get_oauth_bearer_request_safe(self); + + if (this->request->token) + free(this->request->token); + this->request->token = NULL; + + this->request = NULL; + + rb_exc_raise(ex); +} + +/* + * Auth data proxy function -- delegate the callback to the + * currently-registered Ruby auth_data_hook object. + */ +int +auth_data_hook_proxy(PGauthData type, PGconn *_conn, void *data) +{ + VALUE proc = auth_data_hook_get(), ret = Qnil; + + if (proc != Qnil) { + if (type == PQAUTHDATA_PROMPT_OAUTH_DEVICE) { + t_pg_prompt_oauth_device *prompt; + + VALUE v_prompt = TypedData_Make_Struct(rb_cPromptOAuthDevice, t_pg_prompt_oauth_device, &pg_prompt_oauth_device_type, prompt); + + prompt->prompt = data; + + ret = rb_rescue(call_auth_data_hook, v_prompt, prompt_oauth_device_hook_cleanup, v_prompt); + + prompt->prompt = NULL; + } else if (type == PQAUTHDATA_OAUTH_BEARER_TOKEN) { + t_pg_oauth_bearer_request *request; + + VALUE v_request = TypedData_Make_Struct(rb_cOAuthBearerRequest, t_pg_oauth_bearer_request, &pg_oauth_bearer_request_type, request); + + request->request = data; + request->request->cleanup = oauth_bearer_request_cleanup; + + ret = rb_rescue(call_auth_data_hook, v_request, oauth_bearer_request_hook_cleanup, v_request); + + request->request = NULL; + } + } + + return RTEST(ret); +} + +/* + * Document-method: PG.set_auth_data_hook + * + * call-seq: + * PG.set_auth_data_hook {|data| ... } -> Proc + * + * If you pass no arguments, it will reset the handler to the default. + */ +static VALUE +pg_s_set_auth_data_hook(VALUE _self) +{ + PQsetAuthDataHook(gvl_auth_data_hook_proxy); // TODO: Add some safeguards? + + VALUE old_proc = auth_data_hook_get(), proc; + + if (rb_block_given_p()) { + proc = rb_block_proc(); + } else { + /* if no block is given, set back to default */ + proc = Qnil; + } + + auth_data_hook_set(proc); + + return old_proc; +} + +void +init_pg_auth_hooks(void) +{ + auth_data_hook_init(); + + /* rb_mPG = rb_define_module("PG") */ + + rb_define_singleton_method(rb_mPG, "set_auth_data_hook", pg_s_set_auth_data_hook, 0); + + rb_cPromptOAuthDevice = rb_define_class_under(rb_mPG, "PromptOAuthDevice", rb_cObject); + rb_undef_alloc_func(rb_cPromptOAuthDevice); + + rb_define_method(rb_cPromptOAuthDevice, "verification_uri", pg_prompt_oauth_device_verification_uri, 0); + rb_define_method(rb_cPromptOAuthDevice, "user_code", pg_prompt_oauth_device_user_code, 0); + rb_define_method(rb_cPromptOAuthDevice, "verification_uri_complete", pg_prompt_oauth_device_verification_uri_complete, 0); + rb_define_method(rb_cPromptOAuthDevice, "expires_in", pg_prompt_oauth_device_expires_in, 0); + + rb_cOAuthBearerRequest = rb_define_class_under(rb_mPG, "OAuthBearerRequest", rb_cObject); + rb_undef_alloc_func(rb_cOAuthBearerRequest); + + rb_define_method(rb_cOAuthBearerRequest, "openid_configuration", pg_oauth_bearer_request_openid_configuration, 0); + rb_define_method(rb_cOAuthBearerRequest, "scope", pg_oauth_bearer_request_scope, 0); + rb_define_method(rb_cOAuthBearerRequest, "token=", pg_oauth_bearer_request_token_set, 1); + rb_define_method(rb_cOAuthBearerRequest, "token", pg_oauth_bearer_request_token_get, 0); +} + +#else + +void init_pg_auth_hooks(void) {} + +#endif diff --git a/spec/pg/connection_spec.rb b/spec/pg/connection_spec.rb index 24204a3cd..5acac81f5 100644 --- a/spec/pg/connection_spec.rb +++ b/spec/pg/connection_spec.rb @@ -3011,7 +3011,7 @@ def wait_check_socket(conn) end end - describe "OAuth support", :postgresql_18 do + describe "PG.set_auth_data_hook", :postgresql_18 do before :all do skip "requires a PostgreSQL 18 cluster" unless $pg_server.version >= 18 @@ -3065,7 +3065,91 @@ def start_fake_oauth(port) end end + it "should call prompt oauth device hook" do + oauth_server = start_fake_oauth(@port + 3) + + verification_uri, user_code, verification_uri_complete, expires_in = nil, nil, nil, nil + + PG.set_auth_data_hook do |data| + case data + when PG::PromptOAuthDevice + verification_uri = data.verification_uri + user_code = data.user_code + verification_uri_complete = data.verification_uri_complete + expires_in = data.expires_in + true + end + end + + begin + PG.connect("host=localhost port=#{@port} dbname=test user=testuseroauth oauth_issuer=http://localhost:#{@port + 3} oauth_client_id=foo") do |conn| + conn.exec("SELECT 1") + end + rescue PG::ConnectionBad => e + if e.message =~ /no OAuth flows are available/ + skip "requires libpq-oauth to be installed" + end + raise + ensure + oauth_server.shutdown + end + + expect(verification_uri).to eq("http://localhost:#{@port + 3}/verify") + expect(user_code).to eq("666") + expect(verification_uri_complete).to eq(nil) + expect(expires_in).to eq(60) + end + + it "should call oauth bearer reuqest hook" do + openid_configuration, scope = nil, nil + + PG.set_auth_data_hook do |data| + case data + when PG::OAuthBearerRequest + openid_configuration = data.openid_configuration + scope = data.scope + data.token = "yes" + true + end + end + + PG.connect("host=localhost port=#{@port} dbname=test user=testuseroauth oauth_issuer=http://localhost:#{@port + 3} oauth_client_id=foo") do |conn| + conn.exec("SELECT 1") + end + + expect(openid_configuration).to eq("http://localhost:#{@port + 3}/.well-known/openid-configuration") + expect(scope).to eq("test") + end + + it "should reset the hook when called without block" do + oauth_server = start_fake_oauth(@port + 3) + + PG.set_auth_data_hook do |data| + raise "broken hook" + end + + expect do + PG.connect("host=localhost port=#{@port} dbname=test user=testuseroauth oauth_issuer=http://localhost:#{@port + 3} oauth_client_id=foo") {} + end.to raise_error("broken hook") + + PG.set_auth_data_hook + + begin + PG.connect("host=localhost port=#{@port} dbname=test user=testuseroauth oauth_issuer=http://localhost:#{@port + 3} oauth_client_id=foo") do |conn| + conn.exec("SELECT 1") + end + rescue PG::ConnectionBad => e + if e.message =~ /no OAuth flows are available/ + skip "requires libpq-oauth to be installed" + end + raise + ensure + oauth_server.shutdown + end + end + after :each do + PG.set_auth_data_hook ENV["PGOAUTHDEBUG"] = @old_env end end From 7b131ba95035109787cc8255655b4a78c55fe958 Mon Sep 17 00:00:00 2001 From: Andreas Karlsson Date: Fri, 6 Feb 2026 18:20:02 +0100 Subject: [PATCH 3/6] WIP: Attempt to fix CI --- .github/workflows/binary-gems.yml | 5 +++++ .github/workflows/source-gem.yml | 7 ++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/binary-gems.yml b/.github/workflows/binary-gems.yml index 175394262..870182159 100644 --- a/.github/workflows/binary-gems.yml +++ b/.github/workflows/binary-gems.yml @@ -111,6 +111,11 @@ jobs: brew: "postgresql" # macOS mingw: "postgresql" # Windows mingw / mswin /ucrt + - name: Install postgresql server headers Ubuntu + if: startsWith(matrix.os, 'ubuntu-') + run: | + sudo apt-get -y --allow-downgrades install '^postgresql-server-dev-[0-9]+$' libkrb5-dev + - name: Set up 32 bit x86 Ruby if: matrix.platform == 'x86-mingw32' run: | diff --git a/.github/workflows/source-gem.yml b/.github/workflows/source-gem.yml index 7c1c3deae..4e5eafb91 100644 --- a/.github/workflows/source-gem.yml +++ b/.github/workflows/source-gem.yml @@ -127,9 +127,14 @@ jobs: echo "deb http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main $PGVER" | sudo tee -a /etc/apt/sources.list.d/pgdg.list wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add - sudo apt-get -y update - sudo apt-get -y --allow-downgrades install postgresql-$PGVER libpq5=$PGVER* libpq-dev=$PGVER* + sudo apt-get -y --allow-downgrades install postgresql-$PGVER libpq5=$PGVER* libpq-dev=$PGVER* postgresql-server-dev-$PGVER libkrb5-dev echo /usr/lib/postgresql/$PGVER/bin >> $GITHUB_PATH + - name: Download OAuth support Ubuntu + if: matrix.os == 'ubuntu' && matrix.PGVER >= 18 + run: | + sudo apt-get -y --allow-downgrades install libpq-oauth=$PGVER* + - name: Download PostgreSQL Macos if: matrix.os == 'macos' run: | From ba18e248c651e4a589a6738f40b7954884dbb3af Mon Sep 17 00:00:00 2001 From: Andreas Karlsson Date: Tue, 10 Feb 2026 01:40:49 +0100 Subject: [PATCH 4/6] WIP: ubuntu-latest workaround --- .github/workflows/binary-gems.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/binary-gems.yml b/.github/workflows/binary-gems.yml index 870182159..4d2c6800c 100644 --- a/.github/workflows/binary-gems.yml +++ b/.github/workflows/binary-gems.yml @@ -101,6 +101,12 @@ jobs: env: PGVERSION: ${{ matrix.PGVERSION }} steps: + # Workaround for broken ubuntu-latest image. + # See https://github.com/Shopify/ruby-lsp/issues/3942 + - name: Remove pre-installed Ruby 4.0 + if: matrix.os == 'ubuntu-latest' && matrix.ruby == '4.0' + run: rm -rf /opt/hostedtoolcache/Ruby/4.0* + - uses: actions/checkout@v4 - name: Set up Ruby if: matrix.platform != 'x86-mingw32' From eca0506635d0707d5b388425768fe63e7c1df1be Mon Sep 17 00:00:00 2001 From: Lars Kanis Date: Wed, 11 Feb 2026 14:26:47 +0100 Subject: [PATCH 5/6] Unify GVL wrappers to missing libpq functions To avoid overly complicated or duplicated definitions. --- ext/gvl_wrappers.c | 3 +++ ext/gvl_wrappers.h | 14 +++----------- ext/pg_auth_hooks.c | 2 +- 3 files changed, 7 insertions(+), 12 deletions(-) diff --git a/ext/gvl_wrappers.c b/ext/gvl_wrappers.c index 8e2f0ad86..3cd36b40d 100644 --- a/ext/gvl_wrappers.c +++ b/ext/gvl_wrappers.c @@ -19,6 +19,9 @@ PostgresPollingStatusType PQcancelPoll(PGcancelConn *cancelConn){return PGRES_PO #ifndef LIBPQ_HAS_PIPELINING int PQpipelineSync(PGconn *conn){return 0;} #endif +#ifndef LIBPQ_HAS_PROMPT_OAUTH_DEVICE +int auth_data_hook_proxy(PGauthData type, PGconn *conn, void *data){return 0;} +#endif #ifdef ENABLE_GVL_UNLOCK FOR_EACH_BLOCKING_FUNCTION( DEFINE_GVL_WRAPPER_STRUCT ); diff --git a/ext/gvl_wrappers.h b/ext/gvl_wrappers.h index 475d6f423..52ade3eb3 100644 --- a/ext/gvl_wrappers.h +++ b/ext/gvl_wrappers.h @@ -24,6 +24,9 @@ #ifndef LIBPQ_HAS_CHUNK_MODE typedef struct pg_cancel_conn PGcancelConn; #endif +#ifndef LIBPQ_HAS_PROMPT_OAUTH_DEVICE +typedef enum { DUMMY_TYPE } PGauthData; +#endif #define DEFINE_PARAM_LIST1(type, name) \ name, @@ -291,23 +294,12 @@ FOR_EACH_BLOCKING_FUNCTION( DEFINE_GVL_STUB_DECL ); #define FOR_EACH_PARAM_OF_notice_receiver_proxy(param) \ param(void *, arg) -#ifdef LIBPQ_HAS_PROMPT_OAUTH_DEVICE - /* function( name, void_or_nonvoid, returntype, lastparamtype, lastparamname ) */ #define FOR_EACH_CALLBACK_FUNCTION(function) \ function(auth_data_hook_proxy, GVL_TYPE_NONVOID, int, void *, data) \ function(notice_processor_proxy, GVL_TYPE_VOID, void, const char *, message) \ function(notice_receiver_proxy, GVL_TYPE_VOID, void, const PGresult *, result) \ -#else - -/* function( name, void_or_nonvoid, returntype, lastparamtype, lastparamname ) */ -#define FOR_EACH_CALLBACK_FUNCTION(function) \ - function(notice_processor_proxy, GVL_TYPE_VOID, void, const char *, message) \ - function(notice_receiver_proxy, GVL_TYPE_VOID, void, const PGresult *, result) \ - -#endif - FOR_EACH_CALLBACK_FUNCTION( DEFINE_GVL_STUB_DECL ); diff --git a/ext/pg_auth_hooks.c b/ext/pg_auth_hooks.c index 7d376e1f7..0caa46286 100644 --- a/ext/pg_auth_hooks.c +++ b/ext/pg_auth_hooks.c @@ -6,7 +6,7 @@ #include "pg.h" -#if LIBPQ_HAS_PROMPT_OAUTH_DEVICE +#ifdef LIBPQ_HAS_PROMPT_OAUTH_DEVICE #ifdef TRUFFLERUBY static VALUE auth_data_hook; From dc92807f654db2799af70f1b15ac30c5c1738733 Mon Sep 17 00:00:00 2001 From: Lars Kanis Date: Wed, 11 Feb 2026 21:37:01 +0100 Subject: [PATCH 6/6] Pass the PGconn address to ruby This allows to pass the OAuth request to the related PG::Connection object. The intention of this change is to prepare for a connection local hook. Another option would be to wrap the PGconn address in the callback into a regular PG::Connection object. But this is difficult to combine with the garbage collector. In the end a wrapped object isn't needed, since the PG::Connection is already present after `PG::Connection.connect_start`. This is before the hook is called. So in the callback it's enough to compare the PGconn addresses. --- ext/pg_auth_hooks.c | 16 ++++++++++------ ext/pg_connection.c | 17 +++++++++++++++++ spec/pg/connection_spec.rb | 6 +++--- 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/ext/pg_auth_hooks.c b/ext/pg_auth_hooks.c index 0caa46286..75fd0cc9e 100644 --- a/ext/pg_auth_hooks.c +++ b/ext/pg_auth_hooks.c @@ -260,11 +260,13 @@ oauth_bearer_request_cleanup(PGconn *_conn, struct PGoauthBearerRequest *request } static VALUE -call_auth_data_hook(VALUE data) +call_auth_data_hook(VALUE args) { - VALUE proc = auth_data_hook_get(); + VALUE proc = ((VALUE*)args)[0]; + VALUE conn_num = ((VALUE*)args)[1]; + VALUE v_data = ((VALUE*)args)[2]; - return rb_funcall(proc, rb_intern("call"), 1, data); + return rb_funcall(proc, rb_intern("call"), 2, conn_num, v_data); } static VALUE @@ -296,7 +298,7 @@ oauth_bearer_request_hook_cleanup(VALUE self, VALUE ex) * currently-registered Ruby auth_data_hook object. */ int -auth_data_hook_proxy(PGauthData type, PGconn *_conn, void *data) +auth_data_hook_proxy(PGauthData type, PGconn *conn, void *data) { VALUE proc = auth_data_hook_get(), ret = Qnil; @@ -305,21 +307,23 @@ auth_data_hook_proxy(PGauthData type, PGconn *_conn, void *data) t_pg_prompt_oauth_device *prompt; VALUE v_prompt = TypedData_Make_Struct(rb_cPromptOAuthDevice, t_pg_prompt_oauth_device, &pg_prompt_oauth_device_type, prompt); + VALUE args[] = { proc, PTR2NUM(conn), v_prompt }; prompt->prompt = data; - ret = rb_rescue(call_auth_data_hook, v_prompt, prompt_oauth_device_hook_cleanup, v_prompt); + ret = rb_rescue(call_auth_data_hook, (VALUE)&args, prompt_oauth_device_hook_cleanup, v_prompt); prompt->prompt = NULL; } else if (type == PQAUTHDATA_OAUTH_BEARER_TOKEN) { t_pg_oauth_bearer_request *request; VALUE v_request = TypedData_Make_Struct(rb_cOAuthBearerRequest, t_pg_oauth_bearer_request, &pg_oauth_bearer_request_type, request); + VALUE args[] = { proc, PTR2NUM(conn), v_request }; request->request = data; request->request->cleanup = oauth_bearer_request_cleanup; - ret = rb_rescue(call_auth_data_hook, v_request, oauth_bearer_request_hook_cleanup, v_request); + ret = rb_rescue(call_auth_data_hook, (VALUE)&args, oauth_bearer_request_hook_cleanup, v_request); request->request = NULL; } diff --git a/ext/pg_connection.c b/ext/pg_connection.c index ec56f4c99..fbbd3a822 100644 --- a/ext/pg_connection.c +++ b/ext/pg_connection.c @@ -623,6 +623,22 @@ pgconn_reset_poll(VALUE self) return INT2FIX((int)status); } +/* + * call-seq: + * conn.pgconn_address() + * + * Returns the PGconn address. + * + * It can be used to compare with the address received by the OAuth hook: + * + * PG.set_auth_data_hook do |pgconn_address, data| + */ +static VALUE +pgconn_pgconn_address(VALUE self) +{ + return PTR2NUM(pg_get_pgconn(self)); +} + /* * call-seq: @@ -4750,6 +4766,7 @@ init_pg_connection(void) rb_define_private_method(rb_cPGconn, "reset_start2", pgconn_reset_start2, 1); rb_define_method(rb_cPGconn, "reset_poll", pgconn_reset_poll, 0); rb_define_alias(rb_cPGconn, "close", "finish"); + rb_define_private_method(rb_cPGconn, "pgconn_address", pgconn_pgconn_address, 0); /****** PG::Connection INSTANCE METHODS: Connection Status ******/ rb_define_method(rb_cPGconn, "db", pgconn_db, 0); diff --git a/spec/pg/connection_spec.rb b/spec/pg/connection_spec.rb index 5acac81f5..6be3656d3 100644 --- a/spec/pg/connection_spec.rb +++ b/spec/pg/connection_spec.rb @@ -3070,7 +3070,7 @@ def start_fake_oauth(port) verification_uri, user_code, verification_uri_complete, expires_in = nil, nil, nil, nil - PG.set_auth_data_hook do |data| + PG.set_auth_data_hook do |conn_num, data| case data when PG::PromptOAuthDevice verification_uri = data.verification_uri @@ -3103,7 +3103,7 @@ def start_fake_oauth(port) it "should call oauth bearer reuqest hook" do openid_configuration, scope = nil, nil - PG.set_auth_data_hook do |data| + PG.set_auth_data_hook do |conn_num, data| case data when PG::OAuthBearerRequest openid_configuration = data.openid_configuration @@ -3124,7 +3124,7 @@ def start_fake_oauth(port) it "should reset the hook when called without block" do oauth_server = start_fake_oauth(@port + 3) - PG.set_auth_data_hook do |data| + PG.set_auth_data_hook do |conn_num, data| raise "broken hook" end