PostgreSQL 18 OAuth2 커스텀 검증기 구현 방법 (2)
작성자: Guang Yi Xu
2025년 9월 17일
PostgreSQL 18 (RC1은 2025년 9월 5일 공개)은 새로운 기능인 OAuth2 인증을 도입했습니다. 이를 통해 PostgreSQL은 기존의 사용자 이름과 비밀번호 조합 대신 OAuth2 토큰을 사용해 사용자를 인증할 수 있습니다. 이 가이드에서는 Rust와 pgrx 프레임워크를 활용해 커스텀 검증기 모듈을 구현하고 PostgreSQL 18에 적용하는 과정을 단계별로 살펴봅니다.
이 시리즈는 총 3편으로 구성됩니다.
- Part-1: PostgreSQL 18 OAuth2 인증 작동 원리와 설정 방법
- Part-2 (이 글): PostgreSQL 18 OAuth2 커스텀 검증기 구현 방법
- Part-3: PostgreSQL proto3 클라이언트 라이브러리에 OAUTHBEARER 지원 추가
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)
커스텀 OAuth2 검증기를 위한 PostgreSQL 18 빌드
PostgreSQL 18 빌드에 필요한 도구
먼저 macOS에 필요한 빌드 도구를 설치합니다.
brew install gcc icu4c readline zlib curl ossp-uuid pkg-config
PostgreSQL 빌드 환경 설정
빌드에 필요한 환경 변수를 설정합니다.
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"
PostgreSQL 18 RC1 다운로드 및 빌드
- 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
- PostgreSQL을 구성하고 빌드합니다.
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
- 빌드한 PostgreSQL을 PATH에 추가하고 버전을 확인합니다.
export PATH=${PG_INST_DIR_PREFIX}/bin:$PATH
# 방금 빌드한 pg_config 사용 확인
pg_config --version
# "PostgreSQL 18rc1" 출력 확인
Rust 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"
여기서는 bindgen을 사용해 PostgreSQL FFI를 위한 Rust 바인딩을 생성합니다. 검증기 모듈은 pg18 기능을 사용해 PostgreSQL 18을 대상으로 구성합니다.
Rust로 커스텀 OAuth2 검증기 구현하기
이 튜토리얼에서는 낮 시간대에 모든 인증 요청을 허용하는 단순한 예제 검증기를 구현합니다. 동작 방식을 설명하기 위한 교육용 예제로, 실제 운영 환경에서는 사용해서는 안 됩니다.
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_configlet 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 changesprintln!("cargo:rerun-if-changed=include/pg_oauth_shim.h");// Generate bindingslet 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단계: startup·shutdown·validate 콜백 구현
검증기 모듈은 PostgreSQL이 호출하는 세 가지 C 함수를 구현해야 합니다.
- startup(): 모듈이 로드될 때 한 번 호출됩니다. 일반적으로 전역으로 공유되는 설정 데이터와 검증 로직에서 사용할 캐시(예: JSON Web Keys)를 초기화합니다.
extern "C" fn startup(state: *mut ValidatorModuleState) {
// 구현 세부사항...
}
- shutdown(): 모듈이 언로드될 때 한 번 호출됩니다. 모듈의 자원을 정리하고
startup()에서 생성한 전역 공유 데이터에 할당된 메모리를 해제합니다.
extern "C" fn shutdown(state: *mut ValidatorModuleState) {
// 구현 세부사항...
}
- 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 {
// 구현 세부사항...
}
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() 과정에서 할당한 검증기별 데이터를 가리킵니다.
검증기 모듈 상태와 라이프사이클 관리
예제 검증기는 환경 변수 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();
}
}
}OAuth2 토큰 검증 로직 구현
validate() 콜백에서는 다음 세 가지 작업을 수행합니다.
- unsafe C 영역에서 매개변수 추출
- 주요 검증 로직 실행
- 결과를 설정하고 반환
전체적인 구현 구조는 다음과 같습니다.
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 1과 Task 3에서는 C ↔ Rust 경계를 안전하게 처리하고, safe_validate()에는 실제 검증 로직을 분리해 구현합니다.
Task 1: C에서 Rust로 매개변수 가져오기
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)를 사용해 메모리를 할당합니다. 이를 통해 문자열이 적절한 메모리 컨텍스트에 저장되고 쿼리가 종료될 때 해제됩니다.
PostgreSQL OAuth2 검증기 콜백 등록
#[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가 아닙니다. issuer와 scope 인자도 반드시 필요합니다.
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_id를 joe_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로 OAuth2 인증 테스트하기
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 인증 흐름의 상세 추적을 활성화합니다.
OAuth2 인증 과정 확인
인증 과정에서는 다음과 같은 흐름을 확인할 수 있습니다.
- IdP 인증 URL과 디바이스 코드가 표시됩니다.
Visit http://your-oauth-server/auth/device and enter the code: RJJZ-PRDC - 해당 URL을 방문해 표시된 코드를 입력합니다.
- 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. 인증 거부 동작 테스트
예제 검증기의 설정을 비활성화한 상태에서 인증이 거부되는지 확인하려면 다음과 같이 환경 변수를 변경하고 PostgreSQL을 재시작합니다.
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"
Rust 기반 OAuth2 검증기 구현 시 고려 사항
Rust로 PostgreSQL 18용 커스텀 OAuth2 검증기를 구현하는 과정은 PostgreSQL 플러그인 아키텍처를 활용해 인증 로직을 확장하는 방법을 보여줍니다. 구현 과정에서 고려해야 할 핵심 사항은 다음과 같습니다.
- 안전성 우선:
unsafe블록은 C–Rust 경계에서만 사용하고 견고한 오류 처리를 구현합니다. - 모듈화 설계: C ABI 구현과 비즈니스 로직을 명확하게 분리합니다.
- 메모리 관리: C에 반환하는 데이터에는 PostgreSQL의 메모리 할당자(
palloc)를 사용합니다. - 에러 처리: 포괄적인 에러 처리와 로깅을 구현합니다.
- 테스트: 실제 OAuth2 토큰을 다양한 시나리오에서 충분히 테스트합니다.
PostgreSQL 18 OAuth2 개발 참고 자료
- PostgreSQL 18 OAuth2 Documentation
- pgrx Framework Documentation
- JWT Validation with Rust
- PostgreSQL Extension Development
메일: salesinquiry@enterprisedb.com