Skip to content

Commit b4828fb

Browse files
authored
feat(kernel): JWT private-key M2M auth on use_kernel=True (#921)
* feat(kernel): support JWT private-key M2M auth on use_kernel=True Route JWT private-key client-assertion auth (RFC 7523) through the kernel backend. When the caller passes `oauth_jwt_key_file` (+ `oauth_client_id` and `oauth_jwt_kid`, optional `oauth_jwt_passphrase` / `oauth_jwt_algorithm` / `oauth_scopes` / `token_url`), the bridge forwards 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 and owns the token lifecycle. - auth_bridge.py: new JWT branch (checked before shared-secret M2M and PAT, since a private-key file is unambiguous JWT M2M intent); mutually exclusive with oauth_client_secret / credentials_provider; requires client_id + kid. - session.py: forward the new oauth_jwt_* / token_url kwargs into the kernel auth options. - tests: 9 unit tests covering routing, precedence, validation, and ambiguity guards. Verified end-to-end: `SELECT 1` via use_kernel=True against an Azure Databricks warehouse, authenticated by Entra ID against the service principal's registered public certificate. Signed-off-by: Rahul Singhal <rahul.singhal@databricks.com> * fix(kernel): annotate kwargs at first assignment to satisfy mypy The JWT branch introduced an earlier untyped `kwargs =`, so mypy flagged the M2M branch's `kwargs: Dict[str, Any]` as a redefinition. Move the annotation to the first (JWT) assignment. Signed-off-by: Rahul Singhal <rahul.singhal@databricks.com> * fix(kernel): guard JWT M2M + databricks-oauth auth_type; renumber comments Address peco-review-bot review on #921: - Medium: add the missing ambiguity guard for oauth_jwt_key_file + auth_type="databricks-oauth" (U2M intent), mirroring the existing shared-secret M2M + U2M guard. Fails loudly rather than silently resolving to one flow. Covered by a new unit test. - Low: renumber the inline resolution-order comments (PAT→3, U2M→4, creds→5, else→6) to match the docstring after the JWT branch insert. Signed-off-by: Rahul Singhal <rahul.singhal@databricks.com> * docs: document JWT private-key M2M params in CONNECTION_PARAMETERS.md Address Eric's review on #921: add rows for oauth_jwt_key_file / oauth_jwt_kid / oauth_jwt_passphrase / oauth_jwt_algorithm / token_url to the connection-parameter reference (all kernel-only). Signed-off-by: Rahul Singhal <rahul.singhal@databricks.com> * security: suppress unreachable cryptography PKCS#7 advisory (osv-scanner) Security Scan flagged cryptography@49.0.0 CVE-2026-69247 (GHSA-g6cj-pr64-35w5 / PYSEC-2026-3552, CVSS 8.2) — a Bleichenbacher-style oracle in PKCS#7 EnvelopedData / S-MIME decryption. `cryptography` is a transitive dep (PyJWT[crypto] / oauthlib) used only for OAuth token signing/verification; the connector never decrypts PKCS#7 / S-MIME, so the vulnerable path is never invoked. Pre-existing on main (transitive, not introduced by this PR). Suppress both OSV ids with justification; a clean bump awaits PyJWT/oauthlib floors pulling cryptography>=50. Signed-off-by: Rahul Singhal <rahul.singhal@databricks.com> * fix(kernel): forward token_url on shared-secret M2M too (JDBC parity) Address peco-review-bot: the CONNECTION_PARAMETERS.md row said token_url "applies to shared-secret M2M too", but the bridge only forwarded it on the JWT branch — so an oauth_client_id + oauth_client_secret + token_url connection silently dropped token_url. token_url is an auth-method-agnostic token-endpoint override: JDBC's OAuth2ConnAuthTokenEndpoint is consumed by the client-secret M2M provider, the JWT provider, and the refresh provider alike; the Node driver also forwards it on both branches; and the kernel's oauth-m2m auth_type accepts it (pyo3). Forward it on the shared-secret M2M branch so code matches the doc and the other drivers. Adds a unit test. Signed-off-by: Rahul Singhal <rahul.singhal@databricks.com> --------- Signed-off-by: Rahul Singhal <rahul.singhal@databricks.com>
1 parent a68464c commit b4828fb

6 files changed

Lines changed: 283 additions & 11 deletions

File tree

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. `auth_type="azure-oauth"` (Azure AD) is not yet supported on the kernel path and raises `NotSupportedError` — use the Thrift backend for it (PECOBLR-4040; Azure tracked by PECOBLR-4120)
56

67
# 4.4.0 (2026-07-22)

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). |

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: 95 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -156,11 +156,16 @@ def kernel_auth_kwargs(
156156
157157
(``azure-oauth`` is rejected as unsupported before these guards —
158158
PECOBLR-4120.)
159-
1. **OAuth M2M** — ``oauth_client_id`` + ``oauth_client_secret``
159+
1. **OAuth M2M (JWT private key)** — ``oauth_jwt_key_file`` present →
160+
forward the private-key + ``oauth_client_id`` + ``oauth_jwt_kid``
161+
to the kernel's ``oauth-m2m-jwt`` (RFC 7523 client assertion). The
162+
kernel signs the assertion and owns the token lifecycle. Checked
163+
first because a private-key file is unambiguous JWT M2M intent.
164+
2. **OAuth M2M** — ``oauth_client_id`` + ``oauth_client_secret``
160165
both present → forward raw creds to the kernel's ``oauth-m2m``.
161-
2. **PAT** — the built provider is (or wraps) an
166+
3. **PAT** — the built provider is (or wraps) an
162167
``AccessTokenAuthProvider`` → extract the bearer token.
163-
3. **OAuth U2M** — ``auth_type`` is ``databricks-oauth`` → forward the
168+
4. **OAuth U2M** — ``auth_type`` is ``databricks-oauth`` → forward the
164169
connector's coupled ``databricks-sql-python`` bundle (``client_id``
165170
+ ``redirect_ports`` list, defaulting scopes to ``PYSQL_OAUTH_SCOPES``
166171
when the caller supplies none) to the kernel's ``oauth-u2m``, so a
@@ -169,9 +174,9 @@ def kernel_auth_kwargs(
169174
``databricks-sql-connector`` default (PECOBLR-4039/4040). Unlike the
170175
Thrift path, a caller-supplied ``oauth_scopes`` is honored here.
171176
``azure-oauth`` is rejected as unsupported (PECOBLR-4120).
172-
4. **Custom credentials_provider** → ``NotSupportedError`` (opaque
177+
5. **Custom credentials_provider** → ``NotSupportedError`` (opaque
173178
token source; no raw creds for the kernel to own).
174-
5. Anything else → ``NotSupportedError``.
179+
6. Anything else → ``NotSupportedError``.
175180
176181
M2M is checked before PAT so that a workload passing both an
177182
access token *and* M2M creds resolves to the (refreshing) M2M path
@@ -186,7 +191,12 @@ def kernel_auth_kwargs(
186191
client_secret = opts.get("oauth_client_secret")
187192
federation_client_id = opts.get("identity_federation_client_id")
188193
auth_type = opts.get("auth_type")
194+
jwt_key_file = opts.get("oauth_jwt_key_file")
189195
has_m2m = bool(client_id and client_secret)
196+
# A private-key file is unambiguous JWT client-assertion M2M intent
197+
# (RFC 7523): the kernel signs a short-lived assertion with the key
198+
# rather than sending a client secret.
199+
has_jwt_m2m = bool(jwt_key_file)
190200

191201
# azure-oauth (Azure AD U2M) is not yet supported on the kernel path.
192202
# Reject it up front — before any M2M/U2M routing — so ANY azure-oauth
@@ -223,22 +233,96 @@ def kernel_auth_kwargs(
223233
"(machine-to-machine). Drop oauth_client_secret for U2M, or drop "
224234
"auth_type for M2M."
225235
)
236+
if has_jwt_m2m and client_secret:
237+
raise NotSupportedError(
238+
"Ambiguous auth on use_kernel=True: both oauth_jwt_key_file "
239+
"(JWT private-key M2M) and oauth_client_secret (shared-secret "
240+
"M2M) were provided. Pass exactly one — a private key for "
241+
"JWT client-assertion M2M, or a client secret for shared-secret M2M."
242+
)
243+
if has_jwt_m2m and opts.get("credentials_provider") is not None:
244+
raise NotSupportedError(
245+
"Ambiguous auth on use_kernel=True: both a custom "
246+
"credentials_provider and oauth_jwt_key_file were provided. "
247+
"Pass exactly one — oauth_client_id + oauth_jwt_key_file for "
248+
"kernel-managed JWT private-key M2M, or use the Thrift backend "
249+
"(default) for credentials_provider."
250+
)
251+
if has_jwt_m2m and auth_type == "databricks-oauth":
252+
raise NotSupportedError(
253+
f"Ambiguous auth on use_kernel=True: auth_type={auth_type!r} selects "
254+
"the U2M browser flow, but oauth_jwt_key_file was also provided "
255+
"(JWT private-key M2M). Drop oauth_jwt_key_file for U2M, or drop "
256+
"auth_type for JWT M2M."
257+
)
226258

227-
# 1. OAuth M2M — raw client-credentials pair forwarded to the kernel.
228-
if has_m2m:
259+
# 1. OAuth M2M (JWT private-key client assertion) — the kernel signs a
260+
# short-lived assertion with the private key and runs the
261+
# client-credentials grant. Checked before shared-secret M2M and PAT
262+
# because a private-key file is unambiguous JWT M2M intent. Requires
263+
# oauth_client_id (the service principal / OAuth client) and
264+
# oauth_jwt_kid (the key id the IdP uses to select the registered
265+
# public key). Optional oauth_jwt_passphrase / oauth_jwt_algorithm /
266+
# oauth_scopes / token_url are forwarded when present; the kernel
267+
# fills defaults (RS256 algorithm, all-apis scope, OIDC discovery)
268+
# for any omitted.
269+
if has_jwt_m2m:
270+
if not client_id:
271+
raise ProgrammingError(
272+
"use_kernel=True JWT private-key M2M (oauth_jwt_key_file) "
273+
"requires oauth_client_id (the service principal / OAuth "
274+
"client id used as the assertion issuer and subject)."
275+
)
276+
jwt_kid = opts.get("oauth_jwt_kid")
277+
if not jwt_kid:
278+
raise ProgrammingError(
279+
"use_kernel=True JWT private-key M2M (oauth_jwt_key_file) "
280+
"requires oauth_jwt_kid (the key id written into the JWT "
281+
"header so the IdP can select the registered public key)."
282+
)
229283
kwargs: Dict[str, Any] = {
284+
"auth_type": "oauth-m2m-jwt",
285+
"client_id": client_id,
286+
"jwt_key_file": jwt_key_file,
287+
"jwt_kid": jwt_kid,
288+
}
289+
jwt_passphrase = opts.get("oauth_jwt_passphrase")
290+
if jwt_passphrase:
291+
kwargs["jwt_passphrase"] = jwt_passphrase
292+
jwt_algorithm = opts.get("oauth_jwt_algorithm")
293+
if jwt_algorithm:
294+
kwargs["jwt_algorithm"] = jwt_algorithm
295+
token_url = opts.get("token_url")
296+
if token_url:
297+
kwargs["token_url"] = token_url
298+
scopes = _normalize_scopes(opts.get("oauth_scopes"))
299+
if scopes is not None:
300+
kwargs["oauth_scopes"] = scopes
301+
if federation_client_id:
302+
kwargs["identity_federation_client_id"] = federation_client_id
303+
return kwargs
304+
305+
# 2. OAuth M2M — raw client-credentials pair forwarded to the kernel.
306+
if has_m2m:
307+
kwargs = {
230308
"auth_type": "oauth-m2m",
231309
"client_id": client_id,
232310
"client_secret": client_secret,
233311
}
234312
scopes = _normalize_scopes(opts.get("oauth_scopes"))
235313
if scopes is not None:
236314
kwargs["oauth_scopes"] = scopes
315+
# token_url is an auth-method-agnostic token-endpoint override (JDBC's
316+
# OAuth2ConnAuthTokenEndpoint applies it to client-secret M2M too), so
317+
# forward it here as well as on the JWT path.
318+
token_url = opts.get("token_url")
319+
if token_url:
320+
kwargs["token_url"] = token_url
237321
if federation_client_id:
238322
kwargs["identity_federation_client_id"] = federation_client_id
239323
return kwargs
240324

241-
# 2. PAT (including TokenFederationProvider-wrapped PAT).
325+
# 3. PAT (including TokenFederationProvider-wrapped PAT).
242326
if _is_pat(auth_provider):
243327
token = _extract_bearer_token(auth_provider)
244328
if not token:
@@ -251,7 +335,7 @@ def kernel_auth_kwargs(
251335
kwargs["identity_federation_client_id"] = federation_client_id
252336
return kwargs
253337

254-
# 3. OAuth U2M — browser authorization-code flow; the kernel runs it.
338+
# 4. OAuth U2M — browser authorization-code flow; the kernel runs it.
255339
# Only databricks-oauth reaches here (azure-oauth rejected up front).
256340
# Forward the connector's own databricks-sql-python bundle instead of
257341
# the kernel's databricks-sql-connector default, for parity with the
@@ -283,7 +367,7 @@ def kernel_auth_kwargs(
283367
kwargs["identity_federation_client_id"] = federation_client_id
284368
return kwargs
285369

286-
# 4. Custom credentials_provider — the connector's primary M2M path
370+
# 5. Custom credentials_provider — the connector's primary M2M path
287371
# on Thrift/SEA, but unusable on the kernel: it's an opaque token
288372
# source with no extractable client_id/secret, so the kernel
289373
# can't own the token lifecycle. Point the caller at the raw
@@ -297,7 +381,7 @@ def kernel_auth_kwargs(
297381
"credentials_provider."
298382
)
299383

300-
# 5. Everything else (including no usable credentials at all —
384+
# 6. Everything else (including no usable credentials at all —
301385
# ``auth_provider`` is None on the kernel path when no access
302386
# token was supplied and no OAuth kwargs resolved above).
303387
provider_desc = (

src/databricks/sql/session.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,15 @@ def _create_backend(
173173
"oauth_client_secret": kwargs.get("oauth_client_secret"),
174174
"oauth_redirect_port": kwargs.get("oauth_redirect_port"),
175175
"oauth_scopes": kwargs.get("oauth_scopes"),
176+
# JWT private-key M2M (RFC 7523 client assertion): the kernel
177+
# signs a short-lived assertion with the private key instead
178+
# of sending a client secret. token_url points the assertion
179+
# at the workspace's OAuth IdP token endpoint (e.g. Entra ID).
180+
"oauth_jwt_key_file": kwargs.get("oauth_jwt_key_file"),
181+
"oauth_jwt_kid": kwargs.get("oauth_jwt_kid"),
182+
"oauth_jwt_passphrase": kwargs.get("oauth_jwt_passphrase"),
183+
"oauth_jwt_algorithm": kwargs.get("oauth_jwt_algorithm"),
184+
"token_url": kwargs.get("token_url"),
176185
"credentials_provider": kwargs.get("credentials_provider"),
177186
"identity_federation_client_id": kwargs.get(
178187
"identity_federation_client_id"

0 commit comments

Comments
 (0)