Skip to content

feat: JWT authentication with auto-provisioning and read-only enforcement#1084

Open
ziomarco wants to merge 5 commits into
pgdogdev:mainfrom
ziomarco:feat/jwt-authentication
Open

feat: JWT authentication with auto-provisioning and read-only enforcement#1084
ziomarco wants to merge 5 commits into
pgdogdev:mainfrom
ziomarco:feat/jwt-authentication

Conversation

@ziomarco

@ziomarco ziomarco commented Jun 17, 2026

Copy link
Copy Markdown

Summary

Adds JWT-based client authentication to PgDog. Clients present a signed JWT as their password (cleartext auth flow); PgDog validates it against either a local RSA/EC public-key PEM file or a remote JWKS endpoint (with cached keys), maps a configurable claim (sub by default) to a Postgres role, and optionally auto-provisions a connection pool for that user. Auto-provisioned users can be marked read-only, which is enforced at the query-router level (writes are forced to replicas and write-escalation via SET pgdog.role / comments is blocked).

What's included

  • AuthType::Jwt and a JwtValidator (RS256/ES256, JWKS caching with double-checked locking, audience + expiry validation).
  • Config surface in [general]: jwt_public_key_file, jwt_jwks_url, jwt_jwks_cache_ttl, jwt_audience, jwt_username_claim, jwt_server_user, jwt_server_password, jwt_user_suffix, jwt_user_auto_provision, jwt_user_auto_provision_read_only.
  • Backend SET ROLE to the JWT-derived role when it differs from the connection user (identifier is escaped defensively).
  • Pool recovery: re-create an offline pool on re-auth, and shut down a pool on backend auth failure to avoid connection storms.
  • Integration test suite under integration/jwt/ (docker-compose + pytest) and unit tests.

Motivation

At my company we run on Cloud SQL and rely heavily on Cloud SQL IAM authentication — we have internal tooling that automates provisioning, and our developers' local tooling authenticates against it automatically. We're now evaluating Postgres on Kubernetes with CloudNativePG, and JWT-based auth is the natural fit for that environment. We discovered PgDog, started using it, and wanted to contribute this capability back.

Note on authorship/affiliation: This is entirely my own personal work. It is not owned by, sponsored by, or otherwise affiliated with my employer — I'm only mentioning the company to give context on the production use case that motivated it.

Testing

  • cargo fmt --check, cargo clippy (clean), JWT/auth/backend unit tests pass via cargo nextest.
  • JWT integration suite in integration/jwt/.

Happy to iterate on naming, defaults, or the read-only enforcement approach.

Of course I'm more than happy to contribute to the project documentation as well, if you may explain me how to do it :)

ziomarco and others added 3 commits June 17, 2026 23:20
Adds JSON Web Token (JWT) authentication as a new auth_type option.
When enabled, clients present a JWT as their password; pgDog validates
the signature (PEM file or JWKS endpoint), extracts the configured
username claim (default: sub), and optionally auto-provisions a
connection pool for that user.

Key changes:

- pgdog/src/auth/jwt/ — new module: JwtValidator, Claims, PEM cache,
  tests (validation, expiry, audience, algorithm, memory-leak, suffix)
- pgdog-config/src/auth.rs — add Jwt variant to AuthType
- pgdog-config/src/general.rs — add 10 JWT config fields with env
  var support and tests
- pgdog/src/config/mod.rs — JWT_VALIDATOR static + jwt_validator()
- pgdog/src/config/convert.rs — user_from_jwt() helper
- pgdog/src/frontend/client/mod.rs — JWT auth flow in login();
  effective_user derived from token claim used for all downstream ops
- pgdog/src/backend/pool/address.rs — client_user field (drives SET ROLE)
- pgdog/src/backend/server.rs — execute SET ROLE after connect when
  client_user differs from server user
- pgdog/src/backend/pool/cluster.rs — user_read_only flag; forces
  ExcludePrimary rw_split for read-only users
- pgdog/src/backend/pool/request.rs — replica_only field
- pgdog/src/backend/pool/lb/mod.rs — respect replica_only in routing
- pgdog/src/backend/pool/monitor.rs — shut down pool on auth error;
  replenish() propagates errors
- pgdog/src/backend/pool/error.rs — Auth variant + is_auth()
- pgdog/src/backend/databases.rs — recreate offline pools in add()
- pgdog/src/backend/error.rs — extend is_auth() for codes 22023/42704
- pgdog/src/frontend/router/parser/query/mod.rs — enforce read-only
  routing when user_read_only; also apply to StartTransaction routes
- example.pgdog.toml — document all JWT configuration options

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds integration/jwt/ with:
- docker-compose.yml — postgres + pgdog (JWT mode) + python test runner
- Dockerfile.pgdog — builds pgdog from source
- Dockerfile.test — python test image
- pgdog.toml / users.toml — JWT-mode config pointing at postgres service
- generate_keys.sh — generates RSA key pair for local testing
- conftest.py — pytest fixtures: key loading, token_factory, DSN helpers
- test_jwt.py — E2E tests covering:
    - valid JWT connects and queries succeed
    - auto-provisioned pools per unique sub claim
    - simultaneous multi-user sessions
    - expired / tampered / plain-string tokens rejected
    - transactions (commit + rollback) through proxy
    - custom claim passthrough

Run locally:
  cd integration/jwt
  bash generate_keys.sh
  docker-compose up --build --abort-on-container-exit

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Escape embedded double quotes in the JWT-derived role name before
  interpolating it into SET ROLE, preventing identifier injection.
- Collapse nested if-let blocks and use is_none_or to satisfy clippy.
@CLAassistant

CLAassistant commented Jun 17, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

ziomarco added 2 commits June 18, 2026 07:37
Running the integration suite (integration/jwt) surfaced several bugs in
the JWT auth path; all 12 tests now pass.

pgdog fixes:
- Launch backend pools for users that only have server-side credentials
  (server_password / external identity) even when no client-facing
  password is configured. Auto-provisioned JWT users previously had their
  pools disabled with "password not set" and reported "pool is down".
- Reuse an existing online pool when the JWT subject matches a user that
  is already configured or previously provisioned, instead of trying to
  re-provision it (which failed under passthrough auth with a password
  change error).
- Enforce strict JWT expiry by setting validation leeway to 0;
  jsonwebtoken's default 60s leeway accepted tokens that had just expired.
- Treat a failed backend SET ROLE (e.g. role does not exist) as
  non-fatal: log a warning and continue as the configured server user
  rather than dropping the connection.

Integration suite fixes:
- Dockerfile.pgdog: build with rust 1.96 (edition 2024) and run on
  debian:trixie-slim to match the builder's glibc.
- pgdog.toml: use the renamed 'database_name' field.
- test_jwt.py: create the table in a committed transaction before the
  rollback test so the verification query has a table to read.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants