PostgreSQL 18의 OAuth2 인증 미리보기 (2) – Rust로 커스텀 OAuth2 검증기 만들기

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

**PostgreSQL 18 (RC1은 2025년 9월 5일 공개됨)**은 중요한 새로운 기능인 OAuth2 인증을 도입했습니다. 이를 통해 PostgreSQL은 기존의 사용자 이름/비밀번호 쌍 대신 OAuth2 토큰을 사용하여 사용자를 인증할 수 있습니다. 이 가이드에서는 Rust와 pgrx 프레임워크를 사용하여 커스텀 검증기 모듈을 만드는 과정을 단계별로 살펴봅니다.

이 블로그 시리즈는 총 3편으로 구성됩니다:


PostgreSQL 18 OAuth2 인증 이해하기

PostgreSQL 18의 OAuth2 지원은 플러그인 아키텍처를 사용합니다. 여기서 커스텀 검증기 모듈을 로드하여 OAuth2 토큰을 검증할 수 있습니다. 검증기 모듈은 PostgreSQL이 인증 중 호출하는 특정 C ABI(Application Binary Interface)를 반드시 구현해야 합니다.

핵심 구성 요소는 다음과 같습니다:

  • 검증기 모듈: OAuth2 검증 로직을 구현한 공유 라이브러리
  • OAuth2 클라이언트: IdP에서 OAuth2 토큰을 획득하는 애플리케이션
  • IdP(Identity Provider): 토큰을 발급하는 OAuth2 서버 (예: Keycloak, Auth0, Azure Entra ID)

PostgreSQL 18 소스 빌드하기

준비물 (Prerequisites)

먼저 macOS에 필요한 빌드 도구를 설치합니다:

brew install gcc icu4c readline zlib curl ossp-uuid pkg-config

환경 설정 (Environment Setup)

필요한 환경 변수를 설정합니다:

export OPENSSL_PATH=$(brew --prefix openssl)
export CMAKE_PREFIX_PATH=$(brew --prefix icu4c)
export PATH="$OPENSSL_PATH/bin:$CMAKE_PREFIX_PATH/bin:$PATH"
export LDFLAGS="-L$OPENSSL_PATH/lib $LDFLAGS"
export CPPFLAGS="-I$OPENSSL_PATH/include $CPPFLAGS"
export PKG_CONFIG_PATH="$CMAKE_PREFIX_PATH/lib/pkgconfig"

다운로드 및 빌드 (Download and Build)

  1. PostgreSQL 18 rc1을 로컬 디렉토리(예: ${HOME}/pg)에 다운로드:
mkdir -p ${HOME}/pg
cd ${HOME}/pg

# PostgreSQL rc1 다운로드
wget https://ftp.postgresql.org/pub/source/v18rc1/postgresql-18rc1.tar.gz
tar -xzf postgresql-18rc1.tar.gz
cd postgresql-18rc1
  1. 구성 및 빌드:
export PG_INST_DIR_PREFIX=${HOME}/pg/pgsql18
./configure --prefix=${PG_INST_DIR_PREFIX} --with-openssl --with-libcurl --without-icu
make -j$(nproc)
make install
  1. PostgreSQL을 PATH에 추가:
export PATH=${PG_INST_DIR_PREFIX}/bin:$PATH
# 방금 빌드한 pg_config 사용 확인
pg_config --version
# "PostgreSQL 18rc1" 출력 확인

pgrx 프로젝트 설정

pgrx는 PostgreSQL 확장을 빌드하기 위한 Rust 프레임워크로, PostgreSQL의 C API에 안전하게 접근할 수 있는 인터페이스를 제공합니다.

pgrx 설치

cargo install --locked cargo-pgrx
cargo pgrx init --pg18 $(which pg_config)

새 프로젝트 생성

cd $HOME/pg
cargo pgrx new my_validator
cd my_validator

Cargo.toml 설정

[package]
publish = false
name = "my_validator"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]
name = "my_validator"

[profile.dev]
panic = "abort"

[features]
default = ["pg18"]
pg18 = []

[dependencies]
base64 = "0.22.1"
cc = "1.2.32"
chrono = "0.4"
jsonwebtoken = "9.3.1"
lazy_static = "1.5.0"
reqwest = { version = "0.12.22", features = ["blocking", "json"] }
serde = "1.0.219"
serde_json = "1.0.142"
pgrx = { version = "0.16.0", features = ["pg18"]}
parking_lot = "0.12.4"

[build-dependencies]
bindgen = "0.72.0"
cc = "1.2.32"

여기서는 Rust의 PostgreSQL FFI 바인딩을 생성하기 위해 bindgen을 사용합니다. 검증기 모듈은 PostgreSQL 18을 대상으로 하며(pg18 기능).


커스텀 검증기 모듈 구현

이 튜토리얼에서는 낮 시간 동안 모든 인증 요청을 허용하는 간단한(그리고 다소 엉뚱한) 검증기를 만듭니다. 이는 교육용 예제이며, 실제 운영 환경에서는 절대 사용해서는 안 됩니다.

1단계: 헤더 파일 생성

include/pg_oauth_shim.h를 생성해 OAuth2 검증기 ABI를 정의합니다:

#include "postgres.h"         // Postgres 헤더
#include "libpq/oauth.h"      // Postgres 18 검증기 ABI
#include "utils/palloc.h"     // pstrdup/palloc 메모리 인터페이스
#include "fmgr.h"             // Postgres 함수 관리자 ABI

2단계: 빌드 스크립트 구현

build.rs 파일을 작성해 C 헤더에서 Rust 바인딩을 생성합니다.

use std::{env, path::PathBuf, process::Command};
fn main() {
// Get Postgres include directory by pg_config
let pg_config = env::var("PG_CONFIG").unwrap_or_else(|_| "pg_config".into());
let includedir = cmd_out(&pg_config, &["--includedir"]).trim().to_string();
let includedir_server = cmd_out(&pg_config, &["--includedir-server"])
.trim()
.to_string();
// Rebuild when shim header changes
println!("cargo:rerun-if-changed=include/pg_oauth_shim.h");
// Generate bindings
let bindings = bindgen::Builder::default()
.header("include/pg_oauth_shim.h")
.clang_args([format!("-I{includedir}"), format!("-I{includedir_server}")])
.allowlist_type("OAuthValidatorCallbacks")
.allowlist_type("ValidatorModuleResult")
.allowlist_var("PG_OAUTH_VALIDATOR_MAGIC")
.allowlist_function("pstrdup")
.generate()
.expect("Unable to generate bindings");
let out = PathBuf::from(env::var("OUT_DIR").unwrap()).join("bindings.rs");
bindings
.write_to_file(out)
.expect("Couldn't write bindings!");
}
fn cmd_out(bin: &str, args: &[&str]) -> String {
let out = Command::new(bin)
.args(args)
.output()
.expect("run pg_config");
String::from_utf8(out.stdout).expect("utf8")
}

3단계: 핵심 구현 (Core Implementation)

모듈은 PostgreSQL이 호출하는 세 가지 C 함수를 반드시 구현해야 합니다:

  1. startup(): 모듈이 로드될 때 한 번 호출됩니다. 일반적으로 전역적으로 공유되는 설정 데이터와 검증 로직에 사용될 캐시(예: JSON Web Keys)를 초기화합니다.
extern "C" fn startup(state: *mut ValidatorModuleState) {
    // 구현 세부사항...
}
  1. shutdown(): 모듈이 언로드될 때 한 번 호출됩니다. 모듈 자원을 정리하고, startup()에서 생성된 전역 공유 데이터를 해제하기 위해 할당된 메모리를 해제해야 합니다.
extern "C" fn shutdown(state: *mut ValidatorModuleState) {
    // 구현 세부사항...
}
  1. validate(): 모든 인증 요청마다 호출되어 OAuth2 토큰을 검증합니다.
extern "C" fn validate(
    state: *const ValidatorModuleState,
    token_ptr: *const ::std::os::raw::c_char,
    role_ptr: *const ::std::os::raw::c_char,
    result: *mut ValidatorModuleResult,
) -> bool {
    // 구현 세부사항...
}

Rust 관점에서 본 전역 모듈 상태 (ValidatorModuleState)

pub struct ValidatorModuleState {
   pub sversion: ::std::os::raw::c_int,
   pub private_data: *mut ::std::os::raw::c_void,
}

이 데이터는 shutdown()에서 해제된 후 반드시 null로 설정해야 합니다.

private_data 필드는 void * 포인터로, startup() 중에 할당된 검증기별 데이터에 대한 포인터를 가리킵니다.

모듈 상태의 라이프사이클 (Module State’s Lifecycle)

