Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

53 changes: 53 additions & 0 deletions example.pgdog.toml
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,60 @@ mirror_queue = 128
# - scram
# - md5
# - trust
# - jwt
auth_type = "scram"

# ── JWT authentication settings ────────────────────────────────────────────────
# Only used when auth_type = "jwt".

# Path to an RSA/EC public-key PEM file used to verify JWT tokens.
# Takes priority over jwt_jwks_url when both are configured.
#
# jwt_public_key_file = "/etc/pgdog/jwt_public.pem"

# URL of a JWKS endpoint to fetch verification keys from.
# Example: https://auth.example.com/.well-known/jwks.json
#
# jwt_jwks_url = "https://auth.example.com/.well-known/jwks.json"

# How long (seconds) to cache JWKS keys before re-fetching.
# Default: 300
#
# jwt_jwks_cache_ttl = 300

# Expected audience claim. Tokens without this audience value are rejected.
#
# jwt_audience = "myapp"

# The JWT claim to use as the PostgreSQL database username.
# Default: sub
#
# jwt_username_claim = "sub"

# Backend Postgres user pgDog uses to connect on behalf of JWT users.
# Defaults to the connection-string username when not set.
#
# jwt_server_user = "pgdog_service"

# Backend Postgres password used together with jwt_server_user.
#
# jwt_server_password = "supersecret"

# Optional suffix that usernames must end with to trigger JWT authentication.
# If not configured, all users are validated with JWT.
#
# jwt_user_suffix = "@example.com"

# Automatically register a connection pool for successfully authenticated JWT users.
# Default: false
#
# jwt_user_auto_provision = false

# Mark auto-provisioned JWT users as read-only (routes them to replicas).
# Default: false
#
# jwt_user_auto_provision_read_only = false

# Disable cross-shard queries.
#
# Default: false
Expand Down
24 changes: 24 additions & 0 deletions integration/jwt/Dockerfile.pgdog
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
FROM rust:1.96-slim AS builder

RUN apt-get update && apt-get install -y \
libssl-dev \
pkg-config \
cmake \
clang \
&& rm -rf /var/lib/apt/lists/*

WORKDIR /app
COPY . .
RUN cargo build --release --bin pgdog

FROM debian:trixie-slim
RUN apt-get update && apt-get install -y \
libssl3 \
ca-certificates \
postgresql-client \
&& rm -rf /var/lib/apt/lists/*

COPY --from=builder /app/target/release/pgdog /usr/local/bin/pgdog

EXPOSE 6432
ENTRYPOINT ["pgdog"]
89 changes: 89 additions & 0 deletions integration/jwt/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""
Shared fixtures for JWT integration tests.

Keys are read from /keys/ (mounted in Docker) or from the local ./keys/ directory
when running directly. The generate_keys.sh script creates them.
"""

import os
import time
from pathlib import Path

import jwt as pyjwt
import pytest
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.backends import default_backend


# ── Key loading ───────────────────────────────────────────────────────────────

def _find_keys_dir() -> Path:
candidates = [
Path("/keys"),
Path(__file__).parent / "keys",
]
for p in candidates:
if (p / "private.pem").exists():
return p
raise FileNotFoundError(
"RSA key pair not found. Run integration/jwt/generate_keys.sh first."
)


@pytest.fixture(scope="session")
def keys_dir() -> Path:
return _find_keys_dir()


@pytest.fixture(scope="session")
def private_key(keys_dir):
return (keys_dir / "private.pem").read_bytes()


@pytest.fixture(scope="session")
def public_key(keys_dir):
return (keys_dir / "public.pem").read_bytes()


# ── JWT helpers ────────────────────────────────────────────────────────────────

def make_token(private_key: bytes, sub: str, exp_offset: int = 300, **extra) -> str:
"""Create a signed RS256 JWT token."""
payload = {
"sub": sub,
"iat": int(time.time()),
"exp": int(time.time()) + exp_offset,
**extra,
}
return pyjwt.encode(payload, private_key, algorithm="RS256")


@pytest.fixture(scope="session")
def token_factory(private_key):
"""Return a callable that creates JWT tokens signed with the test private key."""
def _factory(sub: str, exp_offset: int = 300, **extra) -> str:
return make_token(private_key, sub, exp_offset, **extra)
return _factory


# ── Connection helpers ─────────────────────────────────────────────────────────

PGDOG_HOST = os.environ.get("PGDOG_HOST", "127.0.0.1")
PGDOG_PORT = int(os.environ.get("PGDOG_PORT", "6432"))
POSTGRES_HOST = os.environ.get("POSTGRES_HOST", "127.0.0.1")
POSTGRES_PORT = int(os.environ.get("POSTGRES_PORT", "5432"))


def pgdog_dsn(user: str, token: str, dbname: str = "pgdog") -> str:
return (
f"host={PGDOG_HOST} port={PGDOG_PORT} "
f"dbname={dbname} user={user} password={token}"
)


def postgres_dsn(user: str = "pgdog", password: str = "pgdog", dbname: str = "pgdog") -> str:
return (
f"host={POSTGRES_HOST} port={POSTGRES_PORT} "
f"dbname={dbname} user={user} password={password}"
)
51 changes: 51 additions & 0 deletions integration/jwt/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
version: "3.9"

services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: pgdog
POSTGRES_PASSWORD: pgdog
POSTGRES_DB: pgdog
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U pgdog"]
interval: 2s
timeout: 5s
retries: 10

pgdog:
build:
context: ../..
dockerfile: integration/jwt/Dockerfile.pgdog
depends_on:
postgres:
condition: service_healthy
ports:
- "6432:6432"
volumes:
- ./keys:/keys:ro
- ./pgdog.toml:/pgdog.toml:ro
- ./users.toml:/users.toml:ro
command: ["--config", "/pgdog.toml", "--users", "/users.toml"]
healthcheck:
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -p 6432 -U pgdog -d pgdog"]
interval: 2s
timeout: 5s
retries: 20

test:
build:
context: .
dockerfile: Dockerfile.test
depends_on:
pgdog:
condition: service_healthy
volumes:
- ./keys:/keys:ro
environment:
PGDOG_HOST: pgdog
PGDOG_PORT: "6432"
POSTGRES_HOST: postgres
POSTGRES_PORT: "5432"
19 changes: 19 additions & 0 deletions integration/jwt/generate_keys.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Generate RSA key pair for JWT signing in local tests.
set -euo pipefail

KEYS_DIR="$(cd "$(dirname "$0")" && pwd)/keys"
mkdir -p "$KEYS_DIR"

if [[ -f "$KEYS_DIR/private.pem" && -f "$KEYS_DIR/public.pem" ]]; then
echo "Keys already exist in $KEYS_DIR — skipping generation."
exit 0
fi

openssl genrsa -out "$KEYS_DIR/private.pem" 2048
openssl rsa -in "$KEYS_DIR/private.pem" -pubout -out "$KEYS_DIR/public.pem"

chmod 600 "$KEYS_DIR/private.pem"
chmod 644 "$KEYS_DIR/public.pem"

echo "Keys written to $KEYS_DIR"
27 changes: 27 additions & 0 deletions integration/jwt/pgdog.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
[general]
host = "0.0.0.0"
port = 6432
workers = 2
auth_type = "jwt"
jwt_public_key_file = "/keys/public.pem"
jwt_username_claim = "sub"
jwt_user_auto_provision = true
jwt_user_auto_provision_read_only = false
jwt_server_user = "pgdog"
jwt_server_password = "pgdog"
connect_timeout = 5_000
checkout_timeout = 5_000
query_timeout = 10_000

[admin]
name = "admin"
user = "admin"
password = "pgdog"

[[databases]]
name = "pgdog"
host = "postgres"
port = 5432
database_name = "pgdog"
user = "pgdog"
password = "pgdog"
4 changes: 4 additions & 0 deletions integration/jwt/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
psycopg==3.2.6
pytest==8.3.5
PyJWT==2.9.0
cryptography==43.0.3
Loading