Skip to content

Commit 2c62512

Browse files
authored
Merge pull request jackc#2400 from dvob/oauth-pg18
Add support for OAuth
2 parents 9b5e030 + 7b301de commit 2c62512

11 files changed

Lines changed: 197 additions & 2 deletions

File tree

.github/workflows/ci.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ jobs:
6464
pgx-test-md5-password-conn-string: "host=127.0.0.1 user=pgx_md5 password=secret dbname=pgx_test"
6565
pgx-test-plain-password-conn-string: "host=127.0.0.1 user=pgx_pw password=secret dbname=pgx_test"
6666
pgx-test-tls-conn-string: "host=localhost user=pgx_ssl password=secret sslmode=verify-full sslrootcert=/tmp/ca.pem dbname=pgx_test"
67+
pgx-test-oauth: "true"
6768
pgx-ssl-password: certpw
6869
pgx-test-tls-client-conn-string: "host=localhost user=pgx_sslcert sslmode=verify-full sslrootcert=/tmp/ca.pem sslcert=/tmp/pgx_sslcert.crt sslkey=/tmp/pgx_sslcert.key dbname=pgx_test"
6970
- pg-version: cockroachdb
@@ -115,6 +116,7 @@ jobs:
115116
PGX_TEST_SCRAM_PASSWORD_CONN_STRING: ${{ matrix.pgx-test-scram-password-conn-string }}
116117
PGX_TEST_MD5_PASSWORD_CONN_STRING: ${{ matrix.pgx-test-md5-password-conn-string }}
117118
PGX_TEST_PLAIN_PASSWORD_CONN_STRING: ${{ matrix.pgx-test-plain-password-conn-string }}
119+
PGX_TEST_OAUTH: ${{ matrix.pgx-test-oauth }}
118120
# TestConnectTLS fails. However, it succeeds if I connect to the CI server with upterm and run it. Give up on that test for now.
119121
# PGX_TEST_TLS_CONN_STRING: ${{ matrix.pgx-test-tls-conn-string }}
120122
PGX_SSL_PASSWORD: ${{ matrix.pgx-ssl-password }}

ci/setup_test.bash

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,17 @@ then
1414
sudo sh -c "echo \"listen_addresses = '127.0.0.1'\" >> /etc/postgresql/$PGVERSION/main/postgresql.conf"
1515
sudo sh -c "cat testsetup/postgresql_ssl.conf >> /etc/postgresql/$PGVERSION/main/postgresql.conf"
1616

17+
if [ "$PGVERSION" -ge 18 ]; then
18+
# Configure and Install OAuth validator for PostgreSQL 18+
19+
sudo sh -c "cat testsetup/oauth_validator_module/postgresql.conf >> /etc/postgresql/$PGVERSION/main/postgresql.conf"
20+
sudo sh -c "cat testsetup/oauth_validator_module/pg_hba.conf >> /etc/postgresql/$PGVERSION/main/pg_hba.conf"
21+
(
22+
cd testsetup/oauth_validator_module
23+
sudo apt-get install -y gcc make libkrb5-dev
24+
make && sudo make install
25+
)
26+
fi
27+
1728
cd testsetup
1829

1930
# Generate CA, server, and encrypted client certificates.

pgconn/auth_oauth.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package pgconn
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
9+
"github.com/jackc/pgx/v5/pgproto3"
10+
)
11+
12+
func (c *PgConn) oauthAuth(ctx context.Context) error {
13+
if c.config.OAuthTokenProvider == nil {
14+
return errors.New("OAuth authentication required but no token provider configured")
15+
}
16+
17+
token, err := c.config.OAuthTokenProvider(ctx)
18+
if err != nil {
19+
return fmt.Errorf("failed to obtain OAuth token: %w", err)
20+
}
21+
22+
// https://www.rfc-editor.org/rfc/rfc7628.html#section-3.1
23+
initialResponse := []byte("n,,\x01auth=Bearer " + token + "\x01\x01")
24+
25+
saslInitialResponse := &pgproto3.SASLInitialResponse{
26+
AuthMechanism: "OAUTHBEARER",
27+
Data: initialResponse,
28+
}
29+
c.frontend.Send(saslInitialResponse)
30+
err = c.flushWithPotentialWriteReadDeadlock()
31+
if err != nil {
32+
return err
33+
}
34+
35+
msg, err := c.receiveMessage()
36+
if err != nil {
37+
return err
38+
}
39+
40+
switch m := msg.(type) {
41+
case *pgproto3.AuthenticationOk:
42+
return nil
43+
case *pgproto3.AuthenticationSASLContinue:
44+
// Server sent error response in SASL continue
45+
// https://www.rfc-editor.org/rfc/rfc7628.html#section-3.2.2
46+
// https://www.rfc-editor.org/rfc/rfc7628.html#section-3.2.3
47+
errResponse := struct {
48+
Status string `json:"status"`
49+
Scope string `json:"scope"`
50+
OpenIDConfiguration string `json:"openid-configuration"`
51+
}{}
52+
err := json.Unmarshal(m.Data, &errResponse)
53+
if err != nil {
54+
return fmt.Errorf("invalid OAuth error response from server: %w", err)
55+
}
56+
57+
// Per RFC 7628 section 3.2.3, we should send a SASLResponse which only contains \x01.
58+
// However, since the connection will be closed anyway, we can skip this
59+
return fmt.Errorf("OAuth authentication failed: %s", errResponse.Status)
60+
61+
case *pgproto3.ErrorResponse:
62+
return ErrorResponseToPgError(m)
63+
64+
default:
65+
return fmt.Errorf("unexpected message type during OAuth auth: %T", msg)
66+
}
67+
}

pgconn/config.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,10 @@ type Config struct {
8383
// that you close on FATAL errors by returning false.
8484
OnPgError PgErrorHandler
8585

86+
// OAuthTokenProvider is a function that returns an OAuth token for authentication. If set, it will be used for
87+
// OAUTHBEARER SASL authentication when the server requests it.
88+
OAuthTokenProvider func(context.Context) (string, error)
89+
8690
// MinProtocolVersion is the minimum acceptable PostgreSQL protocol version.
8791
// If the server does not support at least this version, the connection will fail.
8892
// Valid values: "3.0", "3.2", "latest". Defaults to "3.0".

pgconn/pgconn.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -428,7 +428,20 @@ func connectOne(ctx context.Context, config *Config, connectConfig *connectOneCo
428428
return nil, newPerDialConnectError("failed to write password message", err)
429429
}
430430
case *pgproto3.AuthenticationSASL:
431-
err = pgConn.scramAuth(msg.AuthMechanisms)
431+
// Check if OAUTHBEARER is supported
432+
serverSupportsOAuthBearer := false
433+
for _, mech := range msg.AuthMechanisms {
434+
if mech == "OAUTHBEARER" {
435+
serverSupportsOAuthBearer = true
436+
break
437+
}
438+
}
439+
440+
if serverSupportsOAuthBearer && pgConn.config.OAuthTokenProvider != nil {
441+
err = pgConn.oauthAuth(ctx)
442+
} else {
443+
err = pgConn.scramAuth(msg.AuthMechanisms)
444+
}
432445
if err != nil {
433446
pgConn.conn.Close()
434447
return nil, newPerDialConnectError("failed SASL auth", err)

pgconn/pgconn_test.go

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,11 @@ import (
3030
"github.com/jackc/pgx/v5/pgtype"
3131
)
3232

33-
const pgbouncerConnStringEnvVar = "PGX_TEST_PGBOUNCER_CONN_STRING"
33+
const (
34+
pgbouncerConnStringEnvVar = "PGX_TEST_PGBOUNCER_CONN_STRING"
35+
// runOAuthTestEnvVar has to be set to "true" to run OAuth tests
36+
runOAuthTestEnvVar = "PGX_TEST_OAUTH"
37+
)
3438

3539
func TestConnect(t *testing.T) {
3640
tests := []struct {
@@ -118,6 +122,54 @@ func TestConnectTLS(t *testing.T) {
118122
closeConn(t, conn)
119123
}
120124

125+
// TestConnectOAuth is separate from other connect tests because it specifically
126+
// needs a configured OAuthTokenProvider. Further it's only available in Postgres
127+
// 18+ and requires the dummy OAuth validator module installed.
128+
func TestConnectOAuth(t *testing.T) {
129+
if os.Getenv(runOAuthTestEnvVar) != "true" {
130+
t.Skipf("Skipping as '%s=true' is not set", runOAuthTestEnvVar)
131+
}
132+
133+
config, err := pgconn.ParseConfig("host=127.0.0.1 user=pgx_oauth dbname=pgx_test")
134+
require.NoError(t, err)
135+
136+
// Configure OAuthTokenProvider for dummy validator.
137+
// The dummy validator accepts any token and maps it to the user equal to the
138+
// token string.
139+
config.OAuthTokenProvider = func(ctx context.Context) (string, error) {
140+
return "pgx_oauth", nil
141+
}
142+
143+
conn, err := pgconn.ConnectConfig(context.Background(), config)
144+
require.NoError(t, err)
145+
defer closeConn(t, conn)
146+
147+
result := conn.ExecParams(context.Background(), "SELECT CURRENT_USER", nil, nil, nil, nil).Read()
148+
require.NoError(t, result.Err)
149+
require.Len(t, result.Rows, 1)
150+
require.Len(t, result.Rows[0], 1)
151+
require.Equalf(t, "pgx_oauth", string(result.Rows[0][0]), "not logged in as expected user.")
152+
}
153+
154+
func TestConnectOAuthError(t *testing.T) {
155+
if os.Getenv(runOAuthTestEnvVar) != "true" {
156+
t.Skipf("Skipping as '%s=true' is not set", runOAuthTestEnvVar)
157+
}
158+
159+
config, err := pgconn.ParseConfig("host=127.0.0.1 user=pgx_oauth dbname=pgx_test")
160+
require.NoError(t, err)
161+
162+
// Configure OAuthTokenProvider for dummy validator.
163+
// The dummy validator accepts any token and maps it to the user equal to the
164+
// token string. In this case that token will be accepted but as there is no
165+
// user 'INVALID_TOKEN' the connection should fail.
166+
config.OAuthTokenProvider = func(ctx context.Context) (string, error) {
167+
return "INVALID_TOKEN", nil
168+
}
169+
170+
_, err = pgconn.ConnectConfig(context.Background(), config)
171+
require.Error(t, err, "connect should return error for invalid token")
172+
}
121173
func TestConnectTLSPasswordProtectedClientCertWithSSLPassword(t *testing.T) {
122174
t.Parallel()
123175

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
.PHONY = install clean
2+
3+
PG_CONFIG = pg_config
4+
PKGLIBDIR = $(shell $(PG_CONFIG) --pkglibdir)
5+
CPPFLAGS += -I$(shell $(PG_CONFIG) --includedir-server)
6+
CFLAGS += -fPIC
7+
8+
dummy_validator.so: dummy_validator.o
9+
$(CC) -shared $(CFLAGS) $(LDFLAGS) -o $@ $^
10+
11+
dummy_validator.o: dummy_validator.c
12+
13+
install:
14+
install -D -m 755 dummy_validator.so $(PKGLIBDIR)/
15+
16+
clean:
17+
rm -f dummy_validator.o dummy_validator.so
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
#include "postgres.h"
2+
#include "fmgr.h"
3+
#include "libpq/oauth.h"
4+
5+
PG_MODULE_MAGIC;
6+
7+
bool validate(const ValidatorModuleState *state, const char *token,
8+
const char *role, ValidatorModuleResult *result) {
9+
10+
elog(LOG, "accept token '%s' for role '%s'", token, role);
11+
char *authn_id = pstrdup(token);
12+
result->authn_id = authn_id;
13+
result->authorized = true;
14+
return true;
15+
}
16+
17+
const OAuthValidatorCallbacks callbacks = {
18+
.magic = PG_OAUTH_VALIDATOR_MAGIC,
19+
.startup_cb = NULL,
20+
.shutdown_cb = NULL,
21+
.validate_cb = validate,
22+
};
23+
24+
const OAuthValidatorCallbacks *_PG_oauth_validator_module_init() {
25+
return &callbacks;
26+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
host all pgx_oauth 127.0.0.1/32 oauth validator=dummy_validator issuer=https://example.com scope=
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
oauth_validator_libraries = 'dummy_validator'

0 commit comments

Comments
 (0)