예제 검증기는 환경 변수 PGOAUTH_ALLOW_ALL_AT_DAYTIME에서 불리언 값을 읽어오는 전역 설정 구조체를 정의합니다.

struct Config {
   // 낮 시간대 모든 로그인을 허용하는 재미있는 설정
   allow_all_at_daytime: bool,
}

impl Config {
   fn new() -> Result<Self, String> {
       let allow_all_at_daytime = std::env::var("PGOAUTH_ALLOW_ALL_AT_DAYTIME")
           .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
           .unwrap_or(false);
       Ok(Config {
           allow_all_at_daytime: allow_all_at_daytime,
       })
   }
}

따라서 startup() 함수는 이 Config를 생성하고, 모듈 상태의 private_data에 저장합니다.

extern "C" fn startup(state: *mut ValidatorModuleState) {
   pgrx::info!("my validator startup");
   if state.is_null() {
       return;
   }
   match Config::new() {
       Ok(module_state) => unsafe {
           let boxed = Box::new(module_state);
           (*state).private_data = Box::into_raw(boxed) as *mut c_void;
       },
       Err(e) => {
           // 초기화 실패 시 로그만 남기고, PG가 복구 모드로 들어가지 않도록 FATAL은 피함 :sob:
           pgrx::warning!("my validator startup: {}", e);
           // private_data는 null로 남으며, 다른 함수에서 정상적으로 처리됨
       }
   }
}

여기서 중요한 점은, C에서 전달된 state: *mut ValidatorModuleState와 같은 raw pointer에 접근할 때는 반드시 unsafe 블록을 사용해야 한다는 것입니다.

  • module_state는 힙에 Box::new()로 할당해야 하며,
  • Box::into_raw()를 통해 얻은 raw pointer를 (*state).private_data에 저장하여 Rust가 메모리를 조기 해제하지 않도록 해야 합니다.

정리 작업은 shutdown() 콜백에서 수행됩니다.

  • Box::from_raw()를 사용해 boxed 메모리를 복구하면 Rust가 모듈 언로드 시 메모리를 자동으로 해제하여 누수를 방지할 수 있습니다.
  • (*state).private_data에 접근할 때도 동일하게 unsafe {} 패턴을 사용해야 합니다.
extern "C" fn shutdown(state: *mut ValidatorModuleState) {
   pgrx::info!("my validator shutdown");
   unsafe {
       if !(*state).private_data.is_null() {
           let _boxed: Box<Config> = Box::from_raw((*state).private_data as *mut Config);
           (*state).private_data = ptr::null_mut();
       }
   }
}

Validation

validate() 콜백은 3가지 작업을 구현해야 합니다:

  1. unsafe C 영역에서 매개변수 추출
  2. 주요 검증 로직 실행
  3. 결과를 설정하고 반환

요약하면 다음과 같습니다:

extern "C" fn validate(
   state: *const ValidatorModuleState,
   token_ptr: *const ::std::os::raw::c_char, // 이 예제에서는 토큰을 사용하지 않음
   role_ptr: *const ::std::os::raw::c_char,
   result: *mut ValidatorModuleResult,
) -> bool {
   // task-1: 인증별 매개변수를 Rust로 추출
   let (config, token, role) = match unsafe { get_args(state, token_ptr, role_ptr, result) } {
       Ok(args) => args,
       Err(_) => return false,
   };
   // task-2: 안전한 Rust에서 검증 로직 실행
   let (authorized, authn_id) = safe_validate(config, &token, &role);
   // task-3: 결과를 C 구조체에 다시 기록
   unsafe { set_result(result, authorized, authn_id) };
   // 내부 오류는 false를 반환해야 함. 단순화를 위해 여기서는 true 반환
   true
}

Task-1Task-3는 C ↔ Rust 경계를 안전하게 다루는 것에 집중하여, safe_validate()가 실제 검증 로직을 안심하고 구현할 수 있도록 합니다.


Task-1: C에서 매개변수 추출

