Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@ and this project adheres to [Semantic Versioning](https://book.async.rs/overview
- `Client::begin_transaction`, `Client::commit` and `Client::rollback` for driving Trino transactions, plus `Client::transaction_id` / `Client::set_transaction_id` to inspect and set the session's transaction at runtime (previously only settable at build time via `ClientBuilder::transaction_id`)
- `Error::Transaction` — returned when a transaction operation is attempted in a state that does not allow it (starting one while another is active, or committing/rolling back without one)
- `TransactionId::is_active`
- Interactive OAuth2 authentication (`Auth::new_oauth2` / `new_oauth2_with_handler`). On a `401` Bearer challenge the client presents the login URL (browser + stderr by default, or a custom `RedirectHandler`), polls the Trino token endpoint, and retries with the bearer token. Token is cached in-memory for the process. Coordinators with several authentication types enabled (e.g. `http-server.authentication.type=PASSWORD,OAUTH2`) send one `WWW-Authenticate` header per type, so every challenge header is scanned for the Bearer one rather than only the first

### Fixed
- **Transactions were unusable.** Trino returns a new transaction's identifier in `X-Trino-Started-Transaction-Id`, but the client parsed that header with a function that recognised only four fixed literals. A real identifier matched none of them and was silently discarded, so `START TRANSACTION` succeeded on the coordinator while every subsequent statement sent `X-Trino-Transaction-Id: NONE` and ran outside the transaction — and `COMMIT`/`ROLLBACK` could not address it. The identifier is now retained and sent on every subsequent request
- Unparseable `X-Trino-Set-Role` header values are now logged instead of being dropped silently

### Changed
- **Breaking:** `TransactionId` now models what the `X-Trino-Transaction-Id` header actually carries: `NoTransaction | Id(String)`. The `StartTransaction`, `RollBack` and `Commit` variants are removed — they are SQL statements, not header values, and sending them produced a header Trino does not accept. `to_str` is replaced by `as_header_value(&self) -> &str` and `from_str` by the infallible `from_header_value(&str) -> Self`. `TransactionId` is no longer `Copy` (it now owns a `String`); it is still `Clone`, and now also `PartialEq` and `Eq`. See the [migration guide](MIGRATION.md)
- **Breaking:** `Auth` is now `#[non_exhaustive]` and has a new `OAuth2` variant. Exhaustive `match` on `Auth` must add a wildcard arm

## [0.11.0] - 2026-07-19

Expand Down
16 changes: 16 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,19 @@ transient failure, query submission (POST) only when definitely not processed.
- Tests for `client.rs` internals go in its bottom `mod tests`; integration tests
live in `tests/` (wiremock for HTTP, fixtures in `tests/data/models/`).
- Releases follow the process in the `release` skill (`.claude/skills/release/`).

## Manual OAuth2 e2e

`tests/oauth2.rs::oauth2_real_login` exercises `Auth::new_oauth2()` against a
real, OAuth2-configured Trino coordinator (interactive browser login — not run
in CI). A local Trino + Keycloak stack is committed at
`integration_tests/test_setup/oauth/` (see its README for the one-time
`/etc/hosts` step and setup gotchas):

```bash
docker compose -f integration_tests/test_setup/oauth/docker-compose.yml up -d
TRINO_OAUTH2_HOST=localhost TRINO_OAUTH2_PORT=8443 TRINO_OAUTH2_NO_VERIFY=1 \
cargo test --test oauth2 -- --ignored oauth2_real_login
```

Or point `TRINO_OAUTH2_HOST` (and `TRINO_OAUTH2_PORT`) at your own Trino + IdP.
2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ http = {workspace = true}
iterable = {workspace = true}
lazy_static = {workspace = true}
lz4 = {workspace = true, optional = true}
open = {workspace = true}
paste = {workspace = true}
regex = {workspace = true}
# network dependencies
Expand Down Expand Up @@ -86,6 +87,7 @@ http = "1.4.2"
iterable = "0.6"
lazy_static = "1.5"
lz4 = "1.28"
open = "5"
paste = "1.0.15"
regex = "1.13.1"
reqwest = {version = "0.13.4", default-features = false, features = ["rustls", "json"]}
Expand Down
42 changes: 42 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,48 @@ match id {
It owns a `String`. It is still `Clone`, and now also `PartialEq` and `Eq`. Add
`.clone()` where you relied on implicit copies.

### `Auth` is now `#[non_exhaustive]` (OAuth2 support)

`Auth` gained a new `OAuth2` variant for interactive browser-based
authentication, alongside `Basic` and `Jwt`. To let future variants be added
without another breaking release, `Auth` is now `#[non_exhaustive]` — an
exhaustive `match` no longer compiles and needs a wildcard arm.

**Before:**

```rust
match auth {
Auth::Basic(u, p) => ...,
Auth::Jwt(t) => ...,
}
```

**After:**

```rust
match auth {
Auth::Basic(u, p) => ...,
Auth::Jwt(t) => ...,
_ => ..., // required: Auth is now #[non_exhaustive]
}
```

To use OAuth2:

```rust
let client = ClientBuilder::new("user", "coordinator.example.com")
.secure(true)
.auth(Auth::new_oauth2())
.build()?;
```

On a `401` Bearer challenge the client presents the login URL (opens a
browser and prints it to stderr by default; supply a custom `RedirectHandler`
via `Auth::new_oauth2_with_handler` to change that), polls the Trino token
endpoint, and retries the request with the bearer token once the user
completes the login. The token is cached in-memory for the life of the
`Client`.

## 0.10.x → 0.11.0

### Error handling (restructured `Error` enum)
Expand Down
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Fork rationale :
### authn:
- Basic Auth
- Jwt Auth
- Interactive OAuth2 (browser-based)

### protocols:
- Spooling Protocol (for efficient large result set handling)
Expand Down Expand Up @@ -111,6 +112,39 @@ async fn main() {
}
```

### Interactive OAuth2 example

Trino's OAuth2 authentication makes the **coordinator** the OAuth client: on a
`401` the client opens the coordinator-supplied login URL in a browser (and
prints it to stderr as a fallback), polls Trino's token endpoint until you finish
the IdP login, then retries with the bearer token. The token is cached in memory
for the life of the `Client`. Requires TLS to the coordinator.

```rust
use trino_rust_client::auth::Auth;
use trino_rust_client::{ClientBuilder, Row};

#[tokio::main]
async fn main() {
let cli = ClientBuilder::new("user", "coordinator.example.com")
.secure(true)
.auth(Auth::new_oauth2())
.catalog("catalog")
.build()
.unwrap();

let data = cli.get_all::<Row>("select 1").await.unwrap().into_vec();

for r in data {
println!("{:?}", r)
}
}
```

Supply a custom presentation strategy (instead of opening a browser) with
`Auth::new_oauth2_with_handler(Arc::new(my_handler))`, and tune the token poll
loop with `.with_poll(max_attempts, timeout)`.

### Example dealing with fields not known at compile time
```rust
use trino_rust_client::{ClientBuilder, Row, Trino};
Expand Down
53 changes: 53 additions & 0 deletions examples/oauth2.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
use std::env::var;

use dotenvy::dotenv;
use trino_rust_client::auth::Auth;
use trino_rust_client::{ClientBuilder, Row};

/// Interactive (browser-based) OAuth2 against a Trino coordinator configured with
/// `http-server.authentication.type=OAUTH2`.
///
/// On the first request the client receives a `401`, opens the coordinator's
/// login URL in your browser (and prints it to stderr as a fallback for
/// headless/SSH sessions), then polls Trino's token endpoint until you finish
/// the IdP login and retries the request with the bearer token. The token is
/// cached in memory for the life of the `Client`. Trino requires TLS for OAuth2,
/// so `.secure(true)` is mandatory.
///
/// Run with e.g. a `.env` file providing USERNAME/HOST/PORT/CATALOG/SQL:
/// cargo run --example oauth2
#[tokio::main]
async fn main() {
dotenv().ok();

let user = var("USERNAME").unwrap();
let host = var("HOST").unwrap();
let port = var("PORT")
.unwrap_or_else(|_| "8443".into())
.parse()
.unwrap();
let catalog = var("CATALOG").unwrap();
let sql = var("SQL").unwrap();

// Default handler: opens the system browser and prints the URL to stderr.
// For a custom presentation strategy use
// `Auth::new_oauth2_with_handler(Arc::new(my_handler))`, and tune the token
// poll loop with `.with_poll(max_attempts, timeout)`.
let auth = Auth::new_oauth2();

let cli = ClientBuilder::new(user, host)
.port(port)
.catalog(catalog)
.auth(auth)
.secure(true) // OAuth2 requires HTTPS to the coordinator
// For a self-signed coordinator certificate, also supply its root cert:
// .ssl(Ssl { root_cert: Some(Ssl::read_pem(&"/path/root.pem").unwrap()) })
.build()
.unwrap();

let data = cli.get_all::<Row>(sql).await.unwrap().into_vec();

for r in data {
println!("{:?}", r)
}
}
81 changes: 81 additions & 0 deletions integration_tests/test_setup/oauth/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Manual OAuth2 test stack (Trino + Keycloak)

A **local, manual** stack for exercising the client's interactive OAuth2 support
against a real Trino coordinator and a real IdP (Keycloak). It backs
`tests/oauth2.rs::oauth2_real_login`.

> **Not run in CI.** The interactive flow requires a human to complete the
> Keycloak login in a browser — there is no automated login here. The stack has
> been brought up and verified up to that human step: the coordinator starts
> healthy and returns the expected `401` + `WWW-Authenticate: Bearer
> x_redirect_server=..., x_token_server=...` challenge over TLS. Completing the
> browser login (the two gotchas below) is the part you drive yourself.

## What's in it

- **Keycloak** (`realm=trino`, confidential client `trino`/`trino-secret`, user
`alice`/`alice`) on `http://keycloak:8080`.
- **Trino 478** coordinator with TLS on `8443` and
`http-server.authentication.type=PASSWORD,OAUTH2`, plus a `memory` catalog.
- A one-shot job that generates a self-signed keystore for the coordinator.

### Why two authentication types

A coordinator with several authentication types emits **one `WWW-Authenticate`
header per type**, in configuration order. Verified against Trino 478:

```console
$ curl -sk -i -X POST https://localhost:8443/v1/statement -H 'X-Trino-User: alice' --data 'SELECT 1'
HTTP/2 401
www-authenticate: Basic realm="Trino"
www-authenticate: Bearer x_redirect_server="https://localhost:8443/oauth2/token/initiate/...", x_token_server="..."
```

`Basic` comes first, so a client that reads only the first header never sees the
Bearer challenge and fails with a bare `401`. `PASSWORD` is listed first here on
purpose to keep the manual e2e run on that hostile ordering. The file-based
password authenticator (`password-authenticator.properties`, `password.db` —
`alice` / `alice`, bcrypt) exists only to make the second type valid; the test
still authenticates via OAuth2.

## Two gotchas (read before running)

1. **Trino mandates TLS for OAuth2.** The coordinator serves the client over
`https://localhost:8443`; the client's `auth_http_insecure` can't help here
because it's *Trino* rejecting plain http, not the client. The stack uses a
self-signed cert, so run the test with `TRINO_OAUTH2_NO_VERIFY=1` (or import
the generated cert via `ClientBuilder::ssl`).
2. **Keycloak issuer/hostname must be consistent.** The token `issuer` and
`jwks-url` (used server-to-server by Trino) and the `auth-url` (opened in your
host browser) must all resolve to the *same* Keycloak origin, or issuer
validation fails. The stack pins everything to `http://keycloak:8080`, so add
a hosts entry so your browser can reach it too:

```bash
echo "127.0.0.1 keycloak" | sudo tee -a /etc/hosts # one-time
```

## Run

```bash
docker compose -f integration_tests/test_setup/oauth/docker-compose.yml up -d

# Wait for Trino to report healthy, then:
TRINO_OAUTH2_HOST=localhost TRINO_OAUTH2_PORT=8443 TRINO_OAUTH2_NO_VERIFY=1 \
cargo test --test oauth2 -- --ignored oauth2_real_login
```

A browser opens for the Keycloak login — sign in as `alice` / `alice`. The test
then completes the poll → bearer → query round-trip and asserts one row.

The test's Trino session user defaults to `alice` to match the authenticated
principal — Trino denies a query whose session user differs from the OAuth2
principal (`Access Denied: User alice cannot impersonate user ...`) unless
impersonation is explicitly configured. Override with `TRINO_OAUTH2_USER` for a
coordinator whose principal differs.

Tear down with:

```bash
docker compose -f integration_tests/test_setup/oauth/docker-compose.yml down -v
```
83 changes: 83 additions & 0 deletions integration_tests/test_setup/oauth/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Manual, LOCAL-ONLY stack: Trino (TLS + OAuth2) behind Keycloak, with a ready
# test user. NOT run in CI — the interactive OAuth2 flow needs a human to
# complete the browser login. Pairs with tests/oauth2.rs::oauth2_real_login.
#
# See README.md in this directory for setup, the two common gotchas, and the run
# command. This is a starting point, not turnkey — expect to iterate on the
# Keycloak redirect URIs / issuer hostname on first run.
services:
keycloak:
image: quay.io/keycloak/keycloak:26.0
command: ["start-dev", "--import-realm", "--http-port=8080"]
environment:
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
# Pin the issuer/frontend hostname so the token issuer matches what Trino
# validates AND what your host browser reaches. Requires `keycloak` to
# resolve to 127.0.0.1 on the host (see README) so http://keycloak:8080
# works from both the Trino container and your browser.
KC_HOSTNAME: http://keycloak:8080
KC_HOSTNAME_BACKCHANNEL_DYNAMIC: "false"
ports:
- "8080:8080"
volumes:
- ./keycloak/trino-realm.json:/opt/keycloak/data/import/trino-realm.json:ro
networks:
- trino-oauth
healthcheck:
# Force bash — the /dev/tcp check is a bash builtin, not POSIX sh.
test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/localhost/8080"]
interval: 10s
timeout: 5s
retries: 20

# Generates a self-signed PKCS12 keystore for the coordinator once.
gen-certs:
image: eclipse-temurin:21-jdk
# Exec-form entrypoint (list) so the shell script is passed as a single
# argument — a folded string here gets mangled by Compose's word-splitting.
entrypoint:
- /bin/sh
- -ec
- |
if [ ! -f /certs/keystore.p12 ]; then
keytool -genkeypair -alias trino -keyalg RSA -keysize 2048 -validity 3650 \
-storetype PKCS12 -keystore /certs/keystore.p12 -storepass changeit \
-dname 'CN=localhost' -ext 'SAN=DNS:localhost,DNS:coordinator,IP:127.0.0.1'
fi
volumes:
- certs:/certs
networks:
- trino-oauth

coordinator:
image: trinodb/trino:478
depends_on:
keycloak:
condition: service_healthy
gen-certs:
condition: service_completed_successfully
ports:
- "8443:8443"
volumes:
- ./trino/etc:/etc/trino
- certs:/etc/trino/certs:ro
environment:
- JAVA_OPTS=-Xmx1G -XX:+UseG1GC
networks:
- trino-oauth
healthcheck:
# The https endpoint requires auth; the internal http port stays open for
# a simple liveness probe.
test: ["CMD", "curl", "-f", "http://localhost:8080/v1/info"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s

networks:
trino-oauth:
driver: bridge

volumes:
certs:
Loading
Loading