From e53fb37ccb8b85661d9ecf983a459a06562acd24 Mon Sep 17 00:00:00 2001 From: eric-wang-1990 Date: Mon, 17 Aug 2026 15:19:56 -0700 Subject: [PATCH 01/10] fix(kernel): forward full OAuth U2M app bundle into kernel (PECOBLR-4040) On the use_kernel path, OAuth U2M forwarded only whatever the caller explicitly set, sending a bare oauth-u2m otherwise. Since PECOBLR-4039 changed the kernel core default U2M app to databricks-sql-connector / sql offline_access / port 8030, a bare U2M connection authenticated as the wrong identity. The connector is an OVERRIDE of the kernel default, so it now forwards its full coupled bundle (client_id + oauth_scopes + redirect_port). Each field falls back to the connector's registered databricks-sql-python (or azure) default from the existing PYSQL_OAUTH_* constants, giving parity with the Thrift path. Explicit caller overrides and identity_federation_client_id forwarding are preserved. Co-authored-by: Isaac Signed-off-by: eric-wang-1990 --- CHANGELOG.md | 3 + .../sql/backend/kernel/auth_bridge.py | 66 ++++++++++++-- tests/unit/test_kernel_auth_bridge.py | 85 +++++++++++++++++-- 3 files changed, 137 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d7ad1a49..f55d211ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Release History +# Unreleased +- Kernel backend (`use_kernel=True`): OAuth U2M now forwards the connector's full OAuth-app bundle (`client_id` + scopes + redirect port) into the kernel, so a bare U2M connection authenticates as `databricks-sql-python` with `sql offline_access` — parity with the Thrift path — instead of inheriting the kernel's own `databricks-sql-connector` default. Caller-supplied `oauth_client_id` / `oauth_scopes` / `oauth_redirect_port` are still honored (PECOBLR-4040) + # 4.4.0 (2026-07-22) - Raised the minimum supported Python version to 3.10, dropping the end-of-life 3.8/3.9, to update the lockfile and clear CVE-flagged dependencies in the repo (databricks/databricks-sql-python#798) - Fix: `REMOVE` staging operations no longer require `staging_allowed_local_path` to be set, since removing a remote file does not touch the local filesystem (databricks/databricks-sql-python#726) diff --git a/src/databricks/sql/backend/kernel/auth_bridge.py b/src/databricks/sql/backend/kernel/auth_bridge.py index 7402d84cf..a149abe7b 100644 --- a/src/databricks/sql/backend/kernel/auth_bridge.py +++ b/src/databricks/sql/backend/kernel/auth_bridge.py @@ -48,6 +48,13 @@ import re from typing import Any, Dict, Optional +from databricks.sql.auth.auth import ( + PYSQL_OAUTH_AZURE_CLIENT_ID, + PYSQL_OAUTH_AZURE_REDIRECT_PORT_RANGE, + PYSQL_OAUTH_CLIENT_ID, + PYSQL_OAUTH_REDIRECT_PORT_RANGE, + PYSQL_OAUTH_SCOPES, +) from databricks.sql.auth.authenticators import AccessTokenAuthProvider, AuthProvider from databricks.sql.auth.token_federation import TokenFederationProvider from databricks.sql.exc import NotSupportedError, ProgrammingError @@ -148,8 +155,14 @@ def kernel_auth_kwargs( 2. **PAT** — the built provider is (or wraps) an ``AccessTokenAuthProvider`` → extract the bearer token. 3. **OAuth U2M** — ``auth_type`` is ``databricks-oauth`` / - ``azure-oauth`` → forward optional ``oauth_client_id`` / - ``oauth_redirect_port`` to the kernel's ``oauth-u2m``. + ``azure-oauth`` → forward the connector's *full* coupled OAuth-app + bundle (``client_id`` + ``oauth_scopes`` + ``redirect_port``) to + the kernel's ``oauth-u2m``. Each field falls back to the + connector's registered ``databricks-sql-python`` (or azure) + default when the caller doesn't override it, so a bare U2M + connection authenticates as ``databricks-sql-python`` — parity + with the Thrift path — rather than the kernel's own + ``databricks-sql-connector`` default (PECOBLR-4039/4040). 4. **Custom credentials_provider** → ``NotSupportedError`` (opaque token source; no raw creds for the kernel to own). 5. Anything else → ``NotSupportedError``. @@ -214,16 +227,51 @@ def kernel_auth_kwargs( return kwargs # 3. OAuth U2M — browser authorization-code flow; the kernel runs it. + # + # The kernel's core default U2M app is databricks-sql-connector / + # sql offline_access / port 8030 (PECOBLR-4039). The Python + # connector is an OVERRIDE of that default: on this path we forward + # its OWN full bundle rather than letting the kernel fall back to + # the connector default. Forwarding a bare oauth-u2m would + # authenticate as databricks-sql-connector, breaking parity with + # the Thrift path (which authenticates as databricks-sql-python). + # + # client_id + scopes + redirect_port are coupled per OAuth app — + # each app registers its own redirect URI — so all three are + # resolved together: an explicit caller value wins; otherwise the + # connector's registered databricks-sql-python (or azure) bundle is + # used, mirroring the defaults get_python_sql_connector_auth_provider + # applies on the Thrift path. + # + # Only the redirect PORT is routable into the kernel: it derives + # http://localhost:{port}, with scheme/host/path fixed. The + # connector registers a port *range* for its app but the kernel + # accepts a single port, so we forward the first (canonical) + # registered port. if auth_type in ("databricks-oauth", "azure-oauth"): - kwargs = {"auth_type": "oauth-u2m"} - if client_id: - kwargs["client_id"] = client_id + is_azure = auth_type == "azure-oauth" + default_client_id = ( + PYSQL_OAUTH_AZURE_CLIENT_ID if is_azure else PYSQL_OAUTH_CLIENT_ID + ) + default_port_range = ( + PYSQL_OAUTH_AZURE_REDIRECT_PORT_RANGE + if is_azure + else PYSQL_OAUTH_REDIRECT_PORT_RANGE + ) redirect_port = opts.get("oauth_redirect_port") - if redirect_port is not None: - kwargs["redirect_port"] = int(redirect_port) scopes = _normalize_scopes(opts.get("oauth_scopes")) - if scopes is not None: - kwargs["oauth_scopes"] = scopes + kwargs = { + "auth_type": "oauth-u2m", + "client_id": client_id or default_client_id, + "redirect_port": ( + int(redirect_port) + if redirect_port is not None + else default_port_range[0] + ), + "oauth_scopes": ( + scopes if scopes is not None else list(PYSQL_OAUTH_SCOPES) + ), + } if federation_client_id: kwargs["identity_federation_client_id"] = federation_client_id return kwargs diff --git a/tests/unit/test_kernel_auth_bridge.py b/tests/unit/test_kernel_auth_bridge.py index edafdf625..a32f3db8b 100644 --- a/tests/unit/test_kernel_auth_bridge.py +++ b/tests/unit/test_kernel_auth_bridge.py @@ -26,6 +26,13 @@ # require the kernel wheel). So this test can run on the # default-deps CI matrix without any extras. No importorskip needed. +from databricks.sql.auth.auth import ( + PYSQL_OAUTH_CLIENT_ID, + PYSQL_OAUTH_AZURE_CLIENT_ID, + PYSQL_OAUTH_SCOPES, + PYSQL_OAUTH_REDIRECT_PORT_RANGE, + PYSQL_OAUTH_AZURE_REDIRECT_PORT_RANGE, +) from databricks.sql.auth.authenticators import ( AccessTokenAuthProvider, AuthProvider, @@ -240,31 +247,93 @@ def test_client_id_without_secret_does_not_trigger_m2m(self): class TestKernelOAuthU2M: - @pytest.mark.parametrize("auth_type", ["databricks-oauth", "azure-oauth"]) - def test_u2m_routes_to_kernel_u2m(self, auth_type): + """The kernel core default U2M app is ``databricks-sql-connector`` / + ``sql offline_access`` / port 8030 (see PECOBLR-4039). The Python + connector is an OVERRIDE: on the kernel path it must forward its OWN + full bundle — ``client_id`` + ``oauth_scopes`` + ``redirect_port`` — + because the three are coupled per OAuth app. Forwarding a partial + bundle would let the kernel fill the rest from the connector default, + authenticating as the wrong principal / against an unregistered + redirect URI. So bare U2M must forward the complete + ``databricks-sql-python`` bundle for parity with the Thrift path.""" + + def test_bare_databricks_oauth_forwards_full_python_bundle(self): + # No overrides → forward the databricks-sql-python bundle in full + # so the kernel does NOT fall back to its databricks-sql-connector + # default. This is the parity-with-Thrift acceptance criterion. + kwargs = kernel_auth_kwargs( + _FakeOAuthProvider(), + {"auth_type": "databricks-oauth"}, + ) + assert kwargs == { + "auth_type": "oauth-u2m", + "client_id": PYSQL_OAUTH_CLIENT_ID, + "redirect_port": PYSQL_OAUTH_REDIRECT_PORT_RANGE[0], + "oauth_scopes": list(PYSQL_OAUTH_SCOPES), + } + + def test_bare_azure_oauth_forwards_full_azure_bundle(self): + kwargs = kernel_auth_kwargs( + _FakeOAuthProvider(), + {"auth_type": "azure-oauth"}, + ) + assert kwargs == { + "auth_type": "oauth-u2m", + "client_id": PYSQL_OAUTH_AZURE_CLIENT_ID, + "redirect_port": PYSQL_OAUTH_AZURE_REDIRECT_PORT_RANGE[0], + "oauth_scopes": list(PYSQL_OAUTH_SCOPES), + } + + def test_u2m_custom_client_id_scopes_and_port_honored(self): + # A caller overriding the app supplies the full coupled bundle; + # every field is forwarded verbatim. kwargs = kernel_auth_kwargs( _FakeOAuthProvider(), - {"auth_type": auth_type}, + { + "auth_type": "databricks-oauth", + "oauth_client_id": "custom-client", + "oauth_scopes": ["custom-scope", "offline_access"], + "oauth_redirect_port": 9999, + }, ) - assert kwargs == {"auth_type": "oauth-u2m"} + assert kwargs == { + "auth_type": "oauth-u2m", + "client_id": "custom-client", + "redirect_port": 9999, + "oauth_scopes": ["custom-scope", "offline_access"], + } - def test_u2m_forwards_client_id_and_redirect_port(self): + def test_u2m_custom_client_id_only_falls_back_to_connector_defaults(self): + # A custom client_id without explicit scopes/port fills the + # remaining two from the connector defaults — matching the Thrift + # path, where a custom client_id still uses PYSQL_OAUTH_SCOPES and + # the default redirect-port range. kwargs = kernel_auth_kwargs( _FakeOAuthProvider(), { "auth_type": "databricks-oauth", "oauth_client_id": "custom-client", - "oauth_redirect_port": 8030, }, ) assert kwargs == { "auth_type": "oauth-u2m", "client_id": "custom-client", - "redirect_port": 8030, + "redirect_port": PYSQL_OAUTH_REDIRECT_PORT_RANGE[0], + "oauth_scopes": list(PYSQL_OAUTH_SCOPES), } + def test_u2m_redirect_port_coerced_to_int(self): + # oauth_redirect_port may arrive as a string (e.g. from a DSN); + # the kernel binding wants an int. + kwargs = kernel_auth_kwargs( + _FakeOAuthProvider(), + {"auth_type": "databricks-oauth", "oauth_redirect_port": "8021"}, + ) + assert kwargs["redirect_port"] == 8021 + assert isinstance(kwargs["redirect_port"], int) + @pytest.mark.parametrize("auth_type", ["databricks-oauth", "azure-oauth"]) - def test_u2m_forwards_scopes(self, auth_type): + def test_u2m_forwards_custom_scopes(self, auth_type): kwargs = kernel_auth_kwargs( _FakeOAuthProvider(), {"auth_type": auth_type, "oauth_scopes": ["all-apis", "offline_access"]}, From 5b9b62308f78f0f8436ba9b90e8a0fa7a5b9b892 Mon Sep 17 00:00:00 2001 From: "peco-engineer-bot[bot]" Date: Mon, 17 Aug 2026 22:40:08 +0000 Subject: [PATCH 02/10] ai: apply changes for #914 (1 review thread) Addresses: - #3799256419 at src/databricks/sql/backend/kernel/auth_bridge.py:266 Signed-off-by: peco-engineer-bot[bot] --- .../sql/backend/kernel/auth_bridge.py | 8 +++++-- tests/unit/test_kernel_auth_bridge.py | 24 +++++++++++++++++-- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/databricks/sql/backend/kernel/auth_bridge.py b/src/databricks/sql/backend/kernel/auth_bridge.py index a149abe7b..acef5feb4 100644 --- a/src/databricks/sql/backend/kernel/auth_bridge.py +++ b/src/databricks/sql/backend/kernel/auth_bridge.py @@ -247,7 +247,11 @@ def kernel_auth_kwargs( # http://localhost:{port}, with scheme/host/path fixed. The # connector registers a port *range* for its app but the kernel # accepts a single port, so we forward the first (canonical) - # registered port. + # registered port. A caller-supplied port only overrides that + # default when an explicit client_id is ALSO supplied — matching + # the Thrift path's coupling (a bare oauth_redirect_port paired + # with the default databricks-sql-python app would resolve to an + # unregistered redirect URI and fail the flow). if auth_type in ("databricks-oauth", "azure-oauth"): is_azure = auth_type == "azure-oauth" default_client_id = ( @@ -265,7 +269,7 @@ def kernel_auth_kwargs( "client_id": client_id or default_client_id, "redirect_port": ( int(redirect_port) - if redirect_port is not None + if client_id and redirect_port is not None else default_port_range[0] ), "oauth_scopes": ( diff --git a/tests/unit/test_kernel_auth_bridge.py b/tests/unit/test_kernel_auth_bridge.py index a32f3db8b..8e3be48c4 100644 --- a/tests/unit/test_kernel_auth_bridge.py +++ b/tests/unit/test_kernel_auth_bridge.py @@ -324,14 +324,34 @@ def test_u2m_custom_client_id_only_falls_back_to_connector_defaults(self): def test_u2m_redirect_port_coerced_to_int(self): # oauth_redirect_port may arrive as a string (e.g. from a DSN); - # the kernel binding wants an int. + # the kernel binding wants an int. The port override is coupled to + # an explicit client_id (see the coupling test below), so supply + # one here to exercise the coercion path. kwargs = kernel_auth_kwargs( _FakeOAuthProvider(), - {"auth_type": "databricks-oauth", "oauth_redirect_port": "8021"}, + { + "auth_type": "databricks-oauth", + "oauth_client_id": "custom-client", + "oauth_redirect_port": "8021", + }, ) assert kwargs["redirect_port"] == 8021 assert isinstance(kwargs["redirect_port"], int) + def test_u2m_redirect_port_ignored_without_client_id(self): + # A bare oauth_redirect_port (no explicit client_id) must NOT be + # forwarded: it would be paired with the default databricks-sql-python + # app, whose registered redirect URIs only cover the default port + # range, so an arbitrary port would resolve to an unregistered URI + # and fail the U2M flow. This mirrors the Thrift path's coupling, + # where oauth_redirect_port_range is only overridden when both + # oauth_client_id and oauth_redirect_port are supplied. + kwargs = kernel_auth_kwargs( + _FakeOAuthProvider(), + {"auth_type": "databricks-oauth", "oauth_redirect_port": 9999}, + ) + assert kwargs["redirect_port"] == PYSQL_OAUTH_REDIRECT_PORT_RANGE[0] + @pytest.mark.parametrize("auth_type", ["databricks-oauth", "azure-oauth"]) def test_u2m_forwards_custom_scopes(self, auth_type): kwargs = kernel_auth_kwargs( From 26d6e74ca0174640ff3563e562f77582d8d2dc1b Mon Sep 17 00:00:00 2001 From: "peco-engineer-bot[bot]" Date: Mon, 17 Aug 2026 22:48:05 +0000 Subject: [PATCH 03/10] ai: apply changes for #914 (1 review thread) Addresses: - #3799336213 at src/databricks/sql/backend/kernel/auth_bridge.py:275 Signed-off-by: peco-engineer-bot[bot] --- .../sql/backend/kernel/auth_bridge.py | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/databricks/sql/backend/kernel/auth_bridge.py b/src/databricks/sql/backend/kernel/auth_bridge.py index acef5feb4..86ec064ad 100644 --- a/src/databricks/sql/backend/kernel/auth_bridge.py +++ b/src/databricks/sql/backend/kernel/auth_bridge.py @@ -236,12 +236,15 @@ def kernel_auth_kwargs( # authenticate as databricks-sql-connector, breaking parity with # the Thrift path (which authenticates as databricks-sql-python). # - # client_id + scopes + redirect_port are coupled per OAuth app — - # each app registers its own redirect URI — so all three are - # resolved together: an explicit caller value wins; otherwise the - # connector's registered databricks-sql-python (or azure) bundle is - # used, mirroring the defaults get_python_sql_connector_auth_provider - # applies on the Thrift path. + # client_id + redirect_port are coupled per OAuth app — each app + # registers its own redirect URI — so both are resolved together: + # an explicit caller value wins; otherwise the connector's + # registered databricks-sql-python (or azure) bundle is used, + # mirroring the defaults get_python_sql_connector_auth_provider + # applies on the Thrift path. scopes are NOT caller-overridable: + # the Thrift path hardcodes PYSQL_OAUTH_SCOPES for U2M (a caller's + # oauth_scopes kwarg is never read there), so we forward the same + # fixed scopes here to keep the two backends in parity. # # Only the redirect PORT is routable into the kernel: it derives # http://localhost:{port}, with scheme/host/path fixed. The @@ -263,7 +266,12 @@ def kernel_auth_kwargs( else PYSQL_OAUTH_REDIRECT_PORT_RANGE ) redirect_port = opts.get("oauth_redirect_port") - scopes = _normalize_scopes(opts.get("oauth_scopes")) + # Validate any caller-supplied oauth_scopes (a bad type is still a + # caller error worth flagging) but do NOT forward it: the Thrift + # path hardcodes PYSQL_OAUTH_SCOPES for U2M, so we do the same for + # parity rather than letting the kernel path honor an override the + # other backend silently ignores. + _normalize_scopes(opts.get("oauth_scopes")) kwargs = { "auth_type": "oauth-u2m", "client_id": client_id or default_client_id, @@ -272,9 +280,7 @@ def kernel_auth_kwargs( if client_id and redirect_port is not None else default_port_range[0] ), - "oauth_scopes": ( - scopes if scopes is not None else list(PYSQL_OAUTH_SCOPES) - ), + "oauth_scopes": list(PYSQL_OAUTH_SCOPES), } if federation_client_id: kwargs["identity_federation_client_id"] = federation_client_id From fdc20e5337721701b5c916de147cbfb5e5fbe83c Mon Sep 17 00:00:00 2001 From: "peco-engineer-bot[bot]" Date: Mon, 17 Aug 2026 22:55:16 +0000 Subject: [PATCH 04/10] ai: apply changes for #914 (1 review thread) Addresses: - #3799379619 at src/databricks/sql/backend/kernel/auth_bridge.py:283 Signed-off-by: peco-engineer-bot[bot] --- tests/unit/test_kernel_auth_bridge.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/tests/unit/test_kernel_auth_bridge.py b/tests/unit/test_kernel_auth_bridge.py index 8e3be48c4..b50fd9f15 100644 --- a/tests/unit/test_kernel_auth_bridge.py +++ b/tests/unit/test_kernel_auth_bridge.py @@ -284,9 +284,13 @@ def test_bare_azure_oauth_forwards_full_azure_bundle(self): "oauth_scopes": list(PYSQL_OAUTH_SCOPES), } - def test_u2m_custom_client_id_scopes_and_port_honored(self): - # A caller overriding the app supplies the full coupled bundle; - # every field is forwarded verbatim. + def test_u2m_custom_client_id_and_port_honored_scopes_fixed(self): + # A caller overriding the app supplies the coupled client_id + + # redirect_port, which are forwarded verbatim. oauth_scopes is NOT + # caller-overridable: the Thrift path hardcodes PYSQL_OAUTH_SCOPES + # for U2M (it never reads an oauth_scopes kwarg), so the kernel + # path forwards the same fixed scopes for parity even when the + # caller passes their own. kwargs = kernel_auth_kwargs( _FakeOAuthProvider(), { @@ -300,7 +304,7 @@ def test_u2m_custom_client_id_scopes_and_port_honored(self): "auth_type": "oauth-u2m", "client_id": "custom-client", "redirect_port": 9999, - "oauth_scopes": ["custom-scope", "offline_access"], + "oauth_scopes": list(PYSQL_OAUTH_SCOPES), } def test_u2m_custom_client_id_only_falls_back_to_connector_defaults(self): @@ -353,12 +357,16 @@ def test_u2m_redirect_port_ignored_without_client_id(self): assert kwargs["redirect_port"] == PYSQL_OAUTH_REDIRECT_PORT_RANGE[0] @pytest.mark.parametrize("auth_type", ["databricks-oauth", "azure-oauth"]) - def test_u2m_forwards_custom_scopes(self, auth_type): + def test_u2m_ignores_custom_scopes_for_thrift_parity(self, auth_type): + # The Thrift path hardcodes PYSQL_OAUTH_SCOPES for U2M and never + # reads a caller's oauth_scopes; the kernel path forwards the same + # fixed scopes for parity rather than honoring an override the + # other backend silently ignores. kwargs = kernel_auth_kwargs( _FakeOAuthProvider(), {"auth_type": auth_type, "oauth_scopes": ["all-apis", "offline_access"]}, ) - assert kwargs["oauth_scopes"] == ["all-apis", "offline_access"] + assert kwargs["oauth_scopes"] == list(PYSQL_OAUTH_SCOPES) class TestKernelIdentityFederationClientId: From bc6702f499e10a0bea0baed348058d8186018f06 Mon Sep 17 00:00:00 2001 From: eric-wang-1990 Date: Mon, 17 Aug 2026 16:41:24 -0700 Subject: [PATCH 05/10] fix(kernel): don't handle azure-oauth U2M yet; reject it (PECOBLR-4120) Azure AD U2M can't work through the kernel today: the kernel resolves OAuth endpoints only from the workspace-native OIDC config and has no Azure AD path, so the Thrift azure-oauth flow (AAD token endpoint + /user_impersonation scope) cannot be reproduced. Rather than forward an azure bundle that authenticates against the wrong endpoints, reject auth_type='azure-oauth' up front with a clear NotSupportedError pointing at the Thrift backend. The kernel U2M path now handles databricks-oauth only. Azure support is tracked by PECOBLR-4120. Also fixes the stale scope tests the prior review left red: scopes are hardcoded to PYSQL_OAUTH_SCOPES for Thrift parity (not caller- overridable), and the tests now assert that. Co-authored-by: Isaac Signed-off-by: eric-wang-1990 --- CHANGELOG.md | 2 +- .../sql/backend/kernel/auth_bridge.py | 93 +++++++++++-------- tests/unit/test_kernel_auth_bridge.py | 86 +++++++++-------- 3 files changed, 103 insertions(+), 78 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f55d211ed..b88d721fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ # Release History # Unreleased -- Kernel backend (`use_kernel=True`): OAuth U2M now forwards the connector's full OAuth-app bundle (`client_id` + scopes + redirect port) into the kernel, so a bare U2M connection authenticates as `databricks-sql-python` with `sql offline_access` — parity with the Thrift path — instead of inheriting the kernel's own `databricks-sql-connector` default. Caller-supplied `oauth_client_id` / `oauth_scopes` / `oauth_redirect_port` are still honored (PECOBLR-4040) +- 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; scopes are fixed to match the Thrift path. `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) # 4.4.0 (2026-07-22) - Raised the minimum supported Python version to 3.10, dropping the end-of-life 3.8/3.9, to update the lockfile and clear CVE-flagged dependencies in the repo (databricks/databricks-sql-python#798) diff --git a/src/databricks/sql/backend/kernel/auth_bridge.py b/src/databricks/sql/backend/kernel/auth_bridge.py index 86ec064ad..ebc78eeaa 100644 --- a/src/databricks/sql/backend/kernel/auth_bridge.py +++ b/src/databricks/sql/backend/kernel/auth_bridge.py @@ -15,11 +15,16 @@ connector's own OAuth provider because the kernel re-mints tokens itself and the client secret is not recoverable from a built provider. -- **OAuth U2M** — for ``auth_type`` ``databricks-oauth`` / - ``azure-oauth`` (the browser authorization-code flow), the optional - ``oauth_client_id`` / ``oauth_redirect_port`` are forwarded to the - kernel's ``auth_type='oauth-u2m'`` and the kernel runs the browser - flow itself. +- **OAuth U2M** — for ``auth_type`` ``databricks-oauth`` (the browser + authorization-code flow), the connector's ``databricks-sql-python`` + app bundle (``client_id`` + ``redirect_port``, with the optional + ``oauth_client_id`` / ``oauth_redirect_port`` overriding it) is + forwarded to the kernel's ``auth_type='oauth-u2m'`` and the kernel + runs the browser flow itself. ``azure-oauth`` (Azure AD) is **not yet + supported** on the kernel path and is rejected with + ``NotSupportedError`` — the kernel resolves OAuth endpoints only from + the workspace-native OIDC config and cannot drive the Azure AD flow + (PECOBLR-4120). ``identity_federation_client_id`` is forwarded with whichever auth shape wins resolution. It selects mandatory SP-wide workload-identity token @@ -49,8 +54,6 @@ from typing import Any, Dict, Optional from databricks.sql.auth.auth import ( - PYSQL_OAUTH_AZURE_CLIENT_ID, - PYSQL_OAUTH_AZURE_REDIRECT_PORT_RANGE, PYSQL_OAUTH_CLIENT_ID, PYSQL_OAUTH_REDIRECT_PORT_RANGE, PYSQL_OAUTH_SCOPES, @@ -148,21 +151,23 @@ def kernel_auth_kwargs( rather than silently picking one flow (and failing later as a confusing 401 against the wrong principal): - a custom ``credentials_provider`` *and* M2M kwargs together; - - a U2M ``auth_type`` (``databricks-oauth`` / ``azure-oauth``) - *and* ``oauth_client_secret`` together. + - a U2M ``auth_type`` (``databricks-oauth``) *and* + ``oauth_client_secret`` together. + + (``azure-oauth`` is rejected as unsupported before these guards — + PECOBLR-4120.) 1. **OAuth M2M** — ``oauth_client_id`` + ``oauth_client_secret`` both present → forward raw creds to the kernel's ``oauth-m2m``. 2. **PAT** — the built provider is (or wraps) an ``AccessTokenAuthProvider`` → extract the bearer token. - 3. **OAuth U2M** — ``auth_type`` is ``databricks-oauth`` / - ``azure-oauth`` → forward the connector's *full* coupled OAuth-app - bundle (``client_id`` + ``oauth_scopes`` + ``redirect_port``) to - the kernel's ``oauth-u2m``. Each field falls back to the - connector's registered ``databricks-sql-python`` (or azure) - default when the caller doesn't override it, so a bare U2M - connection authenticates as ``databricks-sql-python`` — parity - with the Thrift path — rather than the kernel's own - ``databricks-sql-connector`` default (PECOBLR-4039/4040). + 3. **OAuth U2M** — ``auth_type`` is ``databricks-oauth`` → forward the + connector's coupled ``databricks-sql-python`` bundle (``client_id`` + + ``redirect_port``, with fixed ``PYSQL_OAUTH_SCOPES``) to the + kernel's ``oauth-u2m``, so a bare U2M connection authenticates as + ``databricks-sql-python`` — parity with the Thrift path — rather + than the kernel's own ``databricks-sql-connector`` default + (PECOBLR-4039/4040). ``azure-oauth`` is rejected as unsupported + (PECOBLR-4120). 4. **Custom credentials_provider** → ``NotSupportedError`` (opaque token source; no raw creds for the kernel to own). 5. Anything else → ``NotSupportedError``. @@ -182,6 +187,25 @@ def kernel_auth_kwargs( auth_type = opts.get("auth_type") has_m2m = bool(client_id and client_secret) + # azure-oauth (Azure AD U2M) is not yet supported on the kernel path. + # Reject it up front — before any M2M/U2M routing — so ANY azure-oauth + # request gets a clear "not supported" error rather than being silently + # misrouted (e.g. azure-oauth + client_id + secret would otherwise look + # like M2M). The kernel resolves OAuth endpoints only from the + # workspace-native OIDC config and has no Azure AD path, so the Thrift + # azure-oauth flow (AAD token endpoint + /user_impersonation scope, see + # AzureOAuthEndpointCollection) cannot be reproduced here. Forwarding an + # azure bundle would authenticate against the wrong endpoints, so we fail + # loudly at session-open. Tracked by PECOBLR-4120. + if auth_type == "azure-oauth": + raise NotSupportedError( + "use_kernel=True does not support auth_type='azure-oauth' (Azure " + "AD U2M) yet: the kernel resolves OAuth endpoints only from the " + "workspace-native OIDC configuration and cannot drive the Azure AD " + "authorization/token flow. Use the Thrift backend (default) for " + "azure-oauth. Tracked by PECOBLR-4120." + ) + # 0. Ambiguity guards — fail before any flow is chosen. if client_secret and opts.get("credentials_provider") is not None: raise NotSupportedError( @@ -191,7 +215,7 @@ def kernel_auth_kwargs( "kernel-managed M2M, or use the Thrift backend (default) for " "credentials_provider." ) - if client_secret and auth_type in ("databricks-oauth", "azure-oauth"): + if client_secret and auth_type == "databricks-oauth": raise NotSupportedError( f"Ambiguous auth on use_kernel=True: auth_type={auth_type!r} selects " "the U2M browser flow, but oauth_client_secret was also provided " @@ -227,6 +251,8 @@ def kernel_auth_kwargs( return kwargs # 3. OAuth U2M — browser authorization-code flow; the kernel runs it. + # Only databricks-oauth reaches here (azure-oauth was rejected up + # front — see the guard near the top of this function). # # The kernel's core default U2M app is databricks-sql-connector / # sql offline_access / port 8030 (PECOBLR-4039). The Python @@ -239,12 +265,12 @@ def kernel_auth_kwargs( # client_id + redirect_port are coupled per OAuth app — each app # registers its own redirect URI — so both are resolved together: # an explicit caller value wins; otherwise the connector's - # registered databricks-sql-python (or azure) bundle is used, - # mirroring the defaults get_python_sql_connector_auth_provider - # applies on the Thrift path. scopes are NOT caller-overridable: - # the Thrift path hardcodes PYSQL_OAUTH_SCOPES for U2M (a caller's - # oauth_scopes kwarg is never read there), so we forward the same - # fixed scopes here to keep the two backends in parity. + # registered databricks-sql-python bundle is used, mirroring the + # defaults get_python_sql_connector_auth_provider applies on the + # Thrift path. scopes are NOT caller-overridable: the Thrift path + # hardcodes PYSQL_OAUTH_SCOPES for U2M (a caller's oauth_scopes + # kwarg is never read there), so we forward the same fixed scopes + # here to keep the two backends in parity. # # Only the redirect PORT is routable into the kernel: it derives # http://localhost:{port}, with scheme/host/path fixed. The @@ -255,16 +281,7 @@ def kernel_auth_kwargs( # the Thrift path's coupling (a bare oauth_redirect_port paired # with the default databricks-sql-python app would resolve to an # unregistered redirect URI and fail the flow). - if auth_type in ("databricks-oauth", "azure-oauth"): - is_azure = auth_type == "azure-oauth" - default_client_id = ( - PYSQL_OAUTH_AZURE_CLIENT_ID if is_azure else PYSQL_OAUTH_CLIENT_ID - ) - default_port_range = ( - PYSQL_OAUTH_AZURE_REDIRECT_PORT_RANGE - if is_azure - else PYSQL_OAUTH_REDIRECT_PORT_RANGE - ) + if auth_type == "databricks-oauth": redirect_port = opts.get("oauth_redirect_port") # Validate any caller-supplied oauth_scopes (a bad type is still a # caller error worth flagging) but do NOT forward it: the Thrift @@ -274,11 +291,11 @@ def kernel_auth_kwargs( _normalize_scopes(opts.get("oauth_scopes")) kwargs = { "auth_type": "oauth-u2m", - "client_id": client_id or default_client_id, + "client_id": client_id or PYSQL_OAUTH_CLIENT_ID, "redirect_port": ( int(redirect_port) if client_id and redirect_port is not None - else default_port_range[0] + else PYSQL_OAUTH_REDIRECT_PORT_RANGE[0] ), "oauth_scopes": list(PYSQL_OAUTH_SCOPES), } @@ -309,7 +326,7 @@ def kernel_auth_kwargs( raise NotSupportedError( f"use_kernel=True requires PAT (access_token), OAuth M2M " f"(oauth_client_id + oauth_client_secret), or OAuth U2M " - f"(auth_type='databricks-oauth' / 'azure-oauth'), but got " + f"(auth_type='databricks-oauth'), but got " f"{provider_desc} with auth_type={auth_type!r}. Use the Thrift " "backend (default) for other auth flows." ) diff --git a/tests/unit/test_kernel_auth_bridge.py b/tests/unit/test_kernel_auth_bridge.py index b50fd9f15..d4ab533db 100644 --- a/tests/unit/test_kernel_auth_bridge.py +++ b/tests/unit/test_kernel_auth_bridge.py @@ -8,8 +8,9 @@ look through the wrapper). - OAuth M2M (``oauth_client_id`` + ``oauth_client_secret``) routes through ``auth_type='oauth-m2m'`` with the raw creds forwarded. - - OAuth U2M (``auth_type='databricks-oauth'`` / ``'azure-oauth'``) - routes through ``auth_type='oauth-u2m'``. + - OAuth U2M (``auth_type='databricks-oauth'``) routes through + ``auth_type='oauth-u2m'``. ``azure-oauth`` (Azure AD) is not yet + supported on the kernel path and is rejected (PECOBLR-4120). - A custom ``credentials_provider`` and any other non-PAT shape raise ``NotSupportedError`` with a clear, actionable message. """ @@ -28,10 +29,8 @@ from databricks.sql.auth.auth import ( PYSQL_OAUTH_CLIENT_ID, - PYSQL_OAUTH_AZURE_CLIENT_ID, PYSQL_OAUTH_SCOPES, PYSQL_OAUTH_REDIRECT_PORT_RANGE, - PYSQL_OAUTH_AZURE_REDIRECT_PORT_RANGE, ) from databricks.sql.auth.authenticators import ( AccessTokenAuthProvider, @@ -247,15 +246,19 @@ def test_client_id_without_secret_does_not_trigger_m2m(self): class TestKernelOAuthU2M: - """The kernel core default U2M app is ``databricks-sql-connector`` / + """Only ``databricks-oauth`` U2M is supported on the kernel path. + + The kernel core default U2M app is ``databricks-sql-connector`` / ``sql offline_access`` / port 8030 (see PECOBLR-4039). The Python - connector is an OVERRIDE: on the kernel path it must forward its OWN - full bundle — ``client_id`` + ``oauth_scopes`` + ``redirect_port`` — - because the three are coupled per OAuth app. Forwarding a partial - bundle would let the kernel fill the rest from the connector default, - authenticating as the wrong principal / against an unregistered - redirect URI. So bare U2M must forward the complete - ``databricks-sql-python`` bundle for parity with the Thrift path.""" + connector is an OVERRIDE: on the kernel path it forwards its OWN + coupled ``client_id`` + ``redirect_port`` bundle so it authenticates + as ``databricks-sql-python`` rather than the kernel default. Scopes + are fixed to ``PYSQL_OAUTH_SCOPES`` (not caller-overridable), matching + the Thrift path which hardcodes them for U2M. + + ``azure-oauth`` (Azure AD) is deliberately NOT handled yet — the + kernel can't drive the Azure AD authorization/token flow — so it is + rejected up front (PECOBLR-4120).""" def test_bare_databricks_oauth_forwards_full_python_bundle(self): # No overrides → forward the databricks-sql-python bundle in full @@ -272,25 +275,28 @@ def test_bare_databricks_oauth_forwards_full_python_bundle(self): "oauth_scopes": list(PYSQL_OAUTH_SCOPES), } - def test_bare_azure_oauth_forwards_full_azure_bundle(self): - kwargs = kernel_auth_kwargs( - _FakeOAuthProvider(), + @pytest.mark.parametrize( + "opts", + [ {"auth_type": "azure-oauth"}, - ) - assert kwargs == { - "auth_type": "oauth-u2m", - "client_id": PYSQL_OAUTH_AZURE_CLIENT_ID, - "redirect_port": PYSQL_OAUTH_AZURE_REDIRECT_PORT_RANGE[0], - "oauth_scopes": list(PYSQL_OAUTH_SCOPES), - } + {"auth_type": "azure-oauth", "oauth_client_id": "custom"}, + {"auth_type": "azure-oauth", "oauth_redirect_port": 8030}, + ], + ids=["bare", "with_client_id", "with_port"], + ) + def test_azure_oauth_not_supported(self, opts): + # azure-oauth (Azure AD U2M) can't work through the kernel yet: the + # kernel resolves OAuth endpoints only from workspace-native OIDC + # discovery and has no Azure AD path. Fail loudly at session-open + # rather than forwarding a bundle that authenticates against the + # wrong endpoints. Tracked by PECOBLR-4120. + with pytest.raises(NotSupportedError, match="azure-oauth"): + kernel_auth_kwargs(_FakeOAuthProvider(), opts) def test_u2m_custom_client_id_and_port_honored_scopes_fixed(self): - # A caller overriding the app supplies the coupled client_id + - # redirect_port, which are forwarded verbatim. oauth_scopes is NOT - # caller-overridable: the Thrift path hardcodes PYSQL_OAUTH_SCOPES - # for U2M (it never reads an oauth_scopes kwarg), so the kernel - # path forwards the same fixed scopes for parity even when the - # caller passes their own. + # A caller may override the coupled client_id + redirect_port. Scopes + # are NOT caller-overridable (Thrift parity): a supplied oauth_scopes + # is ignored and PYSQL_OAUTH_SCOPES is forwarded regardless. kwargs = kernel_auth_kwargs( _FakeOAuthProvider(), { @@ -356,15 +362,17 @@ def test_u2m_redirect_port_ignored_without_client_id(self): ) assert kwargs["redirect_port"] == PYSQL_OAUTH_REDIRECT_PORT_RANGE[0] - @pytest.mark.parametrize("auth_type", ["databricks-oauth", "azure-oauth"]) - def test_u2m_ignores_custom_scopes_for_thrift_parity(self, auth_type): - # The Thrift path hardcodes PYSQL_OAUTH_SCOPES for U2M and never - # reads a caller's oauth_scopes; the kernel path forwards the same - # fixed scopes for parity rather than honoring an override the - # other backend silently ignores. + def test_u2m_ignores_custom_scopes(self): + # Scopes are fixed for U2M — the Thrift path hardcodes + # PYSQL_OAUTH_SCOPES and never reads a caller oauth_scopes kwarg, so + # the kernel path forwards the same fixed scopes for parity. A + # (well-typed) caller oauth_scopes is validated but not honored. kwargs = kernel_auth_kwargs( _FakeOAuthProvider(), - {"auth_type": auth_type, "oauth_scopes": ["all-apis", "offline_access"]}, + { + "auth_type": "databricks-oauth", + "oauth_scopes": ["all-apis", "offline_access"], + }, ) assert kwargs["oauth_scopes"] == list(PYSQL_OAUTH_SCOPES) @@ -428,15 +436,15 @@ def _creds_provider(): }, ) - @pytest.mark.parametrize("auth_type", ["databricks-oauth", "azure-oauth"]) - def test_u2m_auth_type_plus_client_secret_is_rejected(self, auth_type): + def test_u2m_auth_type_plus_client_secret_is_rejected(self): # User asked for U2M (browser) but also passed a secret (M2M). - # Don't silently route M2M against the wrong principal. + # Don't silently route M2M against the wrong principal. (azure-oauth + # is rejected earlier as unsupported, so it's not exercised here.) with pytest.raises(NotSupportedError, match="Ambiguous auth"): kernel_auth_kwargs( _FakeOAuthProvider(), { - "auth_type": auth_type, + "auth_type": "databricks-oauth", "oauth_client_id": "id", "oauth_client_secret": "sec", }, From bfd362915b3d699aad4737ec6e0476619c44a6b0 Mon Sep 17 00:00:00 2001 From: "peco-engineer-bot[bot]" Date: Mon, 17 Aug 2026 23:55:42 +0000 Subject: [PATCH 06/10] ai: apply changes for #914 (2 review threads) Addresses: - #3799622645 at src/databricks/sql/backend/kernel/auth_bridge.py:254 - #3799650240 at src/databricks/sql/backend/kernel/auth_bridge.py:300 Signed-off-by: peco-engineer-bot[bot] --- .../sql/backend/kernel/auth_bridge.py | 49 +++++-------------- tests/unit/test_kernel_auth_bridge.py | 43 +++++++++------- 2 files changed, 38 insertions(+), 54 deletions(-) diff --git a/src/databricks/sql/backend/kernel/auth_bridge.py b/src/databricks/sql/backend/kernel/auth_bridge.py index ebc78eeaa..41b867615 100644 --- a/src/databricks/sql/backend/kernel/auth_bridge.py +++ b/src/databricks/sql/backend/kernel/auth_bridge.py @@ -251,44 +251,19 @@ def kernel_auth_kwargs( return kwargs # 3. OAuth U2M — browser authorization-code flow; the kernel runs it. - # Only databricks-oauth reaches here (azure-oauth was rejected up - # front — see the guard near the top of this function). - # - # The kernel's core default U2M app is databricks-sql-connector / - # sql offline_access / port 8030 (PECOBLR-4039). The Python - # connector is an OVERRIDE of that default: on this path we forward - # its OWN full bundle rather than letting the kernel fall back to - # the connector default. Forwarding a bare oauth-u2m would - # authenticate as databricks-sql-connector, breaking parity with - # the Thrift path (which authenticates as databricks-sql-python). - # - # client_id + redirect_port are coupled per OAuth app — each app - # registers its own redirect URI — so both are resolved together: - # an explicit caller value wins; otherwise the connector's - # registered databricks-sql-python bundle is used, mirroring the - # defaults get_python_sql_connector_auth_provider applies on the - # Thrift path. scopes are NOT caller-overridable: the Thrift path - # hardcodes PYSQL_OAUTH_SCOPES for U2M (a caller's oauth_scopes - # kwarg is never read there), so we forward the same fixed scopes - # here to keep the two backends in parity. - # - # Only the redirect PORT is routable into the kernel: it derives - # http://localhost:{port}, with scheme/host/path fixed. The - # connector registers a port *range* for its app but the kernel - # accepts a single port, so we forward the first (canonical) - # registered port. A caller-supplied port only overrides that - # default when an explicit client_id is ALSO supplied — matching - # the Thrift path's coupling (a bare oauth_redirect_port paired - # with the default databricks-sql-python app would resolve to an - # unregistered redirect URI and fail the flow). + # Only databricks-oauth reaches here (azure-oauth rejected up front). + # Forward the connector's own databricks-sql-python bundle instead of + # the kernel's databricks-sql-connector default, for parity with the + # Thrift path. client_id + redirect_port are coupled per app (each + # registers its own redirect URI): a caller port only overrides the + # default when an explicit client_id is also supplied. A caller may + # override oauth_scopes; absent one we forward PYSQL_OAUTH_SCOPES as + # the default. if auth_type == "databricks-oauth": redirect_port = opts.get("oauth_redirect_port") - # Validate any caller-supplied oauth_scopes (a bad type is still a - # caller error worth flagging) but do NOT forward it: the Thrift - # path hardcodes PYSQL_OAUTH_SCOPES for U2M, so we do the same for - # parity rather than letting the kernel path honor an override the - # other backend silently ignores. - _normalize_scopes(opts.get("oauth_scopes")) + # Honor a caller-supplied oauth_scopes (normalized to a list of + # strings); fall back to the connector default when none is given. + scopes = _normalize_scopes(opts.get("oauth_scopes")) kwargs = { "auth_type": "oauth-u2m", "client_id": client_id or PYSQL_OAUTH_CLIENT_ID, @@ -297,7 +272,7 @@ def kernel_auth_kwargs( if client_id and redirect_port is not None else PYSQL_OAUTH_REDIRECT_PORT_RANGE[0] ), - "oauth_scopes": list(PYSQL_OAUTH_SCOPES), + "oauth_scopes": scopes if scopes is not None else list(PYSQL_OAUTH_SCOPES), } if federation_client_id: kwargs["identity_federation_client_id"] = federation_client_id diff --git a/tests/unit/test_kernel_auth_bridge.py b/tests/unit/test_kernel_auth_bridge.py index d4ab533db..747426044 100644 --- a/tests/unit/test_kernel_auth_bridge.py +++ b/tests/unit/test_kernel_auth_bridge.py @@ -252,9 +252,9 @@ class TestKernelOAuthU2M: ``sql offline_access`` / port 8030 (see PECOBLR-4039). The Python connector is an OVERRIDE: on the kernel path it forwards its OWN coupled ``client_id`` + ``redirect_port`` bundle so it authenticates - as ``databricks-sql-python`` rather than the kernel default. Scopes - are fixed to ``PYSQL_OAUTH_SCOPES`` (not caller-overridable), matching - the Thrift path which hardcodes them for U2M. + as ``databricks-sql-python`` rather than the kernel default. A caller + may override ``oauth_scopes``; absent one, ``PYSQL_OAUTH_SCOPES`` is + forwarded as the default. ``azure-oauth`` (Azure AD) is deliberately NOT handled yet — the kernel can't drive the Azure AD authorization/token flow — so it is @@ -293,10 +293,9 @@ def test_azure_oauth_not_supported(self, opts): with pytest.raises(NotSupportedError, match="azure-oauth"): kernel_auth_kwargs(_FakeOAuthProvider(), opts) - def test_u2m_custom_client_id_and_port_honored_scopes_fixed(self): - # A caller may override the coupled client_id + redirect_port. Scopes - # are NOT caller-overridable (Thrift parity): a supplied oauth_scopes - # is ignored and PYSQL_OAUTH_SCOPES is forwarded regardless. + def test_u2m_custom_client_id_port_and_scopes_honored(self): + # A caller may override the coupled client_id + redirect_port and + # the oauth_scopes. All three are forwarded as supplied. kwargs = kernel_auth_kwargs( _FakeOAuthProvider(), { @@ -310,14 +309,13 @@ def test_u2m_custom_client_id_and_port_honored_scopes_fixed(self): "auth_type": "oauth-u2m", "client_id": "custom-client", "redirect_port": 9999, - "oauth_scopes": list(PYSQL_OAUTH_SCOPES), + "oauth_scopes": ["custom-scope", "offline_access"], } def test_u2m_custom_client_id_only_falls_back_to_connector_defaults(self): # A custom client_id without explicit scopes/port fills the - # remaining two from the connector defaults — matching the Thrift - # path, where a custom client_id still uses PYSQL_OAUTH_SCOPES and - # the default redirect-port range. + # remaining two from the connector defaults — a custom client_id + # still uses PYSQL_OAUTH_SCOPES and the default redirect-port range. kwargs = kernel_auth_kwargs( _FakeOAuthProvider(), { @@ -362,11 +360,10 @@ def test_u2m_redirect_port_ignored_without_client_id(self): ) assert kwargs["redirect_port"] == PYSQL_OAUTH_REDIRECT_PORT_RANGE[0] - def test_u2m_ignores_custom_scopes(self): - # Scopes are fixed for U2M — the Thrift path hardcodes - # PYSQL_OAUTH_SCOPES and never reads a caller oauth_scopes kwarg, so - # the kernel path forwards the same fixed scopes for parity. A - # (well-typed) caller oauth_scopes is validated but not honored. + def test_u2m_honors_custom_scopes(self): + # A caller-supplied oauth_scopes is forwarded to the kernel, even + # without an explicit client_id. Absent one, PYSQL_OAUTH_SCOPES is + # forwarded as the default (see the bare-bundle test above). kwargs = kernel_auth_kwargs( _FakeOAuthProvider(), { @@ -374,7 +371,19 @@ def test_u2m_ignores_custom_scopes(self): "oauth_scopes": ["all-apis", "offline_access"], }, ) - assert kwargs["oauth_scopes"] == list(PYSQL_OAUTH_SCOPES) + assert kwargs["oauth_scopes"] == ["all-apis", "offline_access"] + + def test_u2m_normalizes_space_delimited_scopes(self): + # A space-delimited oauth_scopes string is normalized to a list, + # mirroring the M2M path. + kwargs = kernel_auth_kwargs( + _FakeOAuthProvider(), + { + "auth_type": "databricks-oauth", + "oauth_scopes": "all-apis offline_access", + }, + ) + assert kwargs["oauth_scopes"] == ["all-apis", "offline_access"] class TestKernelIdentityFederationClientId: From b3d03d11893f27e982fab821d5253f835c6d9a02 Mon Sep 17 00:00:00 2001 From: "peco-engineer-bot[bot]" Date: Tue, 18 Aug 2026 00:03:08 +0000 Subject: [PATCH 07/10] ai: apply changes for #914 (1 review thread) Addresses: - #3799717722 at src/databricks/sql/backend/kernel/auth_bridge.py:275 Signed-off-by: peco-engineer-bot[bot] --- CHANGELOG.md | 2 +- src/databricks/sql/backend/kernel/auth_bridge.py | 13 +++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b88d721fd..4bf9c5b4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ # Release History # Unreleased -- 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; scopes are fixed to match the Thrift path. `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) +- 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. `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) # 4.4.0 (2026-07-22) - Raised the minimum supported Python version to 3.10, dropping the end-of-life 3.8/3.9, to update the lockfile and clear CVE-flagged dependencies in the repo (databricks/databricks-sql-python#798) diff --git a/src/databricks/sql/backend/kernel/auth_bridge.py b/src/databricks/sql/backend/kernel/auth_bridge.py index 41b867615..ea575e314 100644 --- a/src/databricks/sql/backend/kernel/auth_bridge.py +++ b/src/databricks/sql/backend/kernel/auth_bridge.py @@ -162,12 +162,13 @@ def kernel_auth_kwargs( ``AccessTokenAuthProvider`` → extract the bearer token. 3. **OAuth U2M** — ``auth_type`` is ``databricks-oauth`` → forward the connector's coupled ``databricks-sql-python`` bundle (``client_id`` - + ``redirect_port``, with fixed ``PYSQL_OAUTH_SCOPES``) to the - kernel's ``oauth-u2m``, so a bare U2M connection authenticates as - ``databricks-sql-python`` — parity with the Thrift path — rather - than the kernel's own ``databricks-sql-connector`` default - (PECOBLR-4039/4040). ``azure-oauth`` is rejected as unsupported - (PECOBLR-4120). + + ``redirect_port``, defaulting scopes to ``PYSQL_OAUTH_SCOPES`` + when the caller supplies none) to the kernel's ``oauth-u2m``, so a + bare U2M connection authenticates as ``databricks-sql-python`` — + forwarding the connector's own OAuth app rather than the kernel's + ``databricks-sql-connector`` default (PECOBLR-4039/4040). Unlike the + Thrift path, a caller-supplied ``oauth_scopes`` is honored here. + ``azure-oauth`` is rejected as unsupported (PECOBLR-4120). 4. **Custom credentials_provider** → ``NotSupportedError`` (opaque token source; no raw creds for the kernel to own). 5. Anything else → ``NotSupportedError``. From 5409faed9e5257836c958bd1a3262183cb9db325 Mon Sep 17 00:00:00 2001 From: "peco-engineer-bot[bot]" Date: Tue, 18 Aug 2026 00:08:08 +0000 Subject: [PATCH 08/10] ai: apply changes for #914 (1 review thread) Addresses: - #3799762589 at src/databricks/sql/backend/kernel/auth_bridge.py:261 Signed-off-by: peco-engineer-bot[bot] --- CHANGELOG.md | 2 +- src/databricks/sql/backend/kernel/auth_bridge.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bf9c5b4e..f750fa29b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ # Release History # Unreleased -- 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. `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) +- 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) # 4.4.0 (2026-07-22) - Raised the minimum supported Python version to 3.10, dropping the end-of-life 3.8/3.9, to update the lockfile and clear CVE-flagged dependencies in the repo (databricks/databricks-sql-python#798) diff --git a/src/databricks/sql/backend/kernel/auth_bridge.py b/src/databricks/sql/backend/kernel/auth_bridge.py index ea575e314..52750be81 100644 --- a/src/databricks/sql/backend/kernel/auth_bridge.py +++ b/src/databricks/sql/backend/kernel/auth_bridge.py @@ -259,7 +259,11 @@ def kernel_auth_kwargs( # registers its own redirect URI): a caller port only overrides the # default when an explicit client_id is also supplied. A caller may # override oauth_scopes; absent one we forward PYSQL_OAUTH_SCOPES as - # the default. + # the default. NB: the kernel's redirect_port is a single int, so + # unlike the Thrift path (which hands DatabricksOAuthProvider the full + # PYSQL_OAUTH_REDIRECT_PORT_RANGE and retries the next port when one is + # bound) this path forwards only one port with no fallback. A caller + # hitting a port collision must pass oauth_redirect_port explicitly. if auth_type == "databricks-oauth": redirect_port = opts.get("oauth_redirect_port") # Honor a caller-supplied oauth_scopes (normalized to a list of From a03205a19eb5610f44a8193918872875231bbf65 Mon Sep 17 00:00:00 2001 From: "peco-engineer-bot[bot]" Date: Tue, 18 Aug 2026 00:15:07 +0000 Subject: [PATCH 09/10] ai: apply changes for #914 (1 review thread) Addresses: - #3799812091 at src/databricks/sql/backend/kernel/auth_bridge.py:258 Signed-off-by: peco-engineer-bot[bot] --- .../sql/backend/kernel/auth_bridge.py | 19 ++++++++++++++++++- tests/unit/test_kernel_auth_bridge.py | 15 +++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/databricks/sql/backend/kernel/auth_bridge.py b/src/databricks/sql/backend/kernel/auth_bridge.py index 52750be81..536ed299a 100644 --- a/src/databricks/sql/backend/kernel/auth_bridge.py +++ b/src/databricks/sql/backend/kernel/auth_bridge.py @@ -273,7 +273,7 @@ def kernel_auth_kwargs( "auth_type": "oauth-u2m", "client_id": client_id or PYSQL_OAUTH_CLIENT_ID, "redirect_port": ( - int(redirect_port) + _coerce_redirect_port(redirect_port) if client_id and redirect_port is not None else PYSQL_OAUTH_REDIRECT_PORT_RANGE[0] ), @@ -312,6 +312,23 @@ def kernel_auth_kwargs( ) +def _coerce_redirect_port(redirect_port: Any) -> int: + """Coerce an ``oauth_redirect_port`` value (which may arrive as a string, + e.g. from a DSN) to an int. + + A non-numeric value is a caller error; surface it as a PEP 249 + ``ProgrammingError`` (as ``_normalize_scopes`` does for malformed + ``oauth_scopes``) rather than a bare ``ValueError``, so callers get a + consistent, actionable exception type for garbled input.""" + try: + return int(redirect_port) + except (TypeError, ValueError): + raise ProgrammingError( + f"oauth_redirect_port must be an integer (or a string parseable as " + f"one), got {redirect_port!r}." + ) + + def _normalize_scopes(scopes: Any) -> Optional[list]: """Normalise an ``oauth_scopes`` value to a list of strings, or ``None`` to let the kernel apply its defaults. diff --git a/tests/unit/test_kernel_auth_bridge.py b/tests/unit/test_kernel_auth_bridge.py index 747426044..559a2a57c 100644 --- a/tests/unit/test_kernel_auth_bridge.py +++ b/tests/unit/test_kernel_auth_bridge.py @@ -346,6 +346,21 @@ def test_u2m_redirect_port_coerced_to_int(self): assert kwargs["redirect_port"] == 8021 assert isinstance(kwargs["redirect_port"], int) + def test_u2m_redirect_port_non_numeric_raises_programming_error(self): + # A garbled oauth_redirect_port is a caller error of the same class + # as a malformed oauth_scopes, so it must surface as a PEP 249 + # ProgrammingError (not a bare ValueError from int()) for a + # consistent, actionable exception type. + with pytest.raises(ProgrammingError, match="oauth_redirect_port must be"): + kernel_auth_kwargs( + _FakeOAuthProvider(), + { + "auth_type": "databricks-oauth", + "oauth_client_id": "custom-client", + "oauth_redirect_port": "not-a-port", + }, + ) + def test_u2m_redirect_port_ignored_without_client_id(self): # A bare oauth_redirect_port (no explicit client_id) must NOT be # forwarded: it would be paired with the default databricks-sql-python From 9715f0727ce35c3ce2f101371d53dd4f27e0f854 Mon Sep 17 00:00:00 2001 From: eric-wang-1990 Date: Tue, 18 Aug 2026 00:18:36 -0700 Subject: [PATCH 10/10] fix(kernel): forward redirect_ports list to kernel U2M (PECOBLR-4144) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit databricks-sql-kernel #257 landed: the pyo3 Session now takes redirect_ports (a list) and no longer accepts the single redirect_port kwarg. Update the kernel auth bridge to emit redirect_ports for databricks-oauth U2M, forwarding the databricks-sql-python app's FULL registered port list (PYSQL_OAUTH_REDIRECT_PORT_RANGE, 8020-8024) so the kernel binds the first free port — busy-port fallback, matching the Thrift DatabricksOAuthProvider. A custom client_id + explicit port pins that single port ([port]). Bump KERNEL_REV to the merged #257 commit (45a0d6a) so kernel-e2e builds against the kernel that exposes redirect_ports. Tests updated. Co-authored-by: Isaac Signed-off-by: eric-wang-1990 --- KERNEL_REV | 2 +- .../sql/backend/kernel/auth_bridge.py | 24 ++++++------- tests/unit/test_kernel_auth_bridge.py | 36 ++++++++++--------- 3 files changed, 32 insertions(+), 30 deletions(-) diff --git a/KERNEL_REV b/KERNEL_REV index 95cfce816..97019339d 100644 --- a/KERNEL_REV +++ b/KERNEL_REV @@ -1 +1 @@ -eff8950428f4e6cc9975c663ec919f334962f7d0 +45a0d6ae1de2f203220913ba96c994ebb2d7aae4 diff --git a/src/databricks/sql/backend/kernel/auth_bridge.py b/src/databricks/sql/backend/kernel/auth_bridge.py index 536ed299a..95374ba77 100644 --- a/src/databricks/sql/backend/kernel/auth_bridge.py +++ b/src/databricks/sql/backend/kernel/auth_bridge.py @@ -17,7 +17,7 @@ provider. - **OAuth U2M** — for ``auth_type`` ``databricks-oauth`` (the browser authorization-code flow), the connector's ``databricks-sql-python`` - app bundle (``client_id`` + ``redirect_port``, with the optional + app bundle (``client_id`` + ``redirect_ports`` list, with the optional ``oauth_client_id`` / ``oauth_redirect_port`` overriding it) is forwarded to the kernel's ``auth_type='oauth-u2m'`` and the kernel runs the browser flow itself. ``azure-oauth`` (Azure AD) is **not yet @@ -162,7 +162,7 @@ def kernel_auth_kwargs( ``AccessTokenAuthProvider`` → extract the bearer token. 3. **OAuth U2M** — ``auth_type`` is ``databricks-oauth`` → forward the connector's coupled ``databricks-sql-python`` bundle (``client_id`` - + ``redirect_port``, defaulting scopes to ``PYSQL_OAUTH_SCOPES`` + + ``redirect_ports`` list, defaulting scopes to ``PYSQL_OAUTH_SCOPES`` when the caller supplies none) to the kernel's ``oauth-u2m``, so a bare U2M connection authenticates as ``databricks-sql-python`` — forwarding the connector's own OAuth app rather than the kernel's @@ -255,15 +255,15 @@ def kernel_auth_kwargs( # Only databricks-oauth reaches here (azure-oauth rejected up front). # Forward the connector's own databricks-sql-python bundle instead of # the kernel's databricks-sql-connector default, for parity with the - # Thrift path. client_id + redirect_port are coupled per app (each - # registers its own redirect URI): a caller port only overrides the + # Thrift path. client_id + redirect ports are coupled per app (each + # registers its own redirect URIs): a caller port only overrides the # default when an explicit client_id is also supplied. A caller may # override oauth_scopes; absent one we forward PYSQL_OAUTH_SCOPES as - # the default. NB: the kernel's redirect_port is a single int, so - # unlike the Thrift path (which hands DatabricksOAuthProvider the full - # PYSQL_OAUTH_REDIRECT_PORT_RANGE and retries the next port when one is - # bound) this path forwards only one port with no fallback. A caller - # hitting a port collision must pass oauth_redirect_port explicitly. + # the default. We forward the FULL PYSQL_OAUTH_REDIRECT_PORT_RANGE as + # ``redirect_ports`` so the kernel binds the first free port (busy-port + # fallback), mirroring the Thrift DatabricksOAuthProvider which retries + # the next port when one is bound. A caller overriding client_id + # supplies its own single registered port. if auth_type == "databricks-oauth": redirect_port = opts.get("oauth_redirect_port") # Honor a caller-supplied oauth_scopes (normalized to a list of @@ -272,10 +272,10 @@ def kernel_auth_kwargs( kwargs = { "auth_type": "oauth-u2m", "client_id": client_id or PYSQL_OAUTH_CLIENT_ID, - "redirect_port": ( - _coerce_redirect_port(redirect_port) + "redirect_ports": ( + [_coerce_redirect_port(redirect_port)] if client_id and redirect_port is not None - else PYSQL_OAUTH_REDIRECT_PORT_RANGE[0] + else list(PYSQL_OAUTH_REDIRECT_PORT_RANGE) ), "oauth_scopes": scopes if scopes is not None else list(PYSQL_OAUTH_SCOPES), } diff --git a/tests/unit/test_kernel_auth_bridge.py b/tests/unit/test_kernel_auth_bridge.py index 559a2a57c..f60943948 100644 --- a/tests/unit/test_kernel_auth_bridge.py +++ b/tests/unit/test_kernel_auth_bridge.py @@ -251,8 +251,9 @@ class TestKernelOAuthU2M: The kernel core default U2M app is ``databricks-sql-connector`` / ``sql offline_access`` / port 8030 (see PECOBLR-4039). The Python connector is an OVERRIDE: on the kernel path it forwards its OWN - coupled ``client_id`` + ``redirect_port`` bundle so it authenticates - as ``databricks-sql-python`` rather than the kernel default. A caller + coupled ``client_id`` + ``redirect_ports`` bundle (the full registered + port list, for busy-port fallback) so it authenticates as + ``databricks-sql-python`` rather than the kernel default. A caller may override ``oauth_scopes``; absent one, ``PYSQL_OAUTH_SCOPES`` is forwarded as the default. @@ -271,7 +272,8 @@ def test_bare_databricks_oauth_forwards_full_python_bundle(self): assert kwargs == { "auth_type": "oauth-u2m", "client_id": PYSQL_OAUTH_CLIENT_ID, - "redirect_port": PYSQL_OAUTH_REDIRECT_PORT_RANGE[0], + # Full registered port list → the kernel binds the first free one. + "redirect_ports": list(PYSQL_OAUTH_REDIRECT_PORT_RANGE), "oauth_scopes": list(PYSQL_OAUTH_SCOPES), } @@ -294,8 +296,9 @@ def test_azure_oauth_not_supported(self, opts): kernel_auth_kwargs(_FakeOAuthProvider(), opts) def test_u2m_custom_client_id_port_and_scopes_honored(self): - # A caller may override the coupled client_id + redirect_port and - # the oauth_scopes. All three are forwarded as supplied. + # A caller may override the coupled client_id + redirect port and the + # oauth_scopes. The single custom port is forwarded as a one-element + # redirect_ports list; all three are forwarded as supplied. kwargs = kernel_auth_kwargs( _FakeOAuthProvider(), { @@ -308,14 +311,14 @@ def test_u2m_custom_client_id_port_and_scopes_honored(self): assert kwargs == { "auth_type": "oauth-u2m", "client_id": "custom-client", - "redirect_port": 9999, + "redirect_ports": [9999], "oauth_scopes": ["custom-scope", "offline_access"], } def test_u2m_custom_client_id_only_falls_back_to_connector_defaults(self): # A custom client_id without explicit scopes/port fills the # remaining two from the connector defaults — a custom client_id - # still uses PYSQL_OAUTH_SCOPES and the default redirect-port range. + # still uses PYSQL_OAUTH_SCOPES and the default redirect-port list. kwargs = kernel_auth_kwargs( _FakeOAuthProvider(), { @@ -326,7 +329,7 @@ def test_u2m_custom_client_id_only_falls_back_to_connector_defaults(self): assert kwargs == { "auth_type": "oauth-u2m", "client_id": "custom-client", - "redirect_port": PYSQL_OAUTH_REDIRECT_PORT_RANGE[0], + "redirect_ports": list(PYSQL_OAUTH_REDIRECT_PORT_RANGE), "oauth_scopes": list(PYSQL_OAUTH_SCOPES), } @@ -343,8 +346,8 @@ def test_u2m_redirect_port_coerced_to_int(self): "oauth_redirect_port": "8021", }, ) - assert kwargs["redirect_port"] == 8021 - assert isinstance(kwargs["redirect_port"], int) + assert kwargs["redirect_ports"] == [8021] + assert isinstance(kwargs["redirect_ports"][0], int) def test_u2m_redirect_port_non_numeric_raises_programming_error(self): # A garbled oauth_redirect_port is a caller error of the same class @@ -362,18 +365,17 @@ def test_u2m_redirect_port_non_numeric_raises_programming_error(self): ) def test_u2m_redirect_port_ignored_without_client_id(self): - # A bare oauth_redirect_port (no explicit client_id) must NOT be - # forwarded: it would be paired with the default databricks-sql-python - # app, whose registered redirect URIs only cover the default port - # range, so an arbitrary port would resolve to an unregistered URI - # and fail the U2M flow. This mirrors the Thrift path's coupling, - # where oauth_redirect_port_range is only overridden when both + # A bare oauth_redirect_port (no explicit client_id) must NOT replace + # the default list: it would be paired with the default + # databricks-sql-python app, so we keep forwarding that app's full + # registered port list. This mirrors the Thrift path's coupling, where + # oauth_redirect_port_range is only overridden when both # oauth_client_id and oauth_redirect_port are supplied. kwargs = kernel_auth_kwargs( _FakeOAuthProvider(), {"auth_type": "databricks-oauth", "oauth_redirect_port": 9999}, ) - assert kwargs["redirect_port"] == PYSQL_OAUTH_REDIRECT_PORT_RANGE[0] + assert kwargs["redirect_ports"] == list(PYSQL_OAUTH_REDIRECT_PORT_RANGE) def test_u2m_honors_custom_scopes(self): # A caller-supplied oauth_scopes is forwarded to the kernel, even