unsafe fn get_args(
   state: *const ValidatorModuleState,
   token_ptr: *const ::std::os::raw::c_char,
   role_ptr: *const ::std::os::raw::c_char,
   result: *mut ValidatorModuleResult,
) -> Result<(&'static Config, String, String), ()> {
   // 모든 입력 매개변수가 null이 아닌지 확인
   if state.is_null() || result.is_null() || role_ptr.is_null() || token_ptr.is_null() {
       pgrx::warning!("my validator: null state or result pointer");
       return Err(());
   }
   // private_data가 초기화되었는지 확인
   if (*state).private_data.is_null() {
       pgrx::warning!("my validator: null private_data");
       return Err(());
   }
   // 결과 구조체 초기화
   (*result).authorized = false;
   (*result).authn_id = ptr::null_mut();
   // 설정값 추출
   let config = &*((*state).private_data as *const Config);
   
   // ... ...
   // ... ...

   // role 매개변수를 안전하게 추출
   let role_cstr = CStr::from_ptr(role_ptr);
   let role = role_cstr
       .to_str()
       .map_err(|_| {
           pgrx::warning!("my validator: invalid UTF-8 in role parameter");
       })?
       .to_string();
   Ok((config, token, role))
}
  • null 포인터 체크는 항상 좋은 습관입니다.
  • 기본값으로 unauthorized로 초기화하는 것은 안전한 선택입니다.
  • C 문자열을 Rust 문자열로 변환할 때는 CStr::from_ptr()를 사용하며, UTF-8 오류 처리에 주의해야 합니다.

Task-2: Rust에서 검증

fn safe_validate(config: &Config, _token: &str, role: &str) -> (bool, Option<String>) {
   // 낮 시간 동안만 모든 로그인을 허용 (config가 활성화된 경우)
   let authorized = if config.allow_all_at_daytime {
       let hour = chrono::Local::now().hour();
       hour < 19 || hour > 6
   } else {
       pgrx::warning!("my validator: declining login because daytime allowance is disabled");
       false
   };
   // 인증 결과와 {role}_at_day 형태의 authn_id 반환
   (authorized, Some(format!("{}_at_day", role)))
}

이 예제는 현재 시간을 확인해, 낮 시간 동안에만 사용자를 허용합니다.
이 로직은 실제 보안 관점에서는 무의미하며, 데모용일 뿐입니다.
함수는 role_name_at_day 형식의 인증 ID를 반환하고, PostgreSQL은 이를 pg_hba.conf에서 설정한 대로 pg_ident.conf를 통해 데이터베이스 롤에 매핑합니다.


Task-3: 결과를 C에 설정

unsafe fn set_result(
   result: *mut ValidatorModuleResult,
   authorized: bool,
   authn_id: Option<String>,
) {
   (*result).authorized = authorized;
   if let Some(authentication_id) = authn_id {
       match CString::new(authentication_id) {
           Ok(c_string) => {
               (*result).authn_id = pstrdup(c_string.as_ptr());
           }
           Err(_) => {
               pgrx::warning!(
                   "my validator: authentication ID contains null bytes, setting to null"
               );
               (*result).authn_id = ptr::null_mut();
           }
       }
   } else {
       (*result).authn_id = ptr::null_mut();
   }
}

pg_sys::pstrdup() 호출은 PostgreSQL의 allocator(palloc)를 사용해 메모리를 할당합니다. 이렇게 하면 문자열이 올바른 메모리 컨텍스트에 존재하며, 쿼리 종료 시 적절히 해제됩니다.


콜백 등록

#[unsafe(no_mangle)]
pub extern "C" fn _PG_oauth_validator_module_init() -> *mut OAuthValidatorCallbacks {
   let callbacks = Box::new(OAuthValidatorCallbacks {
       magic: PG_OAUTH_VALIDATOR_MAGIC,
       startup_cb: Some(startup),
       shutdown_cb: Some(shutdown),
       validate_cb: Some(validate),
   });
   Box::into_raw(callbacks)
}

모듈 빌드 및 설치

모듈 빌드

PG_CONFIG=$(which pg_config) cargo build --release

모듈 설치

cp target/release/libmy_validator.dylib $(pg_config --pkglibdir)/my_validator.dylib

⚠️ 참고: macOS에서는 동적 라이브러리 접미사가 .dylib입니다.


PostgreSQL OAuth2 설정

새로운 PostgreSQL 데이터베이스 클러스터 생성

initdb -D my_data

1. pg_hba.conf 설정

my_data/pg_hba.conf에 OAuth 인증 방법을 추가합니다:

# OAuth2 authentication
host    all             all             0.0.0.0/0               oauth map=my_map issuer=http://your-oauth-server/auth scope="openid profile"

테스트 목적으로만, 이 예제에서는 로컬 네트워크 로그인을 비활성화합니다.

# IPv4 local connections:
#host    all             all             127.0.0.1/32            trust

# IPv6 local connections:
#host    all             all             ::1/128                 trust

⚠️ 참고: 인증 방법은 oauth이며(oauth2가 아님), issuerscope 인자가 반드시 필요합니다.


2. pg_ident.conf 설정

OAuth2 ID를 PostgreSQL 롤에 매핑하기 위해 my_data/pg_ident.conf에 다음과 같이 추가합니다:

# MAPNAME       SYSTEM-USERNAME         DATABASE-USERNAME
my_map          /^(.+)_at_day$          \1

이 간단한 예제는 OAuth 인증 ID (captured by (.+))를 동일한 PostgreSQL 롤 이름 (\1)으로 매핑합니다.
예를 들어, 검증기가 authn_idjoe_at_day로 반환한다면(safe_validate()에서 <role name>_at_day 형태로 반환), PostgreSQL 롤 joe로 매핑됩니다.


3. 환경 변수 설정

검증기 모듈을 구성하기 위해 환경 변수를 설정합니다:

export PGOAUTH_ALLOW_ALL_AT_DAYTIME="true"

4. 모듈 로드

postgresql.conf에 다음 내용을 추가합니다:

oauth_validator_libraries = 'my_validator'

psql로 테스트하기 (Testing with psql)

1. PostgreSQL 시작

pg_ctl -D my_data -l logfile start

2. 테스트용 롤과 데이터베이스 생성

방금 만든 인스턴스에 psql postgres로 접속한 후, 사용자와 데이터베이스를 생성합니다.

CREATE ROLE joe WITH LOGIN;
CREATE DATABASE my_db OWNER joe;

3. OAuth2 연결 테스트

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()"

⚠️ 참고: PGOAUTHDEBUG=UNSAFE는 로컬 IdP 테스트 시 HTTP(즉, TLS 미사용)를 허용하며 PostgreSQL OAuth2 인증 흐름의 상세 추적을 활성화합니다.


예상되는 동작

다음과 같은 흐름을 볼 수 있습니다:

  1. IdP 인증 URL과 디바이스 코드가 표시됨 Visit http://your-oauth-server/auth/device and enter the code: RJJZ-PRDC
  2. 해당 URL을 방문 후 표시된 코드를 입력합니다.
  3. OAuth2 인증 플로우를 완료합니다.

최종 출력 예시

 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.3.19.1), 64-bit
(1 row)

4. 낮 시간대 테스트

이 예제 검증기는 낮 시간대 인증을 허용하도록 설정되어 있으므로, 동일한 로그인 테스트를 낮 시간에 하거나, 동작을 끄고 싶다면 아래와 같이 설정합니다:

export PGOAUTH_ALLOW_ALL_AT_DAYTIME="false"
pg_ctl -D my_data -l logfile restart

그 후 위에서 사용한 psql 명령어를 다시 실행하면:

psql: error: connection to server at "localhost" (127.0.0.1), port 5432 failed: retrying connection with new bearer token
connection to server at "localhost" (127.0.0.1), port 5432 failed: FATAL:  OAuth bearer authentication failed for user "joe"

PostgreSQL 로그에서 확인할 수 있는 내용

[73895] WARNING:  my validator: decline the login during day time or your system administrator never wants you to login
[73895] LOG:  OAuth bearer authentication failed for user "joe"
[73895] DETAIL:  Validator failed to authorize the provided token.
[73895] FATAL:  OAuth bearer authentication failed for user "joe"

결론 (Conclusion)

Rust로 PostgreSQL 18을 위한 커스텀 OAuth2 검증기를 구현하는 것은 PostgreSQL 플러그인 아키텍처의 유연성을 잘 보여줍니다. 핵심 요약:

  • 안전성 우선: unsafe 블록은 C–Rust 경계에서만 사용하고, 반드시 견고한 오류 처리를 구현할 것
  • 모듈화 설계: C ABI 구현과 비즈니스 로직을 명확히 분리
  • 메모리 관리: C에 반환되는 데이터는 PostgreSQL의 메모리 할당자(palloc)를 사용
  • 에러 처리: 포괄적인 에러 처리와 로깅을 반드시 구현
  • 테스트: 실제 OAuth2 토큰을 다양한 시나리오에서 철저히 테스트

추가 참고 자료 (Further Readings)

메일: salesinquiry@enterprisedb.com