feat(postgres): support SASL OAUTHBEARER authentication - #4400
Conversation
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
left a comment
There was a problem hiding this comment.
Can you add an integration test
|
|
||
| // `PgStream::recv` turns `ErrorResponse` into `Err`, which is the expected | ||
| // outcome here and carries the server's own diagnostic. | ||
| stream.recv().await?; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Should I go for a stacked Pr on top of the changes of #3582?
There was a problem hiding this comment.
Done — the document is what comes out now instead of being dropped.
- New
Error::OAuth(OAuthChallenge)insqlx-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
authvalue that RFC 7628 §4.3 (and libpq'sclient_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_tokendocuments 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: newsqlx::Poolarchitecture #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>
Closes #4399.
PostgreSQL 18's
oauthHBA method authenticates over SASL OAUTHBEARER (RFC 7628) instead of with a password.sasl::authenticateaccepted 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).authvalue that RFC 7628 §4.3 and libpq use to ask a server for its OAuth parameters.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 insideestablish.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
pspecifier, so the gs2 header is alwaysn,,and this does not go through the-PLUSmachinery.b64tokengrammar 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.Debugprints nothing, becausePgConnectOptionsderivesDebugand a bearer token is a credential. It never appears in an error either.psoutput and in logs.SaslInitialResponsenow carries the mechanism name instead of aplusflag that was alwaysfalse.Tests
tests/postgres/oauth.rsruns against a real PostgreSQL 18: thepostgres_18_oauthservice intests/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.current_useris the requested roleError::OAuth; document reportsinvalid_tokenError::OAuth; document names the issuer's.well-known/openid-configurationand the scopeError::Configurationbefore anything is sent, and the error omits the tokenThe tests are
#[ignore]d, because the mechanism is the server's choice and no other test service asks for a token. The newpostgres-oauthCI job andpython3 tests/x.py -t postgres_18_oauthrun them with--include-ignored.🤖 Generated with Claude Code