Skip to content

feat(spark): add OIDC federation token provider to the UC connector - #3

Draft
parisni wants to merge 6 commits into
lbcfrom
feat-uc-file-oidc-auth
Draft

feat(spark): add OIDC federation token provider to the UC connector#3
parisni wants to merge 6 commits into
lbcfrom
feat-uc-file-oidc-auth

Conversation

@parisni

@parisni parisni commented Sep 4, 2026

Copy link
Copy Markdown

Why

The 0.2.x Spark connector could only authenticate with a static token or an OAuth
client-credentials secret. Both are password-equivalents that must be stored and rotated. A
Kubernetes workload can instead prove its identity with the service account token the kubelet
already projects for it, so nothing secret is stored on either side.

What

OIDC federation provider

  • New FileOidcUCTokenProvider: RFC 8693 token exchange over the JDK HTTP client, consistent with
    the 0.2.x backport constraints (no RetryingApiClient/Clock available here). Token cached and
    renewed 30s before expiry, thread-safe via double-checked locking, like OAuthUCTokenProvider.
  • The subject token is re-read on every renewal rather than cached at initialization: the kubelet
    rotates the projected file and a Spark session outlives a single token.
  • No Authorization header on the exchange — the subject token is the proof, there is no secret.
  • New options oidc.uri, oidc.clientId, oidc.tokenFilePath.

Alignment with the 0.3.x option formalism

  • UCTokenProvider gains initialize(Map) and create dispatches on a type key (static,
    oauth, oidc, or a fully qualified custom provider class name), mirroring 0.3.x including the
    custom-class escape hatch.
  • AuthConfigs holds the key constants, named exactly as upstream, so a future rebase is a package
    move rather than a rename.
  • AuthConfigUtils is backported to normalize catalog options, strip the auth. prefix and reject a
    key configured twice. Extended beyond upstream to infer a type from the un-prefixed keys this
    connector already shipped, so existing configurations keep working untouched.
  • UCSingleCatalog delegates to AuthConfigUtils and keys off the presence of type.

Renewable S3 vended credentials

S3 was the one cloud whose vended credentials landed in the plan as literal
fs.s3a.access.key/secret.key/session.token. They last about an hour, so a stage outliving them
failed on S3 rather than on anything resembling an auth problem. Azure and GCS already went through a
token provider.

S3VendedCredentialsProvider is an S3A credentials provider that re-requests credentials from UC
before they expire, keyed by table scope. It is installed on the executors too, which is why the UC
coordinates and the auth configuration travel in the Hadoop configuration: the driver's provider
instance does not cross the plan, so each side re-authenticates on its own. With oidc, that means
the projected token has to be mounted on the executors as well.

The cache is static, because generateCredentialProps sets fs.s3a.impl.disable.cache=true: Spark
rebuilds the FileSystem, and the provider with it, on every resolution, so an instance cache would
never be reused.

This is inactive while skipCredentialVending=true, which is the current setting. It is here so
that turning vending on later is a configuration change rather than a code change.

What is deliberately not here

Upstream 0.3.x ships a connectors/hadoop module for this, whose GenericCredentialProvider is
generic over four clouds and five credential scopes, and which rebuilds providers through
TokenProvider.configs(). Not backported:

  • it needs client internals absent from 0.2.x (client.internal.Clock, ApiClientUtils,
    RetryingApiClient, client.delta.api.*, client.auth.TokenProvider);
  • moving the connector to 0.3.x instead is not an option on our side: it builds for Spark 4.0+ and
    Scala 2.13 only, while this distribution is Spark 3.5.8 / Scala 2.12.

So the provider here collapses that hierarchy into one table-scoped, S3-only class. There is no
configs() accessor either: it is only needed to rebuild a provider generically, which this
single-purpose provider does directly from the Hadoop configuration.

Compatibility

Existing modes are untouched and keep precedence: an explicit type wins, otherwise token infers
static and the oauth.* keys infer oauth. A blank value counts as unset, so an unauthenticated
metastore stays unauthenticated. No current catalog configuration has to change.

Tests

60 tests passing: 31 unit + the 29 TableReadWriteTest integration tests.

  • FileOidcUCTokenProviderTest (6): request shape, caching, re-read + re-exchange after expiry,
    non-200 propagation, missing and empty token file.
  • UCTokenProviderTest (8): dispatch per type, custom class instantiation and its failure, missing
    type, incomplete oauth.* / oidc.*.
  • AuthConfigUtilsTest (12): prefix stripping, type inference for the three legacy shapes, explicit
    type winning, double-configuration rejection, blank values, case insensitivity.
  • S3VendedCredentialsProviderTest (5): bootstrap credentials served without calling UC, absent
    expiry treated as non-expiring, renewal triggered inside the lead time, cache shared across
    provider instances of one scope, scopes isolated from each other.
build/sbt "spark/testOnly io.unitycatalog.spark.*"             # 60 tests, 0 failures
build/sbt "spark/checkstyle" "spark/Test/checkstyle"

