Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 ([databricks-sql-kernel#280](https://github.com/databricks/databricks-sql-kernel/pull/280)); 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.**
Expand Down
2 changes: 1 addition & 1 deletion KERNEL_REV
Original file line number Diff line number Diff line change
@@ -1 +1 @@
eff8950428f4e6cc9975c663ec919f334962f7d0
1495174dac3aae39f23c34040fe639c54a3c3f35

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium — KERNEL_REV is bumped to 1495174d, which the PR description states is a temporary pin to the tip of the unmerged kernel branch #280 (stacked on #263). Pinning kernel-e2e to an unmerged branch tip is fragile — if that branch is rebased, force-pushed, or deleted, the connector's kernel build breaks with no code change here. Additionally, the @databricks/databricks-sql-kernel-*@0.2.0 npm pins in package.json still lack the AzureSpM2m surface, so end users on the published kernel who hit the new Azure SP M2M path will get a native-side rejection at connect time. Please confirm this PR is gated behind the kernel #263/#280 merge + release (re-point KERNEL_REV to a main SHA and bump the npm pins) before it lands, rather than merging with the temporary branch-tip pin.

100 changes: 89 additions & 11 deletions lib/kernel/KernelAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,14 @@ export type KernelNativeConnectionOptions = KernelSessionDefaults &
oauthScopes?: Array<string>;
oauthClientId?: string;
}
| {
hostName: string;
httpPath: string;
authMode: 'AzureSpM2m';
azureClientId: string;
azureClientSecret: string;
azureTenantId?: string;
}
);

function prependSlash(str: string): string {
Expand All @@ -261,6 +269,28 @@ 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
* the same way `getManager` does (lowercase, strip scheme + any path) so a
* caller passing a bare host or a full URL is treated identically.
*/
function isAzureHost(host: string): boolean {
const normalized = host
.toLowerCase()
.replace(/^https?:\/\//, '')
.split('/')[0];
return AZURE_HOST_SUFFIXES.some((suffix) => normalized.endsWith(suffix));
}
Comment on lines +286 to +292

/**
* Reject inputs that pass `typeof === 'string' && length > 0` but are
* structurally useless as credentials: whitespace-only strings, and the
Expand Down Expand Up @@ -481,11 +511,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;
Expand All @@ -499,7 +543,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 / Entra-direct U2M /

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Low — Stale docstring: the Throws section still lists HiveDriverError for "Entra-direct U2M", but this PR's whole point is that U2M is never rejected — every U2M config (including Azure, with or without useDatabricksOAuthInAzure) now falls through to OAuthU2m. This directly contradicts the Azure (Entra) prose added a few lines above, which correctly states Azure U2M is "NOT special-cased and NOT rejected." Recommend dropping the "Entra-direct U2M" clause from the Throws list (there is no longer any Entra/Azure-U2M throw path).

* custom persistence / ambiguous combinations.
*/
/**
Expand Down Expand Up @@ -667,12 +711,46 @@ 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. 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.
// The `oauthClientSecret !== undefined` check is inline (not extracted to a
// const) so TypeScript narrows it to `string` for the AzureSpM2m literal below.
if (
isAzureHost(options.host) &&

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium — The new Azure SP M2M branch returns before the persistence rejection checks, so a caller who supplies a persistence hook alongside an Azure Entra-direct M2M config has it silently dropped rather than rejected. The generic OAuthM2m path (and the OAuthU2m path) both throw a clear HiveDriverError for persistence because "M2M tokens have no refresh token; the kernel re-issues on expiry." That rationale applies identically to Azure SP M2M (also a client-credentials flow with no refresh token), so the inconsistency is surprising: the same option is a hard error on AWS/GCP M2M but a no-op on Azure SP M2M. Consider adding the same persistence !== undefined guard inside this branch (or moving the guard above it) so all M2M variants behave the same.

oauth.useDatabricksOAuthInAzure !== true &&
oauth.oauthClientSecret !== undefined
) {
const azureClientId = oauth.oauthClientId;
if (azureClientId === undefined) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Low — The Azure SP M2M branch introduces a divergence from the regular M2M path: regular M2M defaults a missing oauthClientId to DEFAULT_OAUTH_CLIENT_ID, but AzureSpM2m throws a HiveDriverError requiring an explicit oauthClientId. This is intentional (an Entra app registration has no sensible default), but the rejection branch (Azure host + secret + no oauthClientId) has no unit test. The new M2M tests cover Azure+secret+tenant, Azure+secret+no-tenant, and the in-house useDatabricksOAuthInAzure:true case, but not this rejection. Adding a test would lock in the intended divergence and prevent a regression that silently defaults the client id on the Azure path.

throw new HiveDriverError(
'kernel backend: Azure service-principal M2M requires `oauthClientId` (the Entra ' +
'app-registration client id) alongside `oauthClientSecret`.',
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Low — New error path lacks test coverage. The Azure SP M2M branch throws HiveDriverError when oauthClientSecret is present on an Azure host but oauthClientId is missing. The added unit tests cover Azure SP M2M with tenant, without tenant, and the useDatabricksOAuthInAzure:true → OAuthM2m case, but none exercise the missing-oauthClientId throw. Since this is a new user-facing rejection (and diverges from the non-Azure M2M behavior, which defaults a missing id to databricks-sql-connector), a test asserting the throw would lock the contract in.

}
const azure = {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Low — The new AzureSpM2m (Entra-direct SP M2M) branch does not reject a persistence hook, whereas both the regular M2M arm (persistenceHiveDriverError "not supported on OAuth M2M") and the U2M arm reject it. AzureSpM2m is fundamentally an M2M client-credentials flow (no refresh token), so the same rationale applies — but here a caller who passes persistence alongside Azure SP creds has it silently dropped instead of getting the clear, documented error. This also contradicts the function's own contract docblock, which lists "persistence on M2M → rejected with a clear error" as out-of-scope for the OAuth paths. Consider rejecting persistence in the entraDirect M2M path for parity.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium — The Entra-direct AzureSpM2m branch returns before the generic M2M persistence guard is reached, so a persistence hook is silently dropped on Azure SP M2M — while the exact same option throws a clear HiveDriverError on the non-Azure M2M path (line ~775) and on U2M.

Azure SP M2M is conceptually an M2M flow (no refresh token, re-issued on expiry), so the same rationale for rejecting persistence applies. A caller who sets persistence and connects to an Azure host with an Entra SP secret gets their hook quietly ignored rather than the actionable error every other OAuth path produces. This is an inconsistent contract and a silent-failure footgun.

Consider adding the same if (oauth.persistence !== undefined) throw ... guard inside the entraDirect M2M branch before returning the AzureSpM2m options.

...base,
authMode: 'AzureSpM2m' as const,
azureClientId,
azureClientSecret: oauth.oauthClientSecret,
};
return oauth.azureTenantId !== undefined ? { ...azure, azureTenantId: oauth.azureTenantId } : azure;
}

// Flow selector + client-id resolution mirror the Thrift driver EXACTLY
Expand Down
63 changes: 51 additions & 12 deletions tests/unit/kernel/auth-m2m.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,23 +127,56 @@ 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',
};

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: '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('rejects useDatabricksOAuthInAzure with the same Entra-direct error', () => {
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',
Expand All @@ -153,10 +186,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)', () => {
Expand Down
39 changes: 29 additions & 10 deletions tests/unit/kernel/auth-u2m.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,32 +100,51 @@ 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',
authType: 'databricks-oauth',
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',
authType: 'databricks-oauth',
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', () => {
Expand Down
Loading