PostgreSQL 18의 OAuth2 인증 미리보기 (3) — PostgreSQL 클라이언트 라이브러리에 OAUTHBEARER 지원 추가

작성자: Guang Yi Xu
2025년 9월 19일

소개

PostgreSQL 18은 중요한 새 기능인 OAuth2 인증을 도입합니다. 이를 통해 PostgreSQL은 전통적인 사용자명/비밀번호 쌍 대신 OAuth2 토큰을 사용해 사용자를 인증할 수 있습니다. 이 가이드에서는 널리 쓰이는 pgx Go 드라이버를 참조 구현으로 삼아, PostgreSQL 클라이언트 라이브러리에 OAuth Bearer 토큰 인증을 구현하는 방법을 살펴봅니다.

이 블로그 시리즈는 3개의 글로 구성됩니다:

이 글에서 언급하는 모든 코드는 필자의 pgx 포크에 있습니다: xugy99/pgx


OAUTHBEARER 메커니즘

OAUTHBEARER SASL 메커니즘은 클라이언트가 OAuth 2.0 Bearer 토큰을 사용해 인증할 수 있도록 합니다. PostgreSQL은 기존의 SASL 인증 흐름에 OAUTHBEARER 인증을 통합했습니다. 전체 OAUTHBEARER SASL 흐름은 이 시리즈의 Part-1에서 확인할 수 있습니다.


pgx에서의 pgconn 이해하기

pgx는 ConnectWithOptions() 함수를 제공하여 PostgreSQL 클러스터 URL옵션을 받아 새 연결을 생성합니다. 이를 사용해 쿼리를 실행하는 예시는 다음과 같습니다:

func main() {
   // ... ...
   url := "postgres://joe:joespassword@localhost:5432/my_db"
   conn, err = pgx.ConnectWithOptions(context.Background(), url, opts)
   if err != nil {
       log.Fatalf("Unable to connect to database: %v\n", err)
   }
   // run sql
   rows, err := conn.Query(context.Background(), *sql)
   if err != nil {
       log.Fatalf("Failed to execute SQL: %v\n", err)
   }
   defer rows.Close()
   // collect rows
   // ... ...
}

위 예시의 url에는 전통적인 사용자명과 비밀번호가 포함되어 있으며, 함수 호출은 이후의 쿼리에 사용할 수 있는 pgconn 객체를 반환합니다.

pgx.ConnectWithOptions() 흐름은 URL 파싱, 인증 방식 선택, 연결 수립을 거쳐 반환됩니다. 각 단계를 좀 더 자세히 살펴보겠습니다.

1. URL 파싱

//
// from pgconn/pgconn.go in github.com/xugy99/pgx
//
func ConnectWithOptions(ctx context.Context, connString string, options ParseConfigOptions) (*Conn, error) {
   connConfig, err := ParseConfigWithOptions(connString, options)
   if err != nil {
       return nil, err
   }
   return connect(ctx, connConfig)
}

pgconn/config.goParseConfigWithOptions()는 두 가지 URL 형식을 처리합니다:

  • URL 형식: postgres://user:pass@host:port/db?param=valueparseURLSettings()로 파싱
  • 키워드/값 형식: host=localhost port=5432 user=myuserparseKeywordValueSettings()로 파싱

핵심 파싱 로직:

//
// from pgconn/config.go in github.com/xugy99/pgx
//
if strings.HasPrefix(connString, "postgres://") || strings.HasPrefix(connString, "postgresql://") {
   connStringSettings, err = parseURLSettings(connString)
} else {
   connStringSettings, err = parseKeywordValueSettings(connString)
}

2. 인증 방식 선택

TCP/TLS 연결을 수립한 뒤, connectOne()스타트업 메시지를 보내고 서버의 인증 요청을 기다립니다. pgconn/pgconn.goconnectOne()을 살펴보면:

