Skip to content

(Iceberg) Expose OAuth2 scope/audience for REST Catalog Auth - #1402

Merged
aaj3f merged 1 commit into
mainfrom
fix/iceberg-oauth2-scope
Jun 30, 2026
Merged

aaj3f merged 1 commit into
mainfrom
fix/iceberg-oauth2-scope

Conversation

@aaj3f

@aaj3f aaj3f commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Summary

Iceberg REST-catalog OAuth2 client-credentials auth already form-encodes a
scope at the engine level (OAuth2Config.scope), but scope and audience
were not exposed through any public config surface — the builders, the CLI,
and the HTTP API all hardcoded them to None. As a result, connecting to a
scope-gated Iceberg REST catalog was impossible via supported config. The
concrete blocked case is Snowflake Horizon (Snowflake's built-in
Apache-Polaris Iceberg REST endpoint), which requires
scope=session:role:<ROLE> on the /v1/oauth/tokens client-credentials
exchange — and rejects a non-empty client_id on that exchange with
400 invalid_scope.

This PR threads scope/audience through every layer and makes the empty
client_id case work.

What changed (the middle-layer builder gap)

The engine already knew how to send scope; the gap was purely the user-facing
plumbing between the public config types and the engine AuthConfig. Nothing in
the token-exchange or downstream read path changed.

  • fluree-db-api (builders): added fluent with_oauth2_scope /
    with_oauth2_audience setters on IcebergCreateConfig that mutate the
    existing AuthConfig::OAuth2ClientCredentials in place (warn-and-no-op if
    OAuth2 client-credentials auth has not been set first, or in Direct catalog
    mode). Added delegating setters on R2rmlCreateConfig. The existing 3-arg
    with_auth_oauth2(token_url, client_id, client_secret) is unchanged.
  • fluree-db-iceberg (engine): fetch_token now omits client_id from
    the client_credentials form when it is empty
    (Snowflake Horizon's
    session:role: exchange requires an absent/empty client_id; a non-empty one
    is interpreted as the principal flow and rejected).
  • fluree-db-cli: added --oauth2-scope / --oauth2-audience to
    fluree iceberg map, wired into both the local with_oauth2_* chaining and
    the args_to_json remote body. Relaxed activation: OAuth2 now engages on
    token_url + client_secret, with client_id defaulting to "".
  • fluree-db-server: added oauth2_scope / oauth2_audience to the
    IcebergMapRequest body with matching activation + build wiring.

Files

File +/-
fluree-db-api/src/graph_source/config.rs +178
fluree-db-iceberg/src/auth/oauth2.rs +149
fluree-db-cli/src/commands/iceberg.rs +103
fluree-db-server/src/routes/iceberg.rs +71
fluree-db-cli/src/cli.rs +8

498 insertions, 11 deletions.

Tests + results

All tests are hermetic (no network — OAuth2 tests run against a wiremock mock
token server).

  • fluree-db-iceberg (--features aws): 146 lib tests pass, including the
    two new wiremock tests:
    • fetch_token_encodes_scope_and_omits_empty_client_id — scope is
      form-encoded into the token request; client_id is omitted when empty;
      audience absent when None.
    • fetch_token_includes_client_id_when_non_empty_and_audience — client_id
      is present when non-empty; audience and scope are sent.
  • fluree-db-api (--features iceberg): 644 lib tests pass, including
    builder, warns-without-oauth2 no-op, no-migration serde round-trip, and R2RML
    delegation tests.
  • fluree-db-server (--features iceberg): route deserialize tests pass —
    oauth2_scope reaches the engine AuthConfig; a request without
    client_secret does not activate OAuth2.
  • fluree-db-cli (--features iceberg): args_to_json includes
    scope/audience and omits client_id; build_iceberg_config activates OAuth2
    without client_id and threads scope/audience; no secret -> no OAuth2.
  • cargo clippy clean on all four crates; cargo fmt --check clean.

Risk / compatibility

  • Additive, no migration. New optional fields/flags/setters only. The stored
    AuthConfig JSON already supported scope/audience; a round-trip test locks
    the "no migration" claim. Existing 3-arg with_auth_oauth2 callers (incl. the
    it_graph_source_r2rml integration test) compile and pass unchanged.
  • Behavior change 1 — empty client_id omitted from the token form.
    Previously client_id was sent unconditionally (even as an empty string); now
    an empty client_id is omitted entirely. This is required by Snowflake
    Horizon. For standard OAuth2 servers a non-empty client_id is still sent
    exactly as before, so only the empty-string case is affected.
  • Behavior change 2 — relaxed OAuth2 activation (CLI + HTTP). Previously
    activation required token_url + client_id + client_secret all present; now it
    triggers on token_url + client_secret, with client_id defaulting to "".
    A caller that supplies token_url + client_secret but no client_id now
    activates OAuth2 (previously it silently did not). Supplying only token_url
    (no secret) still does not activate OAuth2.

Follow-up (out of scope for this code PR)

Issue #1394 also asks for docs — a "Snowflake Horizon" section in
docs/graph-sources/iceberg.md and docs/cli/iceberg.md showing the happy path
(token_url=.../v1/oauth/tokens, client_id="", scope=session:role:<ROLE>,
vended_credentials=true, UPPERCASE identifiers). Those are intentionally left
out of this code-only slice and can land as a separate docs change.

Fixes #1394

Thread OAuth2 `scope` and `audience` through the public config surface
(builders, CLI, HTTP API) so scope-gated Iceberg REST catalogs such as
Snowflake Horizon / Apache Polaris can be configured via supported config.
The engine already form-encodes `scope`; only the user-facing plumbing was
missing, so every layer forced it to `None`.

- api: add fluent `with_oauth2_scope`/`with_oauth2_audience` setters on
  IcebergCreateConfig (mutate the OAuth2 auth config in place) and delegating
  setters on R2rmlCreateConfig. The existing 3-arg `with_auth_oauth2` is
  unchanged for backward compatibility.
- iceberg: omit `client_id` from the token-request form when empty, which
  Snowflake Horizon's `session:role:` exchange requires (a non-empty
  client_id is rejected with `invalid_scope`).
- cli: add `--oauth2-scope`/`--oauth2-audience` to `fluree iceberg map`;
  relax activation so OAuth2 engages on token_url + client_secret with
  client_id defaulting to "" (Horizon/PAT callers omit it).
- server: add `oauth2_scope`/`oauth2_audience` to the iceberg/map request
  with matching activation and build wiring.

Tested with hermetic wiremock token-endpoint tests (scope sent, empty
client_id omitted / present when non-empty, audience sent), builder + serde
round-trip (no migration), and route/CLI deserialize tests.

Refs #1394

@bplatz bplatz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🦭

@aaj3f
aaj3f merged commit 0dd2fac into main Jun 30, 2026
13 checks passed
@aaj3f
aaj3f deleted the fix/iceberg-oauth2-scope branch June 30, 2026 20:59
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.

Iceberg REST catalog: expose OAuth2 scope (+ allow empty client_id) so Snowflake Horizon / Polaris session:role: auth works

2 participants