Skip to content

Commit 452b426

Browse files
committed
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>
1 parent 0a8f1d2 commit 452b426

4 files changed

Lines changed: 210 additions & 6 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)

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

Lines changed: 77 additions & 6 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,8 +233,69 @@ 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+
252+
# 1. OAuth M2M (JWT private-key client assertion) — the kernel signs a
253+
# short-lived assertion with the private key and runs the
254+
# client-credentials grant. Checked before shared-secret M2M and PAT
255+
# because a private-key file is unambiguous JWT M2M intent. Requires
256+
# oauth_client_id (the service principal / OAuth client) and
257+
# oauth_jwt_kid (the key id the IdP uses to select the registered
258+
# public key). Optional oauth_jwt_passphrase / oauth_jwt_algorithm /
259+
# oauth_scopes / token_url are forwarded when present; the kernel
260+
# fills defaults (RS256 algorithm, all-apis scope, OIDC discovery)
261+
# for any omitted.
262+
if has_jwt_m2m:
263+
if not client_id:
264+
raise ProgrammingError(
265+
"use_kernel=True JWT private-key M2M (oauth_jwt_key_file) "
266+
"requires oauth_client_id (the service principal / OAuth "
267+
"client id used as the assertion issuer and subject)."
268+
)
269+
jwt_kid = opts.get("oauth_jwt_kid")
270+
if not jwt_kid:
271+
raise ProgrammingError(
272+
"use_kernel=True JWT private-key M2M (oauth_jwt_key_file) "
273+
"requires oauth_jwt_kid (the key id written into the JWT "
274+
"header so the IdP can select the registered public key)."
275+
)
276+
kwargs = {
277+
"auth_type": "oauth-m2m-jwt",
278+
"client_id": client_id,
279+
"jwt_key_file": jwt_key_file,
280+
"jwt_kid": jwt_kid,
281+
}
282+
jwt_passphrase = opts.get("oauth_jwt_passphrase")
283+
if jwt_passphrase:
284+
kwargs["jwt_passphrase"] = jwt_passphrase
285+
jwt_algorithm = opts.get("oauth_jwt_algorithm")
286+
if jwt_algorithm:
287+
kwargs["jwt_algorithm"] = jwt_algorithm
288+
token_url = opts.get("token_url")
289+
if token_url:
290+
kwargs["token_url"] = token_url
291+
scopes = _normalize_scopes(opts.get("oauth_scopes"))
292+
if scopes is not None:
293+
kwargs["oauth_scopes"] = scopes
294+
if federation_client_id:
295+
kwargs["identity_federation_client_id"] = federation_client_id
296+
return kwargs
226297