Note: this project's build.sbt asserts JDK 17+.

Notes for the reviewer

  • Review by commit rather than squashed: commit 3 walks back a configs() accessor that had no
    caller, and commit 4 fixes a regression commit 2 introduced. The history carries the reasoning.
  • Neither the OIDC exchange nor the credential renewal has been driven against a live UC endpoint;
    both are covered with mocks. The renewal path in particular is exercised only up to the point where
    it would call UC.
  • build.sbt gains software.amazon.awssdk:auth as Provided, alongside the existing GCS and ABFS
    SPI dependencies. hadoop-aws supplies it at runtime.
  • I could not identify a job that needs oidc through UCSingleCatalog today: the LBCTRANSDA-567
    consumers all go through the Databricks SDK, not this connector.

The connector could only authenticate with a static token or an OAuth
client-credentials secret. Both are password-equivalents that have to be
stored and rotated; a Kubernetes workload can instead prove its identity with
the service account token the kubelet already projects for it.

Adds a third provider performing the RFC 8693 token exchange over the JDK
HTTP client, consistent with the 0.2.x backport constraints. The subject token
is re-read on every renewal rather than cached at construction time, since the
kubelet rotates the projected file and a Spark session outlives it.

Follows the existing flat option formalism (`oidc.*` alongside `oauth.*`)
rather than upstream 0.3.x `auth.type`, to keep this a contained addition.
Existing modes keep working and win on precedence.
The providers were selected by probing which option keys were present, and only
exposed accessToken(). That shape cannot survive a serialization boundary, which
0.3.x needs: its executors rebuild a provider from the Hadoop configuration to
renew vended credentials off the driver, via TokenProvider.configs() and a
type-keyed dispatch.

Adopts that contract instead of the flat key probing: initialize(configs),
configs(), and dispatch on `type` with support for a custom provider class name.
AuthConfigUtils normalizes the catalog options, backported from 0.3.x and
extended to infer a type from the un-prefixed keys this connector already
shipped, so existing catalog configurations keep working untouched.

The OIDC provider gains a configs() that carries only the token file path, never
a secret, so the map is safe to propagate. The file is still read wherever the
provider is rebuilt, keeping the kubelet rotation behaviour.
configs() had no caller outside its own tests. It exists in 0.3.x so an executor
can rebuild a provider from the Hadoop configuration and renew UC-vended storage
credentials, which needs the connectors/hadoop module this connector does not
have. Backporting that module was considered and dropped: it depends on client
internals absent from 0.2.x, and the jobs run with skipCredentialVending, so
nothing is vended and the executors reach S3 through the ambient IAM chain.

Everything the connector authenticates stays on the driver, since UCSingleCatalog
is a TableCatalog resolved at planning time.

The type-based dispatch and AuthConfigUtils are kept: UCSingleCatalog uses them,
and they bring the auth.* keys plus the custom-provider escape hatch.
The type-based dispatch inferred an auth type from the mere presence of a key,
so a declared-but-blank credential selected a provider that then rejected it.
Spark hands over the keys a session declared even when their value is empty,
which is how an unauthenticated local metastore is configured, and the whole
TableReadWriteTest suite broke on "Configuration key 'token' is missing or
empty".

The probing this replaced tested the value, not the key, so the regression came
in with the dispatch. Blank now counts as unset everywhere: the auth.* copy, the
legacy token key and the legacy group inference.
S3 was the one cloud whose vended credentials went into the plan as literal
fs.s3a.access.key/secret.key/session.token. They last about an hour, so any
stage still running past that point failed on S3 rather than on anything that
looked like an auth problem. Azure and GCS already went through a token
provider; only S3 held raw keys.

Adds an S3A credentials provider that re-requests credentials from Unity Catalog
before they expire, keyed by the table scope. Installed on the executors too,
which is why the UC coordinates and the auth configuration travel in the Hadoop
configuration: the driver's provider instance does not cross the plan, so each
side re-authenticates on its own.

The cache is static because generateCredentialProps sets
fs.s3a.impl.disable.cache=true: Spark rebuilds the FileSystem, and the provider
with it, on every resolution, so an instance-level cache would never be reused.

Collapses the 0.3.x AwsVendedTokenProvider and its GenericCredentialProvider base
into one class: that split exists to share a cache across four clouds and five
credential scopes, none of which applies to a table-scoped S3-only path. The
0.3.x module itself cannot be backported, as it needs client internals absent
from 0.2.x.

Unused while skipCredentialVending stays true, which is the current setting: it
makes turning vending on a configuration change rather than a code change.
The javadoc tool crashes on the generated sources with a ClientCodeException
wrapping "StringIndexOutOfBoundsException: begin 6470, end 7150, length 0", which
failed client/publishM2 and left the spark-leboncoin image build without a client
jar to copy. It reproduced on amd64 while arm64 built the same sources, so the
javadoc pass is the fragile part, not the code.

The jar consumed by the connector needs no javadoc, so drop the artifact rather
than working around a JDK bug on generated code we do not own.
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.

1 participant