PostgreSQL 18의 OAuth2 인증 미리보기 (2) – Rust로 커스텀 OAuth2 검증기 만들기
작성자: Guang Yi Xu
2025년 9월 17일
**PostgreSQL 18 (RC1은 2025년 9월 5일 공개됨)**은 중요한 새로운 기능인 OAuth2 인증을 도입했습니다. 이를 통해 PostgreSQL은 기존의 사용자 이름/비밀번호 쌍 대신 OAuth2 토큰을 사용하여 사용자를 인증할 수 있습니다. 이 가이드에서는 Rust와 pgrx 프레임워크를 사용하여 커스텀 검증기 모듈을 만드는 과정을 단계별로 살펴봅니다.
이 블로그 시리즈는 총 3편으로 구성됩니다:
- Part-1: PostgreSQL 18 OAuth2 인증 작동 방식 탐구
- Part-2 (이 글): Rust로 커스텀 검증기 작성하기
- 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)
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)
- 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
- 구성 및 빌드:
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" 출력 확인
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 함수를 반드시 구현해야 합니다:
- 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 {
// 구현 세부사항...
}
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가지 작업을 구현해야 합니다:
- 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에서 매개변수 추출
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
가 아님), 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로 테스트하기 (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 인증 흐름의 상세 추적을 활성화합니다.
예상되는 동작
다음과 같은 흐름을 볼 수 있습니다:
- 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. 낮 시간대 테스트
이 예제 검증기는 낮 시간대 인증을 허용하도록 설정되어 있으므로, 동일한 로그인 테스트를 낮 시간에 하거나, 동작을 끄고 싶다면 아래와 같이 설정합니다:
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 토큰을 다양한 시나리오에서 철저히 테스트