diff --git a/CHANGELOG.md b/CHANGELOG.md index 036c71a4..ad9da98d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Release History +## Unreleased + +- Kernel backend (`useKernel: true`): **Azure Entra (Azure AD) auth is now threaded through the kernel path.** On `authType: 'databricks-oauth'`: **U2M** (no secret) always routes to `OAuthU2m` — the kernel runs one cloud-blind in-house workspace-federated browser flow (it uses the workspace's OIDC-discovered authorize endpoint verbatim), which works against Azure workspaces, so Azure U2M forwards the in-house app (`databricks-sql-connector`) + `sql offline_access` scopes exactly like AWS/GCP, regardless of `useDatabricksOAuthInAzure` (verified E2E against a live Azure workspace). **M2M** (secret): `useDatabricksOAuthInAzure: true` (or non-Azure) → `OAuthM2m` (workspace-OIDC client-credentials); an Azure host with `useDatabricksOAuthInAzure` absent/`false` → the Entra-direct Azure service-principal M2M (`AzureSpM2m`, the Entra SP creds ride `oauthClientId`/`oauthClientSecret`, `azureTenantId` optional and auto-discovered when omitted). On a non-Azure host `useDatabricksOAuthInAzure` is inert. The `AzureSpM2m` path requires a `databricks-sql-kernel` native module that exposes the Azure SP surface — landed on `main` via [databricks-sql-kernel#282](https://github.com/databricks/databricks-sql-kernel/pull/282) (which the pinned `KERNEL_REV` `5e5dea9` carries; the surface was originally proposed in [#280](https://github.com/databricks/databricks-sql-kernel/pull/280), which never reached `main`); U2M works on any kernel build. (PECOBLR-4141 / PECOBLR-4120) + ## 2.0.0 **Breaking changes — completes the security cleanup that 1.17.0 could not do without breaking changes.** diff --git a/KERNEL_REV b/KERNEL_REV index 95cfce81..beffc14b 100644 --- a/KERNEL_REV +++ b/KERNEL_REV @@ -1 +1 @@ -eff8950428f4e6cc9975c663ec919f334962f7d0 +5e5dea91ebc17df49d63665e1f933bacb3072c65 diff --git a/lib/kernel/KernelAuth.ts b/lib/kernel/KernelAuth.ts index 45cde7de..f8c9529f 100644 --- a/lib/kernel/KernelAuth.ts +++ b/lib/kernel/KernelAuth.ts @@ -252,6 +252,14 @@ export type KernelNativeConnectionOptions = KernelSessionDefaults & oauthScopes?: Array; oauthClientId?: string; } + | { + hostName: string; + httpPath: string; + authMode: 'AzureSpM2m'; + azureClientId: string; + azureClientSecret: string; + azureTenantId?: string; + } ); function prependSlash(str: string): string { @@ -261,6 +269,34 @@ function prependSlash(str: string): string { return str; } +/** + * Azure Databricks host suffixes — the superset the Thrift driver's + * `OAuthManager.getManager` recognises (`.azuredatabricks.net`, + * `.databricks.azure.us`, `.databricks.azure.cn`). Used to decide whether an + * OAuth connection is on Azure and therefore subject to the in-house-vs- + * Entra-direct split. + */ +const AZURE_HOST_SUFFIXES = ['.azuredatabricks.net', '.databricks.azure.us', '.databricks.azure.cn']; + +/** + * True when `host` is an Azure Databricks workspace host. Normalises the input + * more aggressively than the Thrift driver's `getManager` (which only + * lowercases and strips a leading `https://`): here we also trim surrounding + * whitespace, strip either scheme, then drop any path and explicit `:port`, so + * a caller passing a bare host, a padded string, or a full URL with a port is + * treated identically. The suffix set matches `getManager`, so routing stays a + * superset — not a byte-for-byte match — of Thrift's. + */ +function isAzureHost(host: string): boolean { + const normalized = host + .trim() + .toLowerCase() + .replace(/^https?:\/\//, '') + .split('/')[0] + .split(':')[0]; + return AZURE_HOST_SUFFIXES.some((suffix) => normalized.endsWith(suffix)); +} + /** * Reject inputs that pass `typeof === 'string' && length > 0` but are * structurally useless as credentials: whitespace-only strings, and the @@ -481,11 +517,25 @@ export function buildKernelHttpOptions(options: ConnectionOptions): KernelHttpOp * binding makes them, happen below the TypeScript layer and are not * observable from this repo. * + * Azure (Entra) on the OAuth path. The kernel runs a single, cloud-blind + * in-house U2M flow and workspace-OIDC M2M; only Entra-direct **M2M** gets a + * dedicated kernel mode: + * - **U2M (no secret), any cloud, any `useDatabricksOAuthInAzure`** → + * `OAuthU2m`. The kernel uses the workspace's OIDC-discovered authorize + * endpoint (`{host}/oidc/v1/authorize`) verbatim; that in-house + * workspace-federated flow works against Azure workspaces too (they federate + * the browser login to Entra server-side — verified E2E). So Azure U2M is + * NOT special-cased and NOT rejected — it forwards the in-house app + * (`databricks-sql-connector`) + `sql offline_access`, exactly like AWS/GCP. + * - **M2M (secret) with `useDatabricksOAuthInAzure: true`** (or non-Azure) → + * `OAuthM2m` (workspace-OIDC client-credentials). + * - **M2M (secret) on an Azure host with `useDatabricksOAuthInAzure` absent/ + * `false`** (Entra-direct) → Azure service-principal M2M (`AzureSpM2m`); the + * Entra SP creds ride `oauthClientId`/`oauthClientSecret`, `azureTenantId` + * optional (kernel auto-discovers). + * - On a non-Azure host `useDatabricksOAuthInAzure` is inert. + * * Out of scope on the OAuth paths (rejected with a clear error): - * - `azureTenantId` / `useDatabricksOAuthInAzure` → Microsoft Entra - * direct flow. The kernel uses workspace-OIDC discovery (which works - * against Azure workspaces too — they serve `/oidc/.well-known/...`) - * and does not implement the Entra-direct scope-rewrite path. * - `persistence` on M2M → M2M tokens are not cached (re-issuing is * cheap; no refresh token). * - `persistence` on U2M → custom token store is a parity gap; @@ -499,7 +549,7 @@ export function buildKernelHttpOptions(options: ConnectionOptions): KernelHttpOp * * Throws: * - `AuthenticationError` for missing/blank required credentials. - * - `HiveDriverError` for unsupported auth modes / Azure-direct / + * - `HiveDriverError` for unsupported auth modes / * custom persistence / ambiguous combinations. */ /** @@ -667,12 +717,91 @@ export function buildKernelConnectionOptions(options: ConnectionOptions): Kernel ); } - if (oauth.azureTenantId !== undefined || oauth.useDatabricksOAuthInAzure === true) { - throw new HiveDriverError( - 'kernel backend: Azure-direct OAuth (azureTenantId / useDatabricksOAuthInAzure) ' + - 'is not supported. The workspace-OIDC discovery path handles Azure workspaces ' + - 'today without these options.', - ); + // Azure Entra-direct **M2M** → the kernel's dedicated azure-sp-m2m. Closely + // mirroring the Thrift driver's `OAuthManager.getManager`, an Azure host with + // `useDatabricksOAuthInAzure` NOT set to true (the Entra-direct default) plus a + // secret is an Entra service-principal client-credentials flow: the Entra SP + // credentials ride the generic `oauthClientId` / `oauthClientSecret` (Thrift + // convention); forward them as `azureClientId` / `azureClientSecret`. + // `azureTenantId` is optional — the kernel auto-discovers it from the workspace + // `/aad/auth` redirect when omitted. + // + // Azure **U2M** is deliberately NOT special-cased and NOT rejected. The kernel + // runs a single, cloud-blind in-house U2M flow: it uses the workspace's + // OIDC-discovered authorize endpoint (`{host}/oidc/v1/authorize`) verbatim, and + // that in-house workspace-federated flow works against Azure workspaces (the + // workspace federates the browser login to Entra server-side; verified E2E). So + // ALL U2M — including Azure, with or without `useDatabricksOAuthInAzure` — falls + // through to the standard `OAuthU2m` path below, which forwards the in-house app + // (`databricks-sql-connector`) + `sql offline_access` scopes, exactly like + // AWS/GCP. Handing the kernel the Thrift Azure Entra-direct app / scope instead + // would derail its in-house flow to a broken AAD authorize URL. + // + // One deliberate divergence from Thrift: `isAzureHost` uses the full suffix + // superset (incl. `.databricks.azure.us`) for every branch, whereas Thrift's + // `useDatabricksOAuthInAzure`-true arm omits `.databricks.azure.us` and so + // throws `OAuth is not supported` for a US-gov host in that mode. Here such a + // host falls through to the in-house flow (accepted) instead — intentional, + // since the kernel's in-house flow is cloud-blind and reachable everywhere. + // The `oauthClientSecret !== undefined` check is inline so TypeScript narrows + // the field to `string` inside the branch (for the AzureSpM2m literal below). + if ( + isAzureHost(options.host) && + oauth.useDatabricksOAuthInAzure !== true && + oauth.oauthClientSecret !== undefined + ) { + // Entra-direct SP M2M is a client-credentials flow (no refresh token), so + // `persistence` is rejected here for parity with the workspace-OIDC M2M and + // U2M arms below (and matching the contract docblock's "persistence on M2M + // → rejected" note). Otherwise a caller's hook would be silently dropped. + if (oauth.persistence !== undefined) { + throw new HiveDriverError( + 'kernel backend: `persistence` is not supported on Azure service-principal M2M ' + + '(M2M tokens have no refresh token; the kernel re-issues on expiry).', + ); + } + // Reject a present-but-degenerate secret (`''`, whitespace, or the reserved + // `'undefined'`/`'null'` shell-export strings) up front. Unlike the generic + // `OAuthM2m` arm below — which forwards such values verbatim for byte-for-byte + // Thrift parity — this Azure arm has no parity contract (it already rejects a + // missing id outright), so a blank credential is as unusable as a missing one + // and would only surface an opaque Entra `invalid_client` downstream. + const azureClientSecret = oauth.oauthClientSecret; + if (isBlankOrReserved(azureClientSecret)) { + throw new HiveDriverError( + 'kernel backend: Azure service-principal M2M requires a non-blank `oauthClientSecret` ' + + '(the Entra app-registration client secret).', + ); + } + const azureClientId = oauth.oauthClientId; + if (typeof azureClientId !== 'string' || isBlankOrReserved(azureClientId)) { + throw new HiveDriverError( + 'kernel backend: Azure service-principal M2M requires `oauthClientId` (the Entra ' + + 'app-registration client id) alongside `oauthClientSecret`.', + ); + } + // `oauthScopes` is intentionally NOT forwarded here (and the `AzureSpM2m` + // union member has no such field), unlike the generic `OAuthM2m` arm below + // which honors an override. Entra service-principal tokens use a fixed + // `/.default` scope that the kernel derives from the Azure app id; + // a caller-supplied scope override is meaningless to that flow, so it is + // dropped by design rather than plumbed through. + const azure = { + ...base, + authMode: 'AzureSpM2m' as const, + azureClientId, + azureClientSecret, + }; + // Forward `azureTenantId` only when it's a real value. A blank/reserved + // string (`''`, whitespace, `'undefined'`/`'null'` shell-export artifacts) + // is treated as omitted so the kernel auto-discovers the tenant from the + // workspace `/aad/auth` redirect, rather than being handed a degenerate + // tenant that suppresses discovery and yields a malformed AAD URL. Matches + // this arm's `oauthClientId`/`oauthClientSecret` hygiene above and the + // Thrift `AzureOAuthManager` empty-tenant fallback. + return oauth.azureTenantId !== undefined && !isBlankOrReserved(oauth.azureTenantId) + ? { ...azure, azureTenantId: oauth.azureTenantId } + : azure; } // Flow selector + client-id resolution mirror the Thrift driver EXACTLY diff --git a/native/kernel/index.d.ts b/native/kernel/index.d.ts index 161c59d9..6fb596e2 100644 --- a/native/kernel/index.d.ts +++ b/native/kernel/index.d.ts @@ -581,7 +581,7 @@ export interface ArrowSchema { * Authentication mode selector crossing the napi boundary. The string * literals are what napi-rs emits from this `#[napi(string_enum)]` — the * NodeJS SEA adapter (`KernelAuth`) matches them verbatim (`'Pat'`, - * `'OAuthM2m'`, `'OAuthU2m'`). + * `'OAuthM2m'`, `'OAuthU2m'`, `'AzureSpM2m'`). * * Mirrors the kernel [`AuthConfig`] variants this binding supports. * `OAuthFederation` / `External` are intentionally not exposed yet — the @@ -593,11 +593,26 @@ export declare const enum AuthMode { Pat = 'Pat', /** OAuth 2.0 machine-to-machine — `oauthClientId` + `oauthClientSecret`. */ OAuthM2m = 'OAuthM2m', + /** + * OAuth 2.0 machine-to-machine with a JWT private-key client + * assertion — `oauthClientId` + `jwtKeyFile` + `jwtKid`. + */ + OAuthM2mJwt = 'OAuthM2mJwt', /** * OAuth 2.0 user-to-machine (browser flow) — optional `oauthClientId` * + `oauthRedirectPort`. */ - OAuthU2m = 'OAuthU2m' + OAuthU2m = 'OAuthU2m', + /** + * Azure Entra service-principal M2M — `azureClientId` + + * `azureClientSecret` (+ optional `azureTenantId` / + * `azureWorkspaceResourceId`). The kernel builds the Entra token + * endpoint + `.default` scope, auto-discovers the tenant from the + * workspace when `azureTenantId` is omitted, and — when + * `azureWorkspaceResourceId` is set — additionally sends the SP + * management token for an RBAC-only service principal. + */ + AzureSpM2m = 'AzureSpM2m' } /** @@ -608,6 +623,8 @@ export declare const enum AuthMode { * - `OAuthM2m` — `oauthClientId` + `oauthClientSecret` required. * - `OAuthU2m` — `oauthClientId` / `oauthRedirectPort` optional * (defaults to the `databricks-sql-connector` client on port 8030). + * - `AzureSpM2m` — `azureClientId` + `azureClientSecret` required + * (+ optional `azureTenantId` / `azureWorkspaceResourceId`). * * Catalog / schema / sessionConf are applied once at session creation * and remain in effect for every statement run on the resulting @@ -653,11 +670,63 @@ export interface ConnectionOptions { * (`["all-apis"]` for M2M; `["sql", "offline_access"]` for U2M). */ oauthScopes?: Array + /** + * OAuth token endpoint override. When omitted the kernel discovers the + * endpoint via the workspace's OIDC configuration. Set it to point the + * client-credentials / JWT client-assertion grant at the workspace's + * configured IdP token endpoint (e.g. Entra ID for Azure Databricks), + * which is required when that IdP — not Databricks-native OIDC — is the + * authority for [`AuthMode::OAuthM2m`] / [`AuthMode::OAuthM2mJwt`]. + */ + tokenUrl?: string + /** + * Path to the PEM private-key file. Required for + * [`AuthMode::OAuthM2mJwt`]. + */ + jwtKeyFile?: string + /** JWT key id (`kid`). Required for [`AuthMode::OAuthM2mJwt`]. */ + jwtKid?: string + /** + * Passphrase for an encrypted PKCS#8 key. Optional for + * [`AuthMode::OAuthM2mJwt`] (omit for an unencrypted key). + */ + jwtPassphrase?: string + /** + * JWT signing algorithm (`RS256`/`384`/`512`, `PS256`/`384`/`512`, + * `ES256`, `ES384`). Optional for [`AuthMode::OAuthM2mJwt`] + * (omitted ⇒ kernel default `RS256`). + */ + jwtAlgorithm?: string /** * SP-wide Workload Identity Federation client id used during mandatory * token exchange. Omitted selects BYOT / account-wide WIF. */ identityFederationClientId?: string + /** + * Azure Entra app-registration client id. Required for + * [`AuthMode::AzureSpM2m`]; ignored otherwise. + */ + azureClientId?: string + /** + * Azure Entra app-registration client secret. Required for + * [`AuthMode::AzureSpM2m`]. + */ + azureClientSecret?: string + /** + * Azure Entra tenant id. Optional for [`AuthMode::AzureSpM2m`]; + * omitted ⇒ the kernel auto-discovers it from the workspace's + * `/aad/auth` redirect (matching the Thrift connector). + */ + azureTenantId?: string + /** + * Azure workspace ARM resource id. Optional for [`AuthMode::AzureSpM2m`]; + * when set the kernel also sends the SP management token + * (`X-Databricks-Azure-SP-Management-Token`) + the + * `X-Databricks-Azure-Workspace-Resource-Id` header, so a service + * principal with an Azure RBAC role but no workspace membership can + * authenticate. Omitted ⇒ the data token authenticates alone. + */ + azureWorkspaceResourceId?: string /** * Default catalog for statements executed on this session. * Routed through the kernel's `DefaultOpts` and onto the SEA @@ -676,6 +745,66 @@ export interface ConnectionOptions { * `session_confs`. Unknown keys are rejected server-side. */ sessionConf?: Record + /** + * Driver name reported in telemetry system configuration. Omitted ⇒ + * kernel default. + */ + driverName?: string + /** + * Driver version reported in telemetry system configuration. Omitted ⇒ + * kernel default. + */ + driverVersion?: string + /** + * Runtime name reported in telemetry system configuration. Omitted ⇒ + * kernel default. + */ + runtimeName?: string + /** + * Runtime version reported in telemetry system configuration. Omitted ⇒ + * kernel default. + */ + runtimeVersion?: string + /** + * Runtime vendor reported in telemetry system configuration. Omitted ⇒ + * kernel default. + */ + runtimeVendor?: string + /** + * Operating system name reported in telemetry system configuration. + * Omitted ⇒ kernel default. + */ + osName?: string + /** + * Operating system version reported in telemetry system configuration. + * Omitted ⇒ kernel default. + */ + osVersion?: string + /** + * Operating system architecture reported in telemetry system + * configuration. Omitted ⇒ kernel default. + */ + osArch?: string + /** + * Client application name reported in telemetry system configuration. + * Omitted ⇒ absent unless the kernel can infer a default. + */ + clientAppName?: string + /** + * Locale name reported in telemetry system configuration. Omitted ⇒ + * kernel default. + */ + localeName?: string + /** + * Character-set encoding reported in telemetry system configuration. + * Omitted ⇒ kernel default. + */ + charSetEncoding?: string + /** + * Process name reported in telemetry system configuration. Omitted ⇒ + * kernel default. + */ + processName?: string /** * Maximum number of pooled HTTP connections per host. Routes * through the kernel's [`HttpConfig::pool_max_idle_per_host`]. @@ -832,6 +961,33 @@ export interface ConnectionOptions { * [`HttpConfig::overall_timeout`]. */ retryOverallTimeoutSecs?: number + /** + * Enable kernel telemetry export. Omitted / `false` keeps telemetry + * disabled. When enabled, the kernel records operation telemetry below the + * binding layer and exports through the Databricks SQL driver telemetry + * endpoint. + */ + telemetryEnabled?: boolean + /** + * Number of telemetry events buffered before a flush. Omitted ⇒ kernel + * default. Must be greater than zero when supplied. + */ + telemetryBatchSize?: number + /** + * Periodic telemetry flush interval in milliseconds. Omitted ⇒ kernel + * default. Must be greater than zero when supplied. + */ + telemetryFlushIntervalMs?: number + /** Maximum telemetry export retries. `0` disables telemetry export retries. */ + telemetryMaxRetries?: number + /** Fixed telemetry retry delay in milliseconds. `0` means immediate retry. */ + telemetryRetryDelayMs?: number + /** + * Maximum time explicit close waits for telemetry final flush, in + * milliseconds. Omitted ⇒ kernel default. Must be greater than zero when + * supplied. + */ + telemetryCloseFlushTimeoutMs?: number /** * Programmatic HTTP/HTTPS proxy ([`ProxyInput`]) to route all kernel * traffic through. Carries the proxy `url`, optional basic-auth diff --git a/tests/unit/kernel/auth-m2m.test.ts b/tests/unit/kernel/auth-m2m.test.ts index 7b55bcb2..d29a1524 100644 --- a/tests/unit/kernel/auth-m2m.test.ts +++ b/tests/unit/kernel/auth-m2m.test.ts @@ -127,23 +127,170 @@ describe('KernelAuth + KernelBackend — OAuth M2M auth flow', () => { expect((native as { oauthClientId?: string }).oauthClientId).to.equal('client-uuid'); }); - it('rejects azureTenantId with a clear Entra-direct-out-of-scope error', () => { + it('routes Azure host + secret (Entra-direct default) to azure-sp-m2m, forwarding the tenant', () => { + // On an Azure host with no `useDatabricksOAuthInAzure`, the default is the + // Entra-direct flow — for M2M that is the kernel's azure-sp-m2m. The Entra + // SP creds ride oauthClientId/oauthClientSecret; azureTenantId is forwarded + // (optional — the kernel auto-discovers it when omitted). const opts: ConnectionOptions = { host: 'adb-12345.0.azuredatabricks.net', path: '/sql/1.0/warehouses/abc', authType: 'databricks-oauth', - oauthClientId: 'client-uuid', - oauthClientSecret: 'dose-fake-secret', + oauthClientId: 'entra-app-id', + oauthClientSecret: 'entra-secret', azureTenantId: 'tenant-uuid', }; + const native = buildKernelConnectionOptions(opts); + expectNativeConnectionOptions(native, { + hostName: 'adb-12345.0.azuredatabricks.net', + httpPath: '/sql/1.0/warehouses/abc', + intervalsAsString: true, + authMode: 'AzureSpM2m', + azureClientId: 'entra-app-id', + azureClientSecret: 'entra-secret', + azureTenantId: 'tenant-uuid', + }); + }); + + it('routes Azure host + secret without a tenant to azure-sp-m2m (kernel auto-discovers)', () => { + const opts: ConnectionOptions = { + host: 'adb-12345.0.azuredatabricks.net', + path: '/sql/1.0/warehouses/abc', + authType: 'databricks-oauth', + oauthClientId: 'entra-app-id', + oauthClientSecret: 'entra-secret', + }; + + const native = buildKernelConnectionOptions(opts); + expectNativeConnectionOptions(native, { + hostName: 'adb-12345.0.azuredatabricks.net', + httpPath: '/sql/1.0/warehouses/abc', + intervalsAsString: true, + authMode: 'AzureSpM2m', + azureClientId: 'entra-app-id', + azureClientSecret: 'entra-secret', + }); + }); + + it('normalizes the host before Azure suffix matching (scheme, port, path, padding, case)', () => { + // `isAzureHost` strips a `http(s)://` scheme, drops any path and explicit + // `:port`, trims surrounding whitespace, and lowercases before matching the + // Azure suffixes. Feed each of those non-normalized shapes and assert the + // connection still routes to the Entra-direct `AzureSpM2m` path — a + // regression in the normalization would silently misroute to the in-house + // `OAuthM2m` and go uncaught otherwise. + const azureHostVariants = [ + 'https://adb-12345.0.azuredatabricks.net:443/sql/1.0/warehouses/abc', + 'http://adb-12345.0.azuredatabricks.net:8443', + ' ADB-12345.0.AzureDatabricks.NET ', + 'adb-12345.0.databricks.azure.us', + ]; + + for (const host of azureHostVariants) { + const opts: ConnectionOptions = { + host, + path: '/sql/1.0/warehouses/abc', + authType: 'databricks-oauth', + oauthClientId: 'entra-app-id', + oauthClientSecret: 'entra-secret', + }; + + const native = buildKernelConnectionOptions(opts); + expect(native.authMode, `for host=${JSON.stringify(host)}`).to.equal('AzureSpM2m'); + } + }); + + it('treats a blank/reserved azureTenantId as omitted (kernel auto-discovers)', () => { + // A tenant that resolves to `''`, whitespace, or the `'undefined'`/`'null'` + // shell-export artifacts is degenerate: forwarding it verbatim would + // suppress the kernel's `/aad/auth` auto-discovery and yield a malformed + // AAD URL. It must fall back to auto-discovery, matching this arm's + // oauthClientId/oauthClientSecret hygiene and the Thrift empty-tenant guard. + for (const badTenant of ['', ' ', 'undefined', 'NULL']) { + const opts: ConnectionOptions = { + host: 'adb-12345.0.azuredatabricks.net', + path: '/sql/1.0/warehouses/abc', + authType: 'databricks-oauth', + oauthClientId: 'entra-app-id', + oauthClientSecret: 'entra-secret', + azureTenantId: badTenant, + }; + + const native = buildKernelConnectionOptions(opts); + expectNativeConnectionOptions(native, { + hostName: 'adb-12345.0.azuredatabricks.net', + httpPath: '/sql/1.0/warehouses/abc', + intervalsAsString: true, + authMode: 'AzureSpM2m', + azureClientId: 'entra-app-id', + azureClientSecret: 'entra-secret', + }); + } + }); + + it('rejects Azure host + secret without an oauthClientId (Entra app has no default)', () => { + // Divergence from the regular M2M path (which defaults a missing + // oauthClientId to the built-in client): the Entra-direct azure-sp-m2m + // branch requires an explicit `oauthClientId` (the Entra app-registration + // client id has no sensible default) and rejects rather than silently + // defaulting on the Azure path. + const opts: ConnectionOptions = { + host: 'adb-12345.0.azuredatabricks.net', + path: '/sql/1.0/warehouses/abc', + authType: 'databricks-oauth', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + oauthClientSecret: 'entra-secret', + } as any; + expect(() => buildKernelConnectionOptions(opts)).to.throw( HiveDriverError, - /Azure-direct OAuth.*is not supported/, + /Azure service-principal M2M requires `oauthClientId`/, ); }); - it('rejects useDatabricksOAuthInAzure with the same Entra-direct error', () => { + it('rejects Azure host + secret with a blank/reserved oauthClientId (as unusable as missing)', () => { + // Unlike the generic M2M arm (which forwards `''` verbatim for Thrift + // parity), the Entra-direct azure-sp-m2m branch has no parity contract and + // rejects a missing id outright — so a blank/reserved id is treated the same + // rather than reaching the kernel as an opaque `invalid_client`. + for (const badId of ['', ' ', 'undefined', 'NULL']) { + const opts: ConnectionOptions = { + host: 'adb-12345.0.azuredatabricks.net', + path: '/sql/1.0/warehouses/abc', + authType: 'databricks-oauth', + oauthClientId: badId, + oauthClientSecret: 'entra-secret', + }; + + expect(() => buildKernelConnectionOptions(opts), `for oauthClientId=${JSON.stringify(badId)}`).to.throw( + HiveDriverError, + /Azure service-principal M2M requires `oauthClientId`/, + ); + } + }); + + it('rejects Azure host + a blank/reserved oauthClientSecret (as unusable as missing)', () => { + for (const badSecret of [' ', 'undefined', 'NULL']) { + const opts: ConnectionOptions = { + host: 'adb-12345.0.azuredatabricks.net', + path: '/sql/1.0/warehouses/abc', + authType: 'databricks-oauth', + oauthClientId: 'entra-app-id', + oauthClientSecret: badSecret, + }; + + expect(() => buildKernelConnectionOptions(opts), `for oauthClientSecret=${JSON.stringify(badSecret)}`).to.throw( + HiveDriverError, + /Azure service-principal M2M requires a non-blank `oauthClientSecret`/, + ); + } + }); + + it('routes Azure host + useDatabricksOAuthInAzure:true + secret to in-house OAuthM2m', () => { + // `useDatabricksOAuthInAzure: true` opts into the in-house + // (workspace-federated) flow — for M2M that is the kernel's generic + // workspace-OIDC client-credentials (OAuthM2m), which works on Azure hosts. const opts: ConnectionOptions = { host: 'adb-12345.0.azuredatabricks.net', path: '/sql/1.0/warehouses/abc', @@ -153,10 +300,16 @@ describe('KernelAuth + KernelBackend — OAuth M2M auth flow', () => { useDatabricksOAuthInAzure: true, }; - expect(() => buildKernelConnectionOptions(opts)).to.throw( - HiveDriverError, - /Azure-direct OAuth.*is not supported/, - ); + const native = buildKernelConnectionOptions(opts); + expectNativeConnectionOptions(native, { + hostName: 'adb-12345.0.azuredatabricks.net', + httpPath: '/sql/1.0/warehouses/abc', + intervalsAsString: true, + authMode: 'OAuthM2m', + oauthClientId: 'client-uuid', + oauthClientSecret: 'dose-fake-secret', + oauthScopes: ['all-apis'], + }); }); it('rejects a `persistence` hook on M2M (no cache needed)', () => { diff --git a/tests/unit/kernel/auth-u2m.test.ts b/tests/unit/kernel/auth-u2m.test.ts index c21493d5..1943e099 100644 --- a/tests/unit/kernel/auth-u2m.test.ts +++ b/tests/unit/kernel/auth-u2m.test.ts @@ -100,7 +100,13 @@ describe('KernelAuth + KernelBackend — OAuth U2M auth flow', () => { expect(native.httpPath).to.equal('/sql/1.0/warehouses/abc'); }); - it('rejects azureTenantId on the U2M path with the Entra-direct error', () => { + it('routes Azure U2M (no secret, no useDatabricksOAuthInAzure) to in-house OAuthU2m', () => { + // Azure U2M is NOT rejected and NOT special-cased: the kernel runs a single + // cloud-blind in-house workspace-federated U2M flow (it uses the workspace's + // OIDC-discovered authorize endpoint verbatim), which works against Azure + // workspaces. So it routes to OAuthU2m with the in-house app + + // sql/offline_access regardless of useDatabricksOAuthInAzure. azureTenantId + // is inert on the kernel U2M path. const opts: ConnectionOptions = { host: 'adb-12345.0.azuredatabricks.net', path: '/sql/1.0/warehouses/abc', @@ -108,13 +114,21 @@ describe('KernelAuth + KernelBackend — OAuth U2M auth flow', () => { azureTenantId: 'tenant-uuid', }; - expect(() => buildKernelConnectionOptions(opts)).to.throw( - HiveDriverError, - /Azure-direct OAuth.*is not supported/, - ); + const native = buildKernelConnectionOptions(opts); + expectNativeConnectionOptions(native, { + hostName: 'adb-12345.0.azuredatabricks.net', + httpPath: '/sql/1.0/warehouses/abc', + intervalsAsString: true, + authMode: 'OAuthU2m', + oauthRedirectPort: 8030, + oauthScopes: ['sql', 'offline_access'], + }); }); - it('rejects useDatabricksOAuthInAzure on the U2M path', () => { + it('routes Azure host + useDatabricksOAuthInAzure:true (no secret) to in-house OAuthU2m', () => { + // `useDatabricksOAuthInAzure: true` opts into the in-house + // (workspace-federated) browser flow, which the kernel runs against Azure + // Databricks workspaces — so this is the U2M happy path, not a rejection. const opts: ConnectionOptions = { host: 'adb-12345.0.azuredatabricks.net', path: '/sql/1.0/warehouses/abc', @@ -122,10 +136,15 @@ describe('KernelAuth + KernelBackend — OAuth U2M auth flow', () => { useDatabricksOAuthInAzure: true, }; - expect(() => buildKernelConnectionOptions(opts)).to.throw( - HiveDriverError, - /Azure-direct OAuth.*is not supported/, - ); + const native = buildKernelConnectionOptions(opts); + expectNativeConnectionOptions(native, { + hostName: 'adb-12345.0.azuredatabricks.net', + httpPath: '/sql/1.0/warehouses/abc', + intervalsAsString: true, + authMode: 'OAuthU2m', + oauthRedirectPort: 8030, + oauthScopes: ['sql', 'offline_access'], + }); }); it('rejects a `persistence` hook on U2M citing the AuthConfig::External kernel-plumbing gap', () => {