227-
# 1. OAuth M2M — raw client-credentials pair forwarded to the kernel.
298+
# 2. OAuth M2M — raw client-credentials pair forwarded to the kernel.
228299
if has_m2m:
229300
kwargs: Dict[str, Any] = {
230301
"auth_type": "oauth-m2m",

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"

tests/unit/test_kernel_auth_bridge.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,129 @@ def test_client_id_without_secret_does_not_trigger_m2m(self):
245245
assert kwargs == {"auth_type": "pat", "access_token": "dapi-xyz"}
246246

247247

248+
class TestKernelOAuthM2MJwt:
249+
"""JWT private-key M2M (RFC 7523 client assertion) → the kernel's
250+
``oauth-m2m-jwt``. Driven by ``oauth_jwt_key_file`` (unambiguous
251+
private-key intent); requires ``oauth_client_id`` + ``oauth_jwt_kid``."""
252+
253+
def test_full_kwargs_route_to_oauth_m2m_jwt(self):
254+
kwargs = kernel_auth_kwargs(
255+
_FakeOAuthProvider(),
256+
{
257+
"oauth_client_id": "sp-uuid",
258+
"oauth_jwt_key_file": "/keys/jwt.pem",
259+
"oauth_jwt_kid": "kid-1",
260+
"oauth_jwt_passphrase": "pw",
261+
"oauth_jwt_algorithm": "ES256",
262+
"token_url": "https://login.microsoftonline.com/t/oauth2/v2.0/token",
263+
"oauth_scopes": ["2ff814a6-.../.default"],
264+
},
265+
)
266+
assert kwargs == {
267+
"auth_type": "oauth-m2m-jwt",
268+
"client_id": "sp-uuid",
269+
"jwt_key_file": "/keys/jwt.pem",
270+
"jwt_kid": "kid-1",
271+
"jwt_passphrase": "pw",
272+
"jwt_algorithm": "ES256",
273+
"token_url": "https://login.microsoftonline.com/t/oauth2/v2.0/token",
274+
"oauth_scopes": ["2ff814a6-.../.default"],
275+
}
276+
277+
def test_minimal_kwargs_omit_optionals(self):
278+
# Only the three required fields; the kernel fills the rest
279+
# (RS256 algorithm, all-apis scope, OIDC discovery).
280+
kwargs = kernel_auth_kwargs(
281+
_FakeOAuthProvider(),
282+
{
283+
"oauth_client_id": "sp-uuid",
284+
"oauth_jwt_key_file": "/keys/jwt.pem",
285+
"oauth_jwt_kid": "kid-1",
286+
},
287+
)
288+
assert kwargs == {
289+
"auth_type": "oauth-m2m-jwt",
290+
"client_id": "sp-uuid",
291+
"jwt_key_file": "/keys/jwt.pem",
292+
"jwt_kid": "kid-1",
293+
}
294+
295+
def test_normalizes_space_delimited_scopes(self):
296+
kwargs = kernel_auth_kwargs(
297+
_FakeOAuthProvider(),
298+
{
299+
"oauth_client_id": "sp",
300+
"oauth_jwt_key_file": "/k.pem",
301+
"oauth_jwt_kid": "k",
302+
"oauth_scopes": "all-apis sql",
303+
},
304+
)
305+
assert kwargs["oauth_scopes"] == ["all-apis", "sql"]
306+
307+
def test_takes_precedence_over_pat(self):
308+
# A private key alongside an ambient PAT resolves to the
309+
# (refreshing) JWT M2M path, not the static token.
310+
kwargs = kernel_auth_kwargs(
311+
AccessTokenAuthProvider("dapi-xyz"),
312+
{
313+
"oauth_client_id": "sp",
314+
"oauth_jwt_key_file": "/k.pem",
315+
"oauth_jwt_kid": "k",
316+
},
317+
)
318+
assert kwargs["auth_type"] == "oauth-m2m-jwt"
319+
320+
def test_missing_client_id_raises_programming_error(self):
321+
with pytest.raises(ProgrammingError, match="oauth_client_id"):
322+
kernel_auth_kwargs(
323+
None,
324+
{"oauth_jwt_key_file": "/k.pem", "oauth_jwt_kid": "k"},
325+
)
326+
327+
def test_missing_kid_raises_programming_error(self):
328+
with pytest.raises(ProgrammingError, match="oauth_jwt_kid"):
329+
kernel_auth_kwargs(
330+
None,
331+
{"oauth_client_id": "sp", "oauth_jwt_key_file": "/k.pem"},
332+
)
333+
334+
def test_jwt_plus_client_secret_is_rejected(self):
335+
with pytest.raises(NotSupportedError, match="oauth_client_secret"):
336+
kernel_auth_kwargs(
337+
None,
338+
{
339+
"oauth_client_id": "sp",
340+
"oauth_jwt_key_file": "/k.pem",
341+
"oauth_jwt_kid": "k",
342+
"oauth_client_secret": "shh",
343+
},
344+
)
345+
346+
def test_jwt_plus_credentials_provider_is_rejected(self):
347+
with pytest.raises(NotSupportedError, match="credentials_provider"):
348+
kernel_auth_kwargs(
349+
None,
350+
{
351+
"oauth_client_id": "sp",
352+
"oauth_jwt_key_file": "/k.pem",
353+
"oauth_jwt_kid": "k",
354+
"credentials_provider": object(),
355+
},
356+
)
357+
358+
def test_federation_client_id_forwarded(self):
359+
kwargs = kernel_auth_kwargs(
360+
_FakeOAuthProvider(),
361+
{
362+
"oauth_client_id": "sp",
363+
"oauth_jwt_key_file": "/k.pem",
364+
"oauth_jwt_kid": "k",
365+
"identity_federation_client_id": "fed",
366+
},
367+
)
368+
assert kwargs["identity_federation_client_id"] == "fed"
369+
370+
248371
class TestKernelOAuthU2M:
249372
"""Only ``databricks-oauth`` U2M is supported on the kernel path.
250373

0 commit comments

Comments
 (0)