Skip to content

Commit 463794c

Browse files
Merge origin/main into eric-wang/kernel-azure-oauth
Resolve conflicts from #921 (JWT private-key M2M on use_kernel=True): - CHANGELOG: keep main's JWT M2M entry + the U2M/Azure entries; the Azure entry supersedes main's stale 'azure-oauth not supported' note. - auth_bridge.py docstring/comments: adopt main's resolution-order numbering (JWT M2M = step 1) while keeping azure-oauth as a U2M type in step 4. - Extend the JWT-vs-U2M ambiguity guard to cover azure-oauth (main only guarded databricks-oauth); azure-oauth is now a U2M type, so oauth_jwt_key_file + azure-oauth must be rejected as ambiguous rather than falling through to JWT M2M. Parametrized the guard test over both U2M types. - Final catch-all error message: list JWT M2M alongside the Azure flows.
2 parents b9290eb + b4828fb commit 463794c

17 files changed

Lines changed: 394 additions & 145 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Release History
22

33
# Unreleased
4+
- Kernel backend (`use_kernel=True`): OAuth **M2M with a JWT private-key client assertion** (RFC 7523) is now supported. Pass `oauth_client_id` + `oauth_jwt_key_file` + `oauth_jwt_kid` (with optional `oauth_jwt_passphrase` for an encrypted PKCS#8 key, `oauth_jwt_algorithm` defaulting to `RS256`, `oauth_scopes`, and `token_url` for the IdP token endpoint) and the connector routes them to the kernel's `auth_type="oauth-m2m-jwt"`, which signs a short-lived assertion with the private key instead of sending a client secret. The kernel owns the token lifecycle. A private-key file is treated as unambiguous JWT M2M intent and is mutually exclusive with `oauth_client_secret` / `credentials_provider` (both raise `NotSupportedError`). Verified end-to-end against an Azure Databricks workspace with the service principal's public certificate registered on its Entra ID app registration. Requires `databricks-sql-kernel >= 0.2.0` with JWT support.
45
- Kernel backend (`use_kernel=True`): OAuth U2M with `auth_type="databricks-oauth"` now forwards the connector's `databricks-sql-python` OAuth-app bundle (`client_id` + `sql offline_access` scopes + redirect port) into the kernel, so a bare U2M connection authenticates as `databricks-sql-python` — parity with the Thrift path — instead of inheriting the kernel's own `databricks-sql-connector` default. A caller-supplied `oauth_client_id` (with its coupled `oauth_redirect_port`) is honored, as is a caller-supplied `oauth_scopes`; absent one, the connector default (`sql offline_access`) is forwarded. Note: the kernel binds a single U2M redirect port, so unlike the Thrift path (which tries the full `8020..8024` range) the kernel path uses only one port and does not fall back to the next port if it is already bound — pass `oauth_redirect_port` (with `oauth_client_id`) to pick a free one on a port collision (PECOBLR-4040)
56
- Kernel backend (`use_kernel=True`): **Azure Entra (Azure AD) service-principal M2M is now supported.** `auth_type="azure-sp-m2m"` forwards `azure_client_id` / `azure_client_secret`; the kernel is the Azure-aware auth core — it builds the Entra v2.0 token endpoint and the `{app_id}/.default` scope, and **auto-discovers the tenant** from the workspace's `/aad/auth` redirect when `azure_tenant_id` is omitted (matching Thrift). The `Authorization` bearer is the Databricks-audience data token, which alone authenticates a workspace-member SP. Set `azure_workspace_resource_id` and the kernel also sends the Azure SP management token (`X-Databricks-Azure-SP-Management-Token`) + `X-Databricks-Azure-Workspace-Resource-Id` header (matching the JDBC driver), so a service principal with an Azure RBAC role but no workspace membership can authenticate; omit it and no ARM management-scope token is fetched. Azure AD **U2M** (`auth_type="azure-oauth"`) now routes to the kernel's OAuth U2M flow, identically to `auth_type="databricks-oauth"`: the kernel runs the in-house workspace-federated browser flow, which Azure workspaces support (the workspace federates login to Entra). It forwards the connector's `databricks-sql-python` OAuth app, not the Thrift Azure app (`96eecda7` / port 8030), which is registered for Thrift's direct-Entra flow the kernel does not perform (PECOBLR-4141; PECOBLR-4120)
67

CONNECTION_PARAMETERS.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,11 @@ to change without notice.
7373
| `oauth_client_id` (U2M) | `str` ||| built-in client id | Custom U2M client id. Forwarded on both; when absent, each path applies its own built-in default. |
7474
| `oauth_redirect_port` (U2M) | `int` ||| `None` | Localhost redirect port for the browser flow. On **both** backends it is only honored when a custom `oauth_client_id` is also supplied — then that single port becomes the redirect URI. With the built-in client id (or when omitted) the connector uses the full registered range 8020–8024 and binds the first free port, so a bare `oauth_redirect_port` has no effect. (Thrift: `auth.py` `oauth_redirect_port_range`; Kernel: same logic, forwarded as `redirect_ports`.) |
7575
| `oauth_client_secret` (OAuth M2M) | `str` ||| `None` | **Kernel-only in practice.** The Thrift auth path never reads `oauth_client_secret`; use `credentials_provider` or an Azure service principal for M2M on Thrift. |
76+
| `oauth_jwt_key_file` (OAuth M2M, JWT private key) | `str` ||| `None` | **Kernel-only.** Path to the PEM private key for JWT private-key M2M (RFC 7523 client assertion). Supplying it selects the JWT flow: the kernel signs a short-lived assertion with the key instead of sending a client secret. Requires `oauth_client_id` + `oauth_jwt_kid`; mutually exclusive with `oauth_client_secret` / `credentials_provider`. |
77+
| `oauth_jwt_kid` (OAuth M2M, JWT private key) | `str` ||| `None` | **Kernel-only.** Key id written into the JWT header so the IdP can select the registered public key. Required with `oauth_jwt_key_file`. (For Entra ID this is the certificate's `x5t` thumbprint.) |
78+
| `oauth_jwt_passphrase` (OAuth M2M, JWT private key) | `str` ||| `None` | **Kernel-only.** Passphrase for an encrypted PKCS#8 private key; omit for an unencrypted key. |
79+
| `oauth_jwt_algorithm` (OAuth M2M, JWT private key) | `str` ||| `RS256` | **Kernel-only.** JWT signing algorithm (`RS256`/`384`/`512`, `PS256`/`384`/`512`, `ES256`, `ES384`). |
80+
| `token_url` (OAuth M2M) | `str` ||| `None` ⇒ OIDC discovery | **Kernel-only.** OAuth IdP token endpoint override. Required for JWT M2M against an external-IdP-backed workspace (e.g. Entra ID for Azure Databricks), since Databricks-native OIDC does not advertise the `private_key_jwt` method. Applies to shared-secret M2M too. |
7681
| `oauth_scopes` | `List[str]` ||| `["sql","offline_access"]`| **Thrift ignores custom scopes** — it always uses the built-in scope set. Only the kernel honors a custom `oauth_scopes`. |
7782
| `credentials_provider` | `CredentialsProvider`||| `None` | Custom external credentials provider. **Rejected on the kernel path** (`NotSupportedError`) — it is an opaque token source, so the kernel cannot own the token lifecycle; use `oauth_client_id` + `oauth_client_secret` for M2M, or the Thrift backend. |
7883
| `identity_federation_client_id` | `str` ||| `None` | Workload identity / token-federation client id (kernel support added in #910). |

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ reproduce on a SEA or kernel connection, and vice versa:
153153
| Backend | Select via (connect kwarg / `extra_params`) | Where its tests live |
154154
| --- | --- | --- |
155155
| **Thrift** (default) | *(nothing — the default path)* | the general `tests/e2e` suite (the `{}` parametrize case) and mocked `tests/unit` |
156-
| **SEA** (Statement Execution API) | `use_sea=True` | the general `tests/e2e` suite (the `{"use_sea": True}` parametrize case, e.g. `tests/e2e/test_driver.py`) and mocked `tests/unit` |
156+
| **SEA** (Statement Execution API) *(deprecated — use Kernel for SEA-native connections)* | `use_sea=True` | the general `tests/e2e` suite (the `{"use_sea": True}` parametrize case, e.g. `tests/e2e/test_driver.py`) and mocked `tests/unit` |
157157
| **Kernel** (Rust, optional) | `use_kernel=True` | the dedicated `tests/e2e/test_kernel_backend.py` / `test_kernel_tls.py`, plus the offline routing test `tests/unit/test_session.py -m realkernel` |
158158

159159
Notes that matter when running the suite:

KERNEL_REV

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
45a0d6ae1de2f203220913ba96c994ebb2d7aae4
1+
9e3dbf9c40733b176151e001c9a15202030b967a

examples/experimental/sea_connector_test.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
"""
22
Main script to run all SEA connector tests.
33
4+
DEPRECATED: the pure-Python SEA backend (``use_sea=True``) exercised by
5+
these examples is incomplete (e.g. no positional ``?`` parameter binding)
6+
and slated for removal. For a SEA-native connection use ``use_kernel=True``
7+
instead — install it with ``pip install 'databricks-sql-connector[kernel]'``.
8+
49
This script runs all the individual test modules and displays
510
a summary of test results with visual indicators.
611

osv-scanner.toml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,26 @@
1616
# This file starts empty -- populate iteratively as the first scan run
1717
# surfaces real false positives. Do not pre-populate with speculative
1818
# suppressions.
19+
20+
[[IgnoredVulns]]
21+
id = "GHSA-g6cj-pr64-35w5"
22+
# CVE-2026-69247 / PYSEC-2026-3552 -- a Bleichenbacher-style timing/error
23+
# oracle in cryptography's PKCS#7 *EnvelopedData* decryption
24+
# (pkcs7_decrypt_der/pem/smime), exploitable only against an endpoint that
25+
# auto-decrypts attacker-supplied S/MIME EnvelopedData and responds
26+
# adaptively. Affects cryptography >=44.0.0,<50.0.0; fixed in 50.0.0.
27+
#
28+
# Not reachable here: `cryptography` is a transitive dependency (via
29+
# PyJWT[crypto] / oauthlib, for OAuth token signing/verification). The
30+
# connector never decrypts PKCS#7 / S/MIME EnvelopedData -- the vulnerable
31+
# code path is never invoked. Pre-existing on main (transitive, not
32+
# introduced by any connector change); a clean bump awaits PyJWT/oauthlib
33+
# floors that pull cryptography>=50. Re-evaluate when the dependency tree
34+
# resolves to a fixed cryptography.
35+
36+
[[IgnoredVulns]]
37+
# Same advisory under its PYSEC alias -- OSV reports both ids for this
38+
# finding, so suppress both to be safe (see GHSA-g6cj-pr64-35w5 above for
39+
# the full justification: unreachable PKCS#7/S-MIME decryption oracle in a
40+
# transitive dependency).
41+
id = "PYSEC-2026-3552"

src/databricks/sql/backend/kernel/auth_bridge.py

Lines changed: 99 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -178,13 +178,18 @@ def kernel_auth_kwargs(
178178
179179
(The ``azure-sp-m2m`` Azure Entra auth type is handled up front, before
180180
these guards, forwarding to the kernel's Azure SP flow. ``azure-oauth`` is
181-
a U2M type and is handled by step 3 alongside ``databricks-oauth``. See the
182-
module docstring.)
183-
1. **OAuth M2M** — ``oauth_client_id`` + ``oauth_client_secret``
181+
a U2M type and is handled by the OAuth U2M step alongside
182+
``databricks-oauth``. See the module docstring.)
183+
1. **OAuth M2M (JWT private key)** — ``oauth_jwt_key_file`` present →
184+
forward the private-key + ``oauth_client_id`` + ``oauth_jwt_kid``
185+
to the kernel's ``oauth-m2m-jwt`` (RFC 7523 client assertion). The
186+
kernel signs the assertion and owns the token lifecycle. Checked
187+
first because a private-key file is unambiguous JWT M2M intent.
188+
2. **OAuth M2M** — ``oauth_client_id`` + ``oauth_client_secret``
184189
both present → forward raw creds to the kernel's ``oauth-m2m``.
185-
2. **PAT** — the built provider is (or wraps) an
190+
3. **PAT** — the built provider is (or wraps) an
186191
``AccessTokenAuthProvider`` → extract the bearer token.
187-
3. **OAuth U2M** — ``auth_type`` is ``databricks-oauth`` or ``azure-oauth``
192+
4. **OAuth U2M** — ``auth_type`` is ``databricks-oauth`` or ``azure-oauth``
188193
→ forward the connector's coupled ``databricks-sql-python`` bundle
189194
(``client_id`` + ``redirect_ports`` list, defaulting scopes to
190195
``PYSQL_OAUTH_SCOPES`` when the caller supplies none) to the kernel's
@@ -194,9 +199,9 @@ def kernel_auth_kwargs(
194199
(PECOBLR-4039/4040). ``azure-oauth`` resolves identically: the kernel's
195200
workspace-federated flow serves Azure workspaces too (PECOBLR-4120).
196201
Unlike the Thrift path, a caller-supplied ``oauth_scopes`` is honored here.
197-
4. **Custom credentials_provider** → ``NotSupportedError`` (opaque
202+
5. **Custom credentials_provider** → ``NotSupportedError`` (opaque
198203
token source; no raw creds for the kernel to own).
199-
5. Anything else → ``NotSupportedError``.
204+
6. Anything else → ``NotSupportedError``.
200205
201206
M2M is checked before PAT so that a workload passing both an
202207
access token *and* M2M creds resolves to the (refreshing) M2M path
@@ -211,7 +216,12 @@ def kernel_auth_kwargs(
211216
client_secret = opts.get("oauth_client_secret")
212217
federation_client_id = opts.get("identity_federation_client_id")
213218
auth_type = opts.get("auth_type")
219+
jwt_key_file = opts.get("oauth_jwt_key_file")
214220
has_m2m = bool(client_id and client_secret)
221+
# A private-key file is unambiguous JWT client-assertion M2M intent
222+
# (RFC 7523): the kernel signs a short-lived assertion with the key
223+
# rather than sending a client secret.
224+
has_jwt_m2m = bool(jwt_key_file)
215225

216226
# Azure Entra (Azure AD) auth types route to the kernel's GENERIC OAuth
217227
# flows with Azure values supplied as overrides — the kernel needs no
@@ -288,22 +298,96 @@ def kernel_auth_kwargs(
288298
"(machine-to-machine). Drop oauth_client_secret for U2M, or drop "
289299
"auth_type for M2M."
290300
)
301+
if has_jwt_m2m and client_secret:
302+
raise NotSupportedError(
303+
"Ambiguous auth on use_kernel=True: both oauth_jwt_key_file "
304+
"(JWT private-key M2M) and oauth_client_secret (shared-secret "
305+
"M2M) were provided. Pass exactly one — a private key for "
306+
"JWT client-assertion M2M, or a client secret for shared-secret M2M."
307+
)
308+
if has_jwt_m2m and opts.get("credentials_provider") is not None:
309+
raise NotSupportedError(
310+
"Ambiguous auth on use_kernel=True: both a custom "
311+
"credentials_provider and oauth_jwt_key_file were provided. "
312+
"Pass exactly one — oauth_client_id + oauth_jwt_key_file for "
313+
"kernel-managed JWT private-key M2M, or use the Thrift backend "
314+
"(default) for credentials_provider."
315+
)
316+
if has_jwt_m2m and auth_type in ("databricks-oauth", "azure-oauth"):
317+
raise NotSupportedError(
318+
f"Ambiguous auth on use_kernel=True: auth_type={auth_type!r} selects "
319+
"the U2M browser flow, but oauth_jwt_key_file was also provided "
320+
"(JWT private-key M2M). Drop oauth_jwt_key_file for U2M, or drop "
321+
"auth_type for JWT M2M."
322+
)
291323

292-
# 1. OAuth M2M — raw client-credentials pair forwarded to the kernel.
293-
if has_m2m:
324+
# 1. OAuth M2M (JWT private-key client assertion) — the kernel signs a
325+
# short-lived assertion with the private key and runs the
326+
# client-credentials grant. Checked before shared-secret M2M and PAT
327+
# because a private-key file is unambiguous JWT M2M intent. Requires
328+
# oauth_client_id (the service principal / OAuth client) and
329+
# oauth_jwt_kid (the key id the IdP uses to select the registered
330+
# public key). Optional oauth_jwt_passphrase / oauth_jwt_algorithm /
331+
# oauth_scopes / token_url are forwarded when present; the kernel
332+
# fills defaults (RS256 algorithm, all-apis scope, OIDC discovery)
333+
# for any omitted.
334+
if has_jwt_m2m:
335+
if not client_id:
336+
raise ProgrammingError(
337+
"use_kernel=True JWT private-key M2M (oauth_jwt_key_file) "
338+
"requires oauth_client_id (the service principal / OAuth "
339+
"client id used as the assertion issuer and subject)."
340+
)
341+
jwt_kid = opts.get("oauth_jwt_kid")
342+
if not jwt_kid:
343+
raise ProgrammingError(
344+
"use_kernel=True JWT private-key M2M (oauth_jwt_key_file) "
345+
"requires oauth_jwt_kid (the key id written into the JWT "
346+
"header so the IdP can select the registered public key)."
347+
)
294348
kwargs: Dict[str, Any] = {
349+
"auth_type": "oauth-m2m-jwt",
350+
"client_id": client_id,
351+
"jwt_key_file": jwt_key_file,
352+
"jwt_kid": jwt_kid,
353+
}
354+
jwt_passphrase = opts.get("oauth_jwt_passphrase")
355+
if jwt_passphrase:
356+
kwargs["jwt_passphrase"] = jwt_passphrase
357+
jwt_algorithm = opts.get("oauth_jwt_algorithm")
358+
if jwt_algorithm:
359+
kwargs["jwt_algorithm"] = jwt_algorithm
360+
token_url = opts.get("token_url")
361+
if token_url:
362+
kwargs["token_url"] = token_url
363+
scopes = _normalize_scopes(opts.get("oauth_scopes"))
364+
if scopes is not None:
365+
kwargs["oauth_scopes"] = scopes
366+
if federation_client_id:
367+
kwargs["identity_federation_client_id"] = federation_client_id
368+
return kwargs
369+
370+
# 2. OAuth M2M — raw client-credentials pair forwarded to the kernel.
371+
if has_m2m:
372+
kwargs = {
295373
"auth_type": "oauth-m2m",
296374
"client_id": client_id,
297375
"client_secret": client_secret,
298376
}
299377
scopes = _normalize_scopes(opts.get("oauth_scopes"))
300378
if scopes is not None:
301379
kwargs["oauth_scopes"] = scopes
380+
# token_url is an auth-method-agnostic token-endpoint override (JDBC's
381+
# OAuth2ConnAuthTokenEndpoint applies it to client-secret M2M too), so
382+
# forward it here as well as on the JWT path.
383+
token_url = opts.get("token_url")
384+
if token_url:
385+
kwargs["token_url"] = token_url
302386
if federation_client_id:
303387
kwargs["identity_federation_client_id"] = federation_client_id
304388
return kwargs
305389

306-
# 2. PAT (including TokenFederationProvider-wrapped PAT).
390+
# 3. PAT (including TokenFederationProvider-wrapped PAT).
307391
if _is_pat(auth_provider):
308392
token = _extract_bearer_token(auth_provider)
309393
if not token:
@@ -316,7 +400,7 @@ def kernel_auth_kwargs(
316400
kwargs["identity_federation_client_id"] = federation_client_id
317401
return kwargs
318402

319-
# 3. OAuth U2M — browser authorization-code flow; the kernel runs it.
403+
# 4. OAuth U2M — browser authorization-code flow; the kernel runs it.
320404
# Both databricks-oauth and azure-oauth reach here: on the kernel path
321405
# they are the same in-house workspace-federated flow (Azure workspaces
322406
# federate the browser login to Entra), so azure-oauth is an alias for
@@ -351,7 +435,7 @@ def kernel_auth_kwargs(
351435
kwargs["identity_federation_client_id"] = federation_client_id
352436
return kwargs
353437

354-
# 4. Custom credentials_provider — the connector's primary M2M path
438+
# 5. Custom credentials_provider — the connector's primary M2M path
355439
# on Thrift/SEA, but unusable on the kernel: it's an opaque token
356440
# source with no extractable client_id/secret, so the kernel
357441
# can't own the token lifecycle. Point the caller at the raw
@@ -365,15 +449,16 @@ def kernel_auth_kwargs(
365449
"credentials_provider."
366450
)
367451

368-
# 5. Everything else (including no usable credentials at all —
452+
# 6. Everything else (including no usable credentials at all —
369453
# ``auth_provider`` is None on the kernel path when no access
370454
# token was supplied and no OAuth kwargs resolved above).
371455
provider_desc = (
372456
type(auth_provider).__name__ if auth_provider is not None else "no credentials"
373457
)
374458
raise NotSupportedError(
375459
f"use_kernel=True requires PAT (access_token), OAuth M2M "
376-
f"(oauth_client_id + oauth_client_secret), OAuth U2M "
460+
f"(oauth_client_id + oauth_client_secret), OAuth M2M JWT "
461+
f"(oauth_client_id + oauth_jwt_key_file + oauth_jwt_kid), OAuth U2M "
377462
f"(auth_type='databricks-oauth' or 'azure-oauth'), or Azure SP M2M "
378463
f"(auth_type='azure-sp-m2m'), but got "
379464
f"{provider_desc} with auth_type={auth_type!r}. Use the Thrift "

0 commit comments

Comments
 (0)