//
// from pgconn/pgconn.go in github.com/xugy99/pgx
//
func connectOne(ctx context.Context, config *Config, connectConfig *connectOneConfig,
    ignoreNotPreferredErr bool,
) (*PgConn, error) {
   // ... ...
   switch msg := msg.(type) {
   case *pgproto3.AuthenticationOk:
       // No auth needed
   case *pgproto3.AuthenticationCleartextPassword:
       err = pgConn.txPasswordMessage(pgConn.config.Password)
   case *pgproto3.AuthenticationMD5Password:
       digestedPassword := "md5" + hexMD5(hexMD5(pgConn.config.Password+pgConn.config.User)+string(msg.Salt[:]))
       err = pgConn.txPasswordMessage(digestedPassword)
   case *pgproto3.AuthenticationSASL:
       err = pgConn.scramAuth(msg.AuthMechanisms)
       if err != nil {
           pgConn.conn.Close()
           return nil, newPerDialConnectError("failed SASL auth", err)
       }
   case *pgproto3.AuthenticationGSS:
       err = pgConn.gssAuth()  // GSSAPI/Kerberos
   }
   // ... ...
}

서버가 AuthenticationOk를 응답하면 인증이 완료되고 연결은 connStatusIdle 상태로 전환됩니다.

여기서 주목할 부분은 pgproto3.AuthenticationSASL 분기입니다. 현재 pgx에서 SASL 기반으로 구현된 인증 방식은 SCRAM(Salted Challenge Response Authentication Mechanism)뿐입니다. 클라이언트가 AuthenticationSASL 메시지를 받으면 즉시 SCRAM 인증 흐름을 시작합니다.

SCRAM 구현 이해하기

PostgreSQL은 SCRAM-SHA-256SCRAM-SHA-256-PLUS를 지원합니다. 간략한 핸드셰이크 다이어그램은 다음과 같습니다:

PostgreSQL 18 SCRAM in SASL

코드를 다시 보면:

//
// from pgconn/pgconn.go in github.com/xugy99/pgx
//
func connectOne(ctx context.Context, config *Config, connectConfig *connectOneConfig,
    ignoreNotPreferredErr bool,
) (*PgConn, error) {
   // ... ...
   // ... ...
   case *pgproto3.AuthenticationSASL:
       err = pgConn.scramAuth(msg.AuthMechanisms)
       if err != nil {
           pgConn.conn.Close()
           return nil, newPerDialConnectError("failed SASL auth", err)
       }
   // ... ...
   // ... ...
}

msg.AuthMechanisms는 메커니즘(SCRAM-SHA-256)을 지정하며, pgConn.scramAuth()가 SASL 흐름의 다음 4개 메시지에 대한 협상을 처리합니다:

  • SASLInitialResponse
  • AuthenticationSASLContinue
  • SASLResponse
  • AuthenticationSASLFinal

SCRAM 패턴을 복제하여 OAUTHBEARER 추가하기

같은 패턴을 따르며, AuthenticationSASL 메시지에서 OAUTHBEARER를 확인하고 OAuth2 전용 인증 흐름을 처리하는 새 함수를 사용하면 문제없이 동작합니다.

참고로, 클라이언트는 언제든 SASLInitialResponse에서 auth=""을 보내 OIDC 디스커버리를 요청하고 authorize 엔드포인트를 통해 OAuth2 로그인 흐름을 완료할 수 있습니다. 이것이 PostgreSQL 18 OAuth2 설계를 따르는 좀 더 정석적인 방법입니다. 이 글에서는 이 단계를 생략하고 이미 토큰을 확보했다고 가정하여, SASLInitialResponse에 토큰을 직접 실어 보내겠습니다. 우리가 구현할 흐름은 다음과 같습니다:

PostgreSQL 18 and PGX Demo

1) 인증 흐름 구현

핵심 OAuth 인증 로직은 연결 수립 과정에 구현됩니다:

//
// from pgconn/auth_oauth.go in github.com/xugy99/pgx
//
func (c *PgConn) oauthAuth() error {
   // Check if we have a pre-configured bearer token
   if c.config == nil || c.config.OAuthBearerToken == "" {
       return fmt.Errorf("server requires OAuthBearerToken for this connection")
   }
   // Construct a SASLInitialResponse message
   reply := &pgproto3.SASLInitialResponse{
       AuthMechanism: "OAUTHBEARER",
       Data:          buildOAuthInitialResponse(c.config.OAuthBearerToken),
   }
   // Use frontend.Send() to populate the send buffer and flush it to the wire
   c.frontend.Send(reply)
   if err := c.flushWithPotentialWriteReadDeadlock(); err != nil {
       return err
   }
   // Wait for AuthenticationOk message
   _, err := c.rxOAuthSASLOk()
   return err
}

주의할 세부 사항:

  • 토큰 주입: 토큰은 전달된 pgconn 연결의 config에서 가져옵니다. 이후 섹션에서 config 객체에 토큰을 싣는 방법을 봅니다.
  • SASL 통합: 기존 SASL 메시지 구조를 재사용합니다. Data 페이로드는 새 함수 buildOAuthInitialResponse()가 구성합니다(아래에서 설명).
  • 응답 처리: rxOAuthSASLOk()는 PostgreSQL 서버의 응답을 기다리며 AuthenticationOk 메시지를 기대합니다.

2) OAuth 초기 응답 형식

이제 RFC 7628에 부합하는 OAuth 초기 응답을 구성하기 위해 buildOAuthInitialResponse() 함수를 구현합니다.

RFC 7628 섹션 4.1(성공적인 Bearer 토큰 교환)을 보면:

[Initial connection and TLS establishment...]
S: * OK IMAP4rev1 Server Ready
C: t0 CAPABILITY
S: * CAPABILITY IMAP4rev1 AUTH=OAUTHBEARER SASL-IR
S: t0 OK Completed
C: t1 AUTHENTICATE OAUTHBEARER bixhPXVzZXJAZXhhbXBsZS5jb20sAWhv
c3Q9c2VydmVyLmV4YW1wbGUuY29tAXBvcnQ9MTQzAWF1dGg9QmVhcmVyI
HZGOWRmdDRxbVRjMk52YjNSbGNrQmhiSFJoZG1semRHRXVZMjl0Q2c9PQ
EB
S: t1 OK SASL authentication succeeded
As required by IMAP [RFC3501], the payloads are base64 encoded. The
decoded initial client response (with %x01 represented as ^A and long
lines wrapped for readability) is:
n,a=user@example.com,^Ahost=server.example.com^Aport=143^A
auth=Bearer vF9dft4qmTc2Nvb3RlckBhbHRhdmlzdGEuY29tCg==^A^A

그리고 PostgreSQL 18의 libpq 구현(backend/libpq/auth-oauth.c)에서는:

static int
oauth_exchange(void *opaq, const char *input, int inputlen,
              char **output, int *outputlen, const char **logdetail)
{
   // ... ...
   //

   /* All remaining fields are separated by the RFC's kvsep (\x01). */
   if (*p != KVSEP)
       ereport(ERROR,
               errcode(ERRCODE_PROTOCOL_VIOLATION),
               errmsg("malformed OAUTHBEARER message"),
               errdetail("Key-value separator expected, but found character \"%s\".",
                           sanitize_char(*p)));
   p++;
   auth = parse_kvpairs_for_auth(&p);
   if (!auth)
       ereport(ERROR,
               errcode(ERRCODE_PROTOCOL_VIOLATION),
               errmsg("malformed OAUTHBEARER message"),
               errdetail("Message does not contain an auth value."));
   }
   // ... ...
   //
}

RFC 7628 및 PostgreSQL 18 요구사항을 만족하려면 다음을 구현해야 합니다:

  • 형식: n,,^Aauth=Bearer <token>^A^A
  • 권한 부여 ID: n,은 authorization identity가 없음을 의미
  • 키-값 구분: ^A(0x01)로 key-value 쌍을 구분
  • 토큰 형식: auth=Bearer <token>은 OAuth 2.0 Bearer 토큰 형식을 따르며, 원시 토큰을 base64로 인코딩해야 합니다
  • 주의: ^A=0x01, host/port는 RFC 7628에 따라 필수가 아닙니다.

결과적으로 함수는 꽤 단순해집니다:

//
// from pgconn/auth_oauth.go in github.com/xugy99/pgx
//
func buildOAuthInitialResponse(token string) []byte {
   var b bytes.Buffer
   b.WriteString("n,,")           // No authorization identity
   b.WriteByte(0x01)              // Key-value separator
   b.WriteString("auth=Bearer ")  // OAuth Bearer token prefix
   b.WriteString(token)           // Actual token
   b.WriteByte(0x01)              // Key-value separator
   b.WriteByte(0x01)              // Final separator
   return b.Bytes()
}

AuthenticationOk 기다리기

rxOAuthSASLOk() 함수는 PostgreSQL 서버의 다음 응답 메시지를 기다리는 동안 블록합니다.

//
// from pgconn/auth_oauth.go in github.com/xugy99/pgx
//
func (c *PgConn) rxOAuthSASLOk() (*pgproto3.AuthenticationOk, error) {
   // Block until we receive a message from the PostgreSQL server
   msg, err := c.receiveMessage()
   if err != nil {
       return nil, err
   }
   switch m := msg.(type) {
   case *pgproto3.AuthenticationOk:
       return m, nil
   case *pgproto3.ErrorResponse:
       return nil, ErrorResponseToPgError(m)
   // AuthenticationSASLContinue is received in when the token
   // is empty or invalid in SASLInitialResponse phase
   //case *pgproto3.AuthenticationSASLContinue:
   //      return nil, fmt.Errorf(": %s", string(m.Data))
   }
   return nil, fmt.Errorf("expected AuthenticationOk message but received unexpected %T", msg)
}

이 글의 예제에서는 SASLInitialResponse항상 유효한 토큰을 보낸다고 가정하므로 AuthenticationSASLContinue를 보지 않게 됩니다. 다만, 개발 시에는 AuthenticationSASLContinue 처리도 고려해야 합니다. 커스텀 검증기가 전달된 토큰을 검증하지 못한 경우 PostgreSQL은 OIDC 디스커버리 URL을 반환하여, 클라이언트가 지정된 IdP로 재인증하도록 안내하기 때문입니다.

3) 연결 로직 통합

이제 OAUTHBEARER 메커니즘으로 AuthenticationSASL을 처리하는 oauthAuth()가 있으므로, pgconn/pgconn.go의 메인 연결 흐름에 통합할 수 있습니다:

//
// from pgconn/pgconn.go in github.com/xugy99/pgx
//
func connectOne(ctx context.Context, config *Config, connectConfig *connectOneConfig,
    ignoreNotPreferredErr bool,
) (*PgConn, error) {
   // ... ...
   // ... ...
       case *pgproto3.AuthenticationSASL:
           // If the mechanism is OAUTHBEARER
           if slices.Contains(msg.AuthMechanisms, "OAUTHBEARER") {
               err = pgConn.oauthAuth()
               if err != nil {
                   pgConn.conn.Close()
                   return nil, newPerDialConnectError("failed OAUTHBEARER auth", err)
               }
               continue
           }
           // Fall back to SCRAM authentication
           err = pgConn.scramAuth(msg.AuthMechanisms)
           // ... error handling
   // ... ...

   // ... ...
}

4) 구성 확장

이 글의 예제는 pgconn의 config가 이미 발급된 Bearer 토큰을 보유하고 있다고 가정합니다. 이제 pgconn/config.go에서 필요한 마지막 부분을 구현합니다:

//
// from pgconn/config.go in github.com/xugy99/pgx
//
// Config is the settings used to establish a connection to a PostgreSQL server. It must be created by [ParseConfig]. A
// manually initialized Config will cause ConnectConfig to panic.
type Config struct {
   // ... existing fields ...
   // The preconfigured OAuth2 Bearer Token for authentication
   OAuthBearerToken string
}

// ParseConfigOptions extension
type ParseConfigOptions struct {
   // ... existing fields ...
   // The callback function to get the OAuth bear token
   GetOAuthBearerToken GetOAuthBearerTokenFunc
}

type GetOAuthBearerTokenFunc func(ctx context.Context) string

GetOAuthBearerTokenFunc()pgx.ConnectWithOptions()의 옵션으로 OAuth 2.0 토큰을 가져오는 콜백입니다. 아래 테스트 커맨드 프로그램에서는 환경 변수에서 토큰을 가져오는 단순 로직을 구현합니다.


실전 사용 예시

토큰 조회

GetOAuthBearerTokenFunc클로저로 활용해 OAuth 인증을 사용하는 방법:

func main() {
   // ... ...
   // ... ...
   opts.GetOAuthBearerToken = func(ctx context.Context) string {
       if et := os.Getenv("OAUTH_BEARER_TOKEN"); et != "" {
           return et
       }
       log.Println("using default fake token, you should expect to see login failure")
       return "whatever_token"
   }
   conn, err := pgx.ConnectWithOptions(context.Background(), *url, opts)
   // handling error ... ...
   // ... ...
}

전체 예제

향상된 pgx 라이브러리를 사용해 OAuth 인증을 수행하고, 테이블 형태의 아름다운 출력을 보여주는 전체 예제:

//
// from examples/oauthtest/main.go in github.com/xugy99/pgx
//
package main
import (
   "context"
   "flag"
   "fmt"
   "log"
   "os"
   "github.com/jackc/pgx/v5"
   "github.com/pterm/pterm"
)
var (
   sql = flag.String("sql", "select version()", "SQL query to execute")
   url = flag.String("url", "postgres://joe@localhost:5432/my_db", "Database URL")
)
func main() {
   var (
       err  error
       opts pgx.ParseConfigOptions
   )
   flag.Parse()
   if *url == "" {
       log.Fatal("missing url")
   }
   // connect to server
   opts.GetOAuthBearerToken = func(ctx context.Context) string {
       if et := os.Getenv("OAUTH_BEARER_TOKEN"); et != "" {
           return et
       }
       log.Println("using default fake token, you should expect to see login failure")
       return "whatever_token"
   }
   conn, err := pgx.ConnectWithOptions(context.Background(), *url, opts)
   if err != nil {
       log.Fatalf("Unable to connect PostgreSQL by OAUTHBEARER: %v\n", err)
   }
   defer conn.Close(context.Background())
   // run sql
   rows, err := conn.Query(context.Background(), *sql)
   if err != nil {
       log.Fatalf("Failed to execute SQL: %v\n", err)
   }
   defer rows.Close()
   // convert to pterm table and print the result
   data := pterm.TableData{{}}
   //   - header row
   fieldDescriptions := rows.FieldDescriptions()
   for _, fd := range fieldDescriptions {
       data[0] = append(data[0], string(fd.Name))
   }
   //   - data row
   for rows.Next() {
       if rows.Err() == pgx.ErrNoRows {
           break
       }
       var row []string
       columns, _ := rows.Values()
       for _, v := range columns {
           row = append(row, fmt.Sprintf("%v", v))
       }
       data = append(data, row)
   }
   pterm.DefaultTable.
       WithBoxed(true).
       WithHasHeader(true).
       WithHeaderRowSeparator("-").
       WithData(data).
       Render() // show it
}

실행해 보기

토큰 받기

이 시리즈 Part-2에서 설정한 PostgreSQL 18 RC1 서버가 아직 있다면, psql을 사용해 트레이스에서 토큰을 얻을 수 있습니다:

PGOAUTHDEBUG=UNSAFE psql "postgres://joe@localhost:5432/my_db?oauth_issuer=http://your-oauth-server/auth&oauth_client_id=your-client-id" -c "SELECT current_user, session_user, version()"

출력에는 트레이스의 access_token이 포함되어 있으며, 예시는 다음과 같습니다(긴 JSON 토막):

[libcurl] <   "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImQ2YThlYTIxZjllN2QzYmJhNTA0NWNhZDBhN2ExZmY1MGM1ZTRkMzgifQ.eyJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjU1NTYvYXV0aCIsInN1YiI6IkNpUmtOakkzTmpnME9TMDRNR1prTFRSak5Ea3RZak5oTkMwd1pUWTFObUpsTVRVeVpUQVNCV3h2WTJGcyIsImF1ZCI6InlvdXItY2xpZW50LWlkIiwiZXhwIjoxNzU4MzczMzMwLCJpYXQiOjE3NTgyODY5MzAsImF0X2hhc2giOiJDLUsyTUlwaG42VGUxUE5lMzlEXy1RIiwibmFtZSI6IkpvZSBEb2UifQ.LJXaixCj9tLNeY_kLxgPH0kn497dEtoHpLswLBVBUh5z7rE46Ixt72xxeHVxHke32fjjnsE3Zh_rR4pcUspkfl7a_L4ElZZaI2AK6e_4YX5x3GHN4yazxgBS7a5U8cUYt5s6Gizox6199Mm50jg5Ho9AHoldkV2JxE6foaSHrp2EZr6zM4ZWPeW_EBC0GVB4ngjaYsaEjAqNLUVePviqv6H70omM5GF6JyFxSGYTpbpUuji8S3CmTCFKJaDNjgk2R5BFrLzLhJaXyPjdcBpDLcZKrKc4NtVCSptAVOLJto5qr1ICeLGanGgg4q-BA-EtbKSd53Dxac_Uu4lmrPJycg",<0A>
[libcurl] <   "token_type": "bearer",<0A>
[libcurl] <   "expires_in": 86399,<0A>

다음 단계에서 사용할 access_token을 저장합니다:

export OAUTH_BEARER_TOKEN=<access_token>

명령 실행

간단히 다음을 실행합니다:

cd examples/oauthtest
go run main.go -url 'postgres://joe@localhost:5432/my_db' -sql 'select current_user,session_user,version()'

그러면 다음과 같은 출력이 보일 것입니다:

┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐
| current_user | session_user | version                                                                                                            |
| ------------------------------------------------------------------------------------------------------------------------------------------------ |
| joe          | joe          | PostgreSQL 18rc1 on aarch64-apple-darwin24.6.0, compiled by Apple clang version 17.0.0 (clang-1700.0.13.5), 64-bit |
└──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘

독립적인 토큰 조회 및 갱신 메커니즘pgxpoolconfig.BeforeConnect() 콜백에 구현하여, 풀에서 생성되는 새 연결이 항상 최신 토큰으로 인증되도록 할 수 있습니다.


무엇이 남았는가?

AuthenticationSASLContinue 처리

이 글의 pgx 예제 코드는 PostgreSQL 서버가 반환하는 OIDC 디스커버리에 대한 AuthenticationSASLContinue 처리를 구현하지 않았습니다. 클라이언트 측에서 이 메시지에 어떻게 응답할지는 프로그램이 구현하고자 하는 바에 달려 있습니다:

  • 토큰이 만료되었거나 유효하지 않을 때, OAuth2 인가 흐름을 별도 컴포넌트나 시스템에 위임하려면 단순 오류 보고를 할 수 있습니다.
  • 범용 PostgreSQL 클라이언트처럼 동작하길 원한다면, 서버가 지정한 IdP로 인가하는 패턴을 따를 수 있습니다.

PGX 풀링

사전 구성된 토큰은 pgx 연결 풀링에도 통합할 수 있습니다. 링크된 https://github.com/xugy99/pgx 코드에는 이 부분이 포함되어 있지 않습니다. 아래와 같이 구현할 수 있습니다:

config, err := pgxpool.ParseConfig("postgres://joe@localhost:5432/my_db")
if err != nil {
   return err
}
config.ConnConfig.OAuthBearerToken = "my_token"
pool, err := pgxpool.NewWithConfig(context.Background(), config)
if err != nil {
   return err
}
defer pool.Close()

PGX에서의 네이티브 OAuth2 인가 흐름

이 유형의 기능은 이미 많은 Golang 구현이 있습니다. 이를 pgx와 결합해 시스템을 구축하는 것은 충분히 가능하며 특별히 어렵지 않습니다.


결론

이 pgx 구현은 다음을 보여줍니다:

  • 구성 확장을 통해 OAuth 토큰을 지원하는 방법
  • SASL 인증 프레임워크 통합 방법
  • RFC 7628에 따른 올바른 OAuth Bearer 토큰 형식 준수

PostgreSQL 18의 OAuth2 인증 방식은 클라우드 네이티브 환경에서 OAuth 2.0 인증을 활용하는 현대적이고 안전한 PostgreSQL 클라이언트 애플리케이션을 위한 탄탄한 기반을 제공합니다.


추가로 읽을거리

메일: salesinquiry@enterprisedb.com