Skip to content

feat(postgres): support SASL OAUTHBEARER authentication - #4400

Open
geoHeil wants to merge 2 commits into
transact-rs:mainfrom
geoHeil:feat/pg-oauthbearer
Open

feat(postgres): support SASL OAUTHBEARER authentication#4400
geoHeil wants to merge 2 commits into
transact-rs:mainfrom
geoHeil:feat/pg-oauthbearer

Conversation

@geoHeil

@geoHeil geoHeil commented Aug 29, 2026

Copy link
Copy Markdown

Closes #4399.

PostgreSQL 18's oauth HBA method authenticates over SASL OAUTHBEARER (RFC 7628) instead of with a password. sasl::authenticate accepted only SCRAM-SHA-256 and SCRAM-SHA-256-PLUS, so such a server was unreachable.

What the driver does

SQLx does not contact an identity provider: obtaining a bearer token is the application's job. A token is the whole of the exchange, so a connection either presents an accepted one or it fails — the driver's part is to present a token if it has one, and otherwise to find out which token the server wants.

  • PgConnectOptions::oauth_token(token) presents that token in the SASL initial client response (token-first, so no extra round trip).
  • With no token set, the driver sends the empty auth value that RFC 7628 §4.3 and libpq use to ask a server for its OAuth parameters.
  • Either way, a failed exchange returns Error::OAuth(OAuthChallenge). OAuthChallenge::document() is the server's status document verbatim, naming the issuer's discovery URI and the required scope. The application runs its flow and dials again with the token set; nothing re-dials inside establish.
let options = PgConnectOptions::new();

let options = match PgConnection::connect_with(&options).await {
    Err(Error::OAuth(challenge)) => options.oauth_token(mint_token(&challenge).await?),

    // The server may not want OAuth at all, in which case this already connected.
    result => return result,
};

PgConnection::connect_with(&options).await

A token set on the options is not refreshed, so an application whose pool outlives its tokens has to build a pool with new options; keeping a pool supplied per connection belongs to the connect callback in #3582.

Notes

  • OAUTHBEARER defines no channel binding and the server rejects the p specifier, so the gs2 header is always n,, and this does not go through the -PLUS machinery.
  • The token is checked against the b64token grammar of RFC 6750 before it reaches the wire, which is what the server enforces too. The grammar excludes the kvsep byte, whitespace and NUL, so a token that passes cannot forge additional key/value pairs or truncate the message.
  • The token is held in a type whose Debug prints nothing, because PgConnectOptions derives Debug and a bearer token is a credential. It never appears in an error either.
  • No connection-string option is added: a URL ends up in shell history, in ps output and in logs.
  • SaslInitialResponse now carries the mechanism name instead of a plus flag that was always false.
  • No new dependencies. The status document is handed over unparsed, which also keeps JSON out of the default build.

Tests

tests/postgres/oauth.rs runs against a real PostgreSQL 18: the postgres_18_oauth service in tests/docker-compose.yml, which compiles a validator module accepting one fixed token, because the server ships none and refuses to run an OAuth exchange without one.

Case Result
Token accepted connects; current_user is the requested role
Token rejected Error::OAuth; document reports invalid_token
No token configured Error::OAuth; document names the issuer's .well-known/openid-configuration and the scope
Token containing a kvsep Error::Configuration before anything is sent, and the error omits the token
Challenge answered by dialing again connects

The tests are #[ignore]d, because the mechanism is the server's choice and no other test service asks for a token. The new postgres-oauth CI job and python3 tests/x.py -t postgres_18_oauth run them with --include-ignored.

🤖 Generated with Claude Code

PostgreSQL 18 added the `oauth` HBA method, which authenticates over SASL
OAUTHBEARER (RFC 7628) instead of a password. `sasl::authenticate` accepted
only SCRAM-SHA-256 and SCRAM-SHA-256-PLUS, so such a server was unreachable.

This is the token-first flow only: the caller supplies a bearer token and the
driver performs the SASL exchange. SQLx does not contact an identity provider,
so the discovery and device-authorization flows of RFC 7628 are not
implemented; this matches the scope other drivers limit themselves to.

    let options = PgConnectOptions::new().oauth_token_provider(|| async {
        Ok(refresh_my_token().await?)
    });

The provider is called once per connection attempt rather than stored, so a
pool that reconnects after the token expired presents a fresh one.
`oauth_token` sets a fixed token where that is enough.

Notes:

- OAUTHBEARER defines no channel binding and the server rejects the `p`
  specifier, so the gs2 header is always `n,,` and this does not go through
  the `-PLUS` machinery.
- The token is checked against the `b64token` grammar of RFC 6750 before it
  reaches the wire, which is what the server enforces too. The grammar
  excludes the kvsep byte, whitespace and NUL, so a token that passes cannot
  forge additional key/value pairs or truncate the message.
- `PgOAuthToken` has a hand-written `Debug` that prints nothing, because
  `PgConnectOptions` derives `Debug` and a bearer token is a credential. The
  token also never appears in an error.
- No connection-string option is added: a URL ends up in shell history, in
  `ps` output and in logs.
- `SaslInitialResponse` now carries the mechanism name instead of a `plus`
  flag that was always `false`.
- No new dependencies.

@abonander abonander left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add an integration test

Comment thread sqlx-postgres/src/connection/oauth.rs Outdated

// `PgStream::recv` turns `ErrorResponse` into `Err`, which is the expected
// outcome here and carries the server's own diagnostic.
stream.recv().await?;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think the ErrorResponse actually contains anything meaningful that the application could use to implement the dynamic discovery flow. Looking at the actual authentication code, it doesn't generate any error that has the discovery information in it, it just bails out: https://github.com/postgres/postgres/blob/a12600b762c36d91450ce085fa25ef75250bc1c2/src/backend/libpq/auth-oauth.c#L215

All that is supposed to be in document, which probably needs to be passed out to the caller somehow.

One problem with the discovery flow is we need to re-dial the connection here, but the whole establish flow assumes the connection either succeeds or it doesn't, there's no built-in retries. Given that users also want to be able to pass in their own sockets, I don't think it makes sense to add an internal re-dial loop.

This arguably makes oauth_token_provider() useless since the data the application would need to implement the discovery isn't made available to that context.

We could expose OAuth failures as a new Error variant that includes the document, and instruct the caller to set the token and re-dial the connection themselves. This also means we should assume the discovery flow during SASL negotiation if a token isn't already set.

As for making this work with Pool, I've added a custom connection callback in #3582, which didn't make it into 0.9 unfortunately, but I'd like to target it for 0.10.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should I go for a stacked Pr on top of the changes of #3582?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — the document is what comes out now instead of being dropped.

  • New Error::OAuth(OAuthChallenge) in sqlx-core. OAuthChallenge::document() is the server's status document verbatim; it is handed over unparsed, since acting on it means running a flow only the application can run, and that keeps JSON out of the default build.
  • A connection with no token no longer fails locally. It sends the empty auth value that RFC 7628 §4.3 (and libpq's client_initial_response(conn, discover=true)) uses to ask a server for its OAuth parameters, so the challenge is how the caller learns the issuer and scope. A missing token and a rejected token now end the same way.
  • Nothing re-dials inside establish. The caller sets the token on the options and connects again; PgConnectOptions::oauth_token documents the sequence.
  • oauth_token_provider() is gone, for the reason you gave: it can refresh a token but never discover one, and keeping a pool supplied with fresh tokens is breaking: new sqlx::Pool architecture #3582's job rather than a second mechanism here. Happy to restore it if you would rather have the refresh convenience in the meantime.

Integration tests are in tests/postgres/oauth.rs, against a real PostgreSQL 18: accepted token, rejected token, no token configured, a token that would forge a message, and the dial-again sequence as documented. They need a server with oauth in pg_hba.conf and a validator module — the server ships none and refuses to run an exchange without one — so tests/docker-compose.yml gains a postgres_18_oauth service that compiles a ~30-line validator accepting one fixed token. The tests are #[ignore]d because the mechanism is the server's choice and no other service asks for a token; the new postgres-oauth job and tests/x.py -t postgres_18_oauth run them with --include-ignored.

Review pointed out that the `ErrorResponse` closing a failed OAUTHBEARER
exchange says only that authentication failed. Everything an application needs
in order to go and get a token is in the server's status document, which the
driver was throwing away, and a token provider cannot be the place where that
happens because the document never reaches it.

So the document is what comes out now. `Error::OAuth` carries an
`OAuthChallenge`, and a connection with no token asks the server for its
parameters instead of failing locally: an empty `auth` value, which is how
RFC 7628 §4.3 and libpq ask. A missing token and a rejected one therefore end
the same way, with the caller holding the issuer and the scope it needs to mint
a token and dial again. Nothing re-dials inside `establish`, which has no
retries and may be handed a socket by the caller.

`oauth_token_provider` goes with it: it could refresh a token but never
discover one, and keeping a pool supplied with fresh tokens is the job of the
connection callback in transact-rs#3582 rather than of a second mechanism here.
`oauth_token` documents the sequence, including that a token set there is not
refreshed.

`tests/postgres/oauth.rs` covers an accepted token, a rejected one, a
connection with no token, a token that would forge a message, and the
dial-again sequence as documented, against a real PostgreSQL 18. The
`postgres_18_oauth` service compiles a validator module for that, because the
server ships none and refuses to run an OAuth exchange without one. Those
tests are `#[ignore]`d, since the mechanism is the server's choice and no
other service asks for a token; the `postgres-oauth` CI job and `x.py` run
them with `--include-ignored`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

Postgres: support SASL OAUTHBEARER (PostgreSQL 18 oauth authentication method)

2 participants