From 651159ec72f3009e7213d8efdd5b22bd25745cf3 Mon Sep 17 00:00:00 2001 From: Rahul Singhal Date: Thu, 20 Aug 2026 00:12:51 +0000 Subject: [PATCH 1/4] feat(kernel): support JWT private-key M2M auth on useKernel Add JWT private-key client-assertion auth (RFC 7523) to the kernel backend. On `authType: 'databricks-oauth'`, supplying `oauthJwtKeyFile` selects the JWT flow: the kernel signs a short-lived assertion with the private key instead of sending a client secret and owns the token lifecycle (`authMode: 'OAuthM2mJwt'`). - KernelAuth: new JWT branch in buildKernelConnectionOptions (checked before the U2M/M2M-secret split; a private-key file is unambiguous JWT M2M intent), plus the OAuthM2mJwt native option shape. Requires oauthClientId + oauthJwtKid; optional oauthJwtPassphrase / oauthJwtAlgorithm / oauthScopes / tokenUrl. Mutually exclusive with oauthClientSecret. Also threads tokenUrl through the existing M2m branch. - IDBSQLClient: new oauthJwt* + tokenUrl fields on the databricks-oauth ConnectionOptions member. - DBSQLClient: on the useKernel path, do not build the connector's own OAuth provider (it eagerly starts the U2M browser flow / M2M exchange before the kernel is consulted); hand over a minimal PAT provider only when a token is present. Mirrors the Python connector. - tests: 9 unit tests for JWT routing / precedence / validation. Verified end-to-end: SELECT 1 via useKernel against an Azure Databricks warehouse, authenticated by Entra ID with a JWT private-key assertion (tokenUrl pointed at the Entra token endpoint). Requires a @databricks/databricks-sql-kernel build with JWT + tokenUrl support (kernel PRs #249 merged, #275 for tokenUrl). Signed-off-by: Rahul Singhal --- CHANGELOG.md | 4 + lib/DBSQLClient.ts | 25 ++++- lib/contracts/IDBSQLClient.ts | 16 ++++ lib/kernel/KernelAuth.ts | 72 +++++++++++++- tests/unit/kernel/auth-m2m-jwt.test.ts | 124 +++++++++++++++++++++++++ 5 files changed, 235 insertions(+), 6 deletions(-) create mode 100644 tests/unit/kernel/auth-m2m-jwt.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 036c71a4..7a03779d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Release History +## Unreleased + +- Kernel backend (`useKernel: true`): OAuth **M2M with a JWT private-key client assertion** (RFC 7523) is now supported. On the `databricks-oauth` auth type, supplying `oauthJwtKeyFile` (with `oauthClientId` + `oauthJwtKid`, optional `oauthJwtPassphrase` / `oauthJwtAlgorithm` / `oauthScopes`, and `tokenUrl` for the IdP token endpoint) selects the JWT client-assertion flow: the kernel signs a short-lived assertion with the private key instead of sending a client secret, and owns the token lifecycle. A private-key file is treated as unambiguous JWT M2M intent and is mutually exclusive with `oauthClientSecret`. `tokenUrl` points the grant at the workspace's OAuth IdP (e.g. Entra ID for Azure Databricks), which is required because Databricks-native OIDC does not advertise the `private_key_jwt` method. Also fixes the kernel path to not eagerly build the connector's own OAuth provider (which could start the U2M browser flow before the kernel is consulted). Verified end-to-end against an Azure Databricks warehouse via Entra ID. Requires a `@databricks/databricks-sql-kernel` build with JWT + `tokenUrl` support. + ## 2.0.0 **Breaking changes — completes the security cleanup that 1.17.0 could not do without breaking changes.** diff --git a/lib/DBSQLClient.ts b/lib/DBSQLClient.ts index f021edf9..e72a548d 100644 --- a/lib/DBSQLClient.ts +++ b/lib/DBSQLClient.ts @@ -721,14 +721,31 @@ export default class DBSQLClient extends EventEmitter implements IDBSQLClient, I // hit endpoints that don't carry the workspace in their URL path. this.config.customHeaders = this.buildCustomHeaders(options.path, options.customHeaders); - this.authProvider = this.createAuthProvider(options, authProvider); - - this.connectionProvider = this.createConnectionProvider(options); - // M0: `useKernel` is consumed via a non-exported internal-options cast so it // doesn't ship in the public `.d.ts`. Mirrors Python's `kwargs.get("use_kernel")` // pattern (see databricks-sql-python/src/databricks/sql/session.py). const internalOptions = options as ConnectionOptions & InternalConnectionOptions; + + // On the kernel path the kernel owns the full auth lifecycle (it resolves + // M2M / U2M / JWT purely from the raw options via `buildKernelConnectionOptions`). + // We must NOT build the connector's own OAuth provider here: for OAuth it + // eagerly runs the U2M browser flow / M2M token exchange at connect() time + // (a telemetry / feature-flag client calls `authProvider.authenticate()`), + // racing — and conflicting with — the kernel's auth. So for `useKernel` we + // hand over only a minimal PAT provider when a `token` is present, and + // `undefined` otherwise. Mirrors Python's use_kernel auth-provider handling. + if (internalOptions.useKernel) { + const { token } = options as { token?: string }; + this.authProvider = + typeof token === 'string' && token.length > 0 + ? new PlainHttpAuthentication({ username: 'token', password: token, context: this }) + : undefined; + } else { + this.authProvider = this.createAuthProvider(options, authProvider); + } + + this.connectionProvider = this.createConnectionProvider(options); + const backend = internalOptions.useKernel ? new KernelBackend({ context: this }) : new ThriftBackend({ diff --git a/lib/contracts/IDBSQLClient.ts b/lib/contracts/IDBSQLClient.ts index bbaa4c69..f2e1621a 100644 --- a/lib/contracts/IDBSQLClient.ts +++ b/lib/contracts/IDBSQLClient.ts @@ -26,6 +26,22 @@ type AuthOptions = // U2M flow to `['sql', 'offline_access']` (parity with the Thrift driver's // `defaultOAuthScopes`), overriding the kernel's bare `all-apis offline_access`. oauthScopes?: Array; + // JWT private-key M2M (RFC 7523 client assertion) — KERNEL BACKEND ONLY + // (`useKernel: true`). Supplying `oauthJwtKeyFile` selects the JWT + // client-assertion flow: the kernel signs a short-lived assertion with the + // private key instead of sending a client secret. Requires `oauthClientId` + // and `oauthJwtKid`. Optional `oauthJwtPassphrase` (encrypted PKCS#8 key), + // `oauthJwtAlgorithm` (default `RS256`), `oauthScopes`, and `tokenUrl` (the + // IdP token endpoint — required when auth is against an external IdP such as + // Entra ID, which is where `private_key_jwt` is supported). Mutually + // exclusive with `oauthClientSecret`. + oauthJwtKeyFile?: string; + oauthJwtKid?: string; + oauthJwtPassphrase?: string; + oauthJwtAlgorithm?: string; + // OAuth token endpoint override (kernel backend). Points the M2M / + // JWT client-assertion grant at the workspace's IdP token endpoint. + tokenUrl?: string; } | { authType: 'custom'; diff --git a/lib/kernel/KernelAuth.ts b/lib/kernel/KernelAuth.ts index 7cf99afa..4e3abd03 100644 --- a/lib/kernel/KernelAuth.ts +++ b/lib/kernel/KernelAuth.ts @@ -230,6 +230,19 @@ export type KernelNativeConnectionOptions = KernelSessionDefaults & oauthClientId: string; oauthClientSecret: string; oauthScopes?: Array; + tokenUrl?: string; + } + | { + hostName: string; + httpPath: string; + authMode: 'OAuthM2mJwt'; + oauthClientId: string; + jwtKeyFile: string; + jwtKid: string; + jwtPassphrase?: string; + jwtAlgorithm?: string; + oauthScopes?: Array; + tokenUrl?: string; } | { hostName: string; @@ -602,6 +615,11 @@ export function buildKernelConnectionOptions(options: ConnectionOptions): Kernel azureTenantId?: string; useDatabricksOAuthInAzure?: boolean; persistence?: unknown; + oauthJwtKeyFile?: string; + oauthJwtKid?: string; + oauthJwtPassphrase?: string; + oauthJwtAlgorithm?: string; + tokenUrl?: string; }; if (authType === undefined || authType === 'access-token') { @@ -637,6 +655,55 @@ export function buildKernelConnectionOptions(options: ConnectionOptions): Kernel ); } + // JWT private-key M2M (RFC 7523 client assertion). A private-key file is + // unambiguous JWT M2M intent, so this is checked before the U2M/M2M + // secret split. The kernel signs a short-lived assertion with the key + // (`authMode: 'OAuthM2mJwt'`) instead of sending a client secret. Requires + // `oauthClientId` (assertion issuer/subject) and `oauthJwtKid` (key id). + // Mutually exclusive with `oauthClientSecret`. + if (oauth.oauthJwtKeyFile !== undefined) { + if (oauth.oauthClientSecret !== undefined) { + throw new HiveDriverError( + 'kernel backend: cannot supply both `oauthJwtKeyFile` (JWT private-key M2M) ' + + 'and `oauthClientSecret` (shared-secret M2M). Pick one.', + ); + } + if (oauth.persistence !== undefined) { + throw new HiveDriverError( + 'kernel backend: `persistence` is not supported on JWT private-key M2M ' + + '(M2M tokens have no refresh token; the kernel re-issues on expiry).', + ); + } + if (oauth.oauthClientId === undefined) { + throw new AuthenticationError( + 'kernel backend: JWT private-key M2M (`oauthJwtKeyFile`) requires `oauthClientId` ' + + '(the service principal / OAuth client id used as the assertion issuer and subject).', + ); + } + if (oauth.oauthJwtKid === undefined) { + throw new AuthenticationError( + 'kernel backend: JWT private-key M2M (`oauthJwtKeyFile`) requires `oauthJwtKid` ' + + '(the key id written into the JWT header so the IdP can select the registered public key).', + ); + } + const jwt = { + ...base, + authMode: 'OAuthM2mJwt' as const, + oauthClientId: oauth.oauthClientId, + jwtKeyFile: oauth.oauthJwtKeyFile, + jwtKid: oauth.oauthJwtKid, + // Configurable (parity with pyo3); defaults to `['all-apis']` in the kernel. + oauthScopes: + Array.isArray(oauth.oauthScopes) && oauth.oauthScopes.length > 0 ? oauth.oauthScopes : M2M_DEFAULT_SCOPES, + }; + return { + ...jwt, + ...(oauth.oauthJwtPassphrase !== undefined ? { jwtPassphrase: oauth.oauthJwtPassphrase } : {}), + ...(oauth.oauthJwtAlgorithm !== undefined ? { jwtAlgorithm: oauth.oauthJwtAlgorithm } : {}), + ...(oauth.tokenUrl !== undefined ? { tokenUrl: oauth.tokenUrl } : {}), + }; + } + // Flow selector + client-id resolution mirror the Thrift driver EXACTLY // (`DBSQLClient.createAuthProvider`, DBSQLClient.ts:220): // flow = oauthClientSecret === undefined ? U2M : M2M (strict undefined) @@ -680,9 +747,9 @@ export function buildKernelConnectionOptions(options: ConnectionOptions): Kernel '(M2M tokens have no refresh token; the kernel re-issues on expiry).', ); } - return { + const m2m = { ...base, - authMode: 'OAuthM2m', + authMode: 'OAuthM2m' as const, // Thrift: `getClientId()` = `oauthClientId ?? defaultClientId`. oauthClientId: oauth.oauthClientId ?? DEFAULT_OAUTH_CLIENT_ID, oauthClientSecret: oauth.oauthClientSecret, @@ -690,6 +757,7 @@ export function buildKernelConnectionOptions(options: ConnectionOptions): Kernel oauthScopes: Array.isArray(oauth.oauthScopes) && oauth.oauthScopes.length > 0 ? oauth.oauthScopes : M2M_DEFAULT_SCOPES, }; + return oauth.tokenUrl !== undefined ? { ...m2m, tokenUrl: oauth.tokenUrl } : m2m; } throw new HiveDriverError( diff --git a/tests/unit/kernel/auth-m2m-jwt.test.ts b/tests/unit/kernel/auth-m2m-jwt.test.ts new file mode 100644 index 00000000..7c8ae216 --- /dev/null +++ b/tests/unit/kernel/auth-m2m-jwt.test.ts @@ -0,0 +1,124 @@ +// Copyright (c) 2026 Databricks, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { expect } from 'chai'; +import { buildKernelConnectionOptions } from '../../../lib/kernel/KernelAuth'; +import { ConnectionOptions } from '../../../lib/contracts/IDBSQLClient'; +import HiveDriverError from '../../../lib/errors/HiveDriverError'; +import AuthenticationError from '../../../lib/errors/AuthenticationError'; + +// A private-key file selects JWT client-assertion M2M (RFC 7523); the kernel +// signs a short-lived assertion with the key instead of sending a secret. +const baseJwt = { + host: 'example.azuredatabricks.net', + path: '/sql/1.0/warehouses/abc', + authType: 'databricks-oauth' as const, + oauthClientId: 'sp-uuid', + oauthJwtKeyFile: '/keys/jwt.pem', + oauthJwtKid: 'kid-1', +}; + +describe('KernelAuth — OAuth M2M JWT private-key auth flow', () => { + it('routes oauthJwtKeyFile to authMode OAuthM2mJwt with the required fields', () => { + const native = buildKernelConnectionOptions(baseJwt as ConnectionOptions); + expect(native.authMode).to.equal('OAuthM2mJwt'); + const jwt = native as { + oauthClientId?: string; + jwtKeyFile?: string; + jwtKid?: string; + oauthScopes?: string[]; + }; + expect(jwt.oauthClientId).to.equal('sp-uuid'); + expect(jwt.jwtKeyFile).to.equal('/keys/jwt.pem'); + expect(jwt.jwtKid).to.equal('kid-1'); + // Defaults to the M2M scope (parity with pyo3 / the secret M2M path). + expect(jwt.oauthScopes).to.deep.equal(['all-apis']); + }); + + it('forwards optional passphrase / algorithm / tokenUrl / scopes when present', () => { + const native = buildKernelConnectionOptions({ + ...baseJwt, + oauthJwtPassphrase: 'pw', + oauthJwtAlgorithm: 'ES256', + tokenUrl: 'https://login.microsoftonline.com/tenant/oauth2/v2.0/token', + oauthScopes: ['2ff814a6-.../.default'], + } as ConnectionOptions); + const jwt = native as { + jwtPassphrase?: string; + jwtAlgorithm?: string; + tokenUrl?: string; + oauthScopes?: string[]; + }; + expect(jwt.jwtPassphrase).to.equal('pw'); + expect(jwt.jwtAlgorithm).to.equal('ES256'); + expect(jwt.tokenUrl).to.equal('https://login.microsoftonline.com/tenant/oauth2/v2.0/token'); + expect(jwt.oauthScopes).to.deep.equal(['2ff814a6-.../.default']); + }); + + it('omits optional fields when not supplied', () => { + const native = buildKernelConnectionOptions(baseJwt as ConnectionOptions); + expect(native).to.not.have.property('jwtPassphrase'); + expect(native).to.not.have.property('jwtAlgorithm'); + expect(native).to.not.have.property('tokenUrl'); + }); + + it('takes precedence over the shared-secret M2M / U2M split', () => { + // A private key present makes this JWT M2M regardless of anything else + // (no secret ⇒ would otherwise be U2M). + const native = buildKernelConnectionOptions(baseJwt as ConnectionOptions); + expect(native.authMode).to.equal('OAuthM2mJwt'); + }); + + it('rejects oauthJwtKeyFile together with oauthClientSecret (ambiguous)', () => { + expect(() => + buildKernelConnectionOptions({ + ...baseJwt, + oauthClientSecret: 'shh', + } as ConnectionOptions), + ).to.throw(HiveDriverError, /both `oauthJwtKeyFile`.*`oauthClientSecret`/); + }); + + it('requires oauthClientId', () => { + const { oauthClientId, ...noClientId } = baseJwt; + expect(() => buildKernelConnectionOptions(noClientId as ConnectionOptions)).to.throw( + AuthenticationError, + /requires `oauthClientId`/, + ); + }); + + it('requires oauthJwtKid', () => { + const { oauthJwtKid, ...noKid } = baseJwt; + expect(() => buildKernelConnectionOptions(noKid as ConnectionOptions)).to.throw( + AuthenticationError, + /requires `oauthJwtKid`/, + ); + }); + + it('rejects persistence on the JWT M2M path', () => { + expect(() => + buildKernelConnectionOptions({ + ...baseJwt, + persistence: {} as never, + } as ConnectionOptions), + ).to.throw(HiveDriverError, /persistence/); + }); + + it('prepends `/` to the path on the JWT branch too', () => { + const native = buildKernelConnectionOptions({ + ...baseJwt, + path: 'sql/1.0/warehouses/abc', + } as ConnectionOptions); + expect((native as { httpPath: string }).httpPath).to.equal('/sql/1.0/warehouses/abc'); + }); +}); From 19e0d0e386b9f0ec7bc19490f0872d11b7bd8985 Mon Sep 17 00:00:00 2001 From: Rahul Singhal Date: Thu, 20 Aug 2026 19:40:14 +0000 Subject: [PATCH 2/4] fix(kernel): move JWT M2M options to internal type; address review Addresses PR #504 review feedback: - Move oauthJwtKeyFile/oauthJwtKid/oauthJwtPassphrase/oauthJwtAlgorithm/ tokenUrl off the public `databricks-oauth` AuthOptions onto InternalConnectionOptions (kernel-only), mirroring `useKernel` and the TLS knobs. The Thrift backend has no JWT client-assertion path, so exposing them on the shared public type would let a Thrift caller set them and have them silently ignored (Eric's divergence concern). - Classify JWT M2M correctly in telemetry `mapAuthType` (`oauth-m2m-jwt`) instead of misreporting it as `external-browser` (bot F1). - Reject a PAT `token` supplied alongside `oauthJwtKeyFile` in the PAT-branch ambiguity guard, so a JWT key can't be silently dropped under authType 'access-token' (bot consistency note). - Add regression tests: connect() on the useKernel path installs no OAuth provider (no eager browser flow) / a PAT-only provider when a token is present (bot F2); plus the new PAT+JWT ambiguity guard. Co-authored-by: Isaac Signed-off-by: Rahul Singhal --- lib/DBSQLClient.ts | 11 ++++- lib/contracts/IDBSQLClient.ts | 16 ------- lib/contracts/InternalConnectionOptions.ts | 51 ++++++++++++++++++++++ lib/kernel/KernelAuth.ts | 8 +++- tests/unit/DBSQLClient.test.ts | 51 ++++++++++++++++++++++ tests/unit/kernel/auth-m2m-jwt.test.ts | 16 +++++++ 6 files changed, 134 insertions(+), 19 deletions(-) diff --git a/lib/DBSQLClient.ts b/lib/DBSQLClient.ts index e72a548d..d537fb27 100644 --- a/lib/DBSQLClient.ts +++ b/lib/DBSQLClient.ts @@ -497,8 +497,17 @@ export default class DBSQLClient extends EventEmitter implements IDBSQLClient, I */ private mapAuthType(options: ConnectionOptions): string { switch (options.authType) { - case 'databricks-oauth': + case 'databricks-oauth': { + // JWT private-key M2M (kernel-only) presents no `oauthClientSecret`, + // so without this check it would misreport as `external-browser` + // (U2M) — the opposite of its machine-to-machine nature. The field + // lives on the internal options surface (see InternalConnectionOptions). + const { oauthJwtKeyFile } = options as ConnectionOptions & InternalConnectionOptions; + if (oauthJwtKeyFile !== undefined) { + return 'oauth-m2m-jwt'; + } return options.oauthClientSecret === undefined ? 'external-browser' : 'oauth-m2m'; + } case 'custom': return 'custom'; case 'token-provider': diff --git a/lib/contracts/IDBSQLClient.ts b/lib/contracts/IDBSQLClient.ts index f2e1621a..bbaa4c69 100644 --- a/lib/contracts/IDBSQLClient.ts +++ b/lib/contracts/IDBSQLClient.ts @@ -26,22 +26,6 @@ type AuthOptions = // U2M flow to `['sql', 'offline_access']` (parity with the Thrift driver's // `defaultOAuthScopes`), overriding the kernel's bare `all-apis offline_access`. oauthScopes?: Array; - // JWT private-key M2M (RFC 7523 client assertion) — KERNEL BACKEND ONLY - // (`useKernel: true`). Supplying `oauthJwtKeyFile` selects the JWT - // client-assertion flow: the kernel signs a short-lived assertion with the - // private key instead of sending a client secret. Requires `oauthClientId` - // and `oauthJwtKid`. Optional `oauthJwtPassphrase` (encrypted PKCS#8 key), - // `oauthJwtAlgorithm` (default `RS256`), `oauthScopes`, and `tokenUrl` (the - // IdP token endpoint — required when auth is against an external IdP such as - // Entra ID, which is where `private_key_jwt` is supported). Mutually - // exclusive with `oauthClientSecret`. - oauthJwtKeyFile?: string; - oauthJwtKid?: string; - oauthJwtPassphrase?: string; - oauthJwtAlgorithm?: string; - // OAuth token endpoint override (kernel backend). Points the M2M / - // JWT client-assertion grant at the workspace's IdP token endpoint. - tokenUrl?: string; } | { authType: 'custom'; diff --git a/lib/contracts/InternalConnectionOptions.ts b/lib/contracts/InternalConnectionOptions.ts index e2146a88..e5acc9f0 100644 --- a/lib/contracts/InternalConnectionOptions.ts +++ b/lib/contracts/InternalConnectionOptions.ts @@ -74,4 +74,55 @@ export interface InternalConnectionOptions { * @internal kernel path only. */ clientKeyPem?: Buffer | string; + + /** + * kernel-only: JWT private-key M2M (RFC 7523 client assertion). Supplying + * `oauthJwtKeyFile` (alongside `authType: 'databricks-oauth'`) selects the + * JWT client-assertion flow: the kernel signs a short-lived assertion with + * the private key instead of sending a client secret. Requires + * `oauthClientId` (the assertion issuer/subject) and `oauthJwtKid` (the key + * id written into the JWT header). Mutually exclusive with + * `oauthClientSecret`. + * + * These live on the internal options surface — NOT the public + * `databricks-oauth` `AuthOptions` — because the Thrift backend has no + * JWT client-assertion path; exposing them publicly would let a Thrift + * caller set them and have them silently ignored. The kernel path reads + * them via the `InternalConnectionOptions` cast, exactly like `useKernel` + * and the TLS knobs above. + * @internal kernel path only. + */ + oauthJwtKeyFile?: string; + + /** + * kernel-only: key id written into the JWT assertion header so the IdP can + * select the registered public key. Required when `oauthJwtKeyFile` is set. + * @internal kernel path only. + */ + oauthJwtKid?: string; + + /** + * kernel-only: passphrase for an encrypted PKCS#8 private key + * (`oauthJwtKeyFile`). Omit for an unencrypted key. + * @internal kernel path only. + */ + oauthJwtPassphrase?: string; + + /** + * kernel-only: JWT signing algorithm for the client assertion. Defaults to + * `RS256` in the kernel when omitted. + * @internal kernel path only. + */ + oauthJwtAlgorithm?: string; + + /** + * kernel-only: OAuth token-endpoint override. Points the M2M / + * JWT client-assertion grant at the workspace's IdP token endpoint — + * required when auth is against an external IdP such as Entra ID, which is + * where `private_key_jwt` is supported. Applies to both shared-secret M2M + * and JWT M2M (auth-method-agnostic, matching JDBC's + * `OAuth2ConnAuthTokenEndpoint`). + * @internal kernel path only. + */ + tokenUrl?: string; } diff --git a/lib/kernel/KernelAuth.ts b/lib/kernel/KernelAuth.ts index 4e3abd03..b90b7d14 100644 --- a/lib/kernel/KernelAuth.ts +++ b/lib/kernel/KernelAuth.ts @@ -629,9 +629,13 @@ export function buildKernelConnectionOptions(options: ConnectionOptions): Kernel "kernel backend: a non-empty PAT must be supplied via `token` when using `authType: 'access-token'`.", ); } - if (oauth.oauthClientId !== undefined || oauth.oauthClientSecret !== undefined) { + if ( + oauth.oauthClientId !== undefined || + oauth.oauthClientSecret !== undefined || + oauth.oauthJwtKeyFile !== undefined + ) { throw new HiveDriverError( - 'kernel backend: cannot supply both `token` and `oauthClientId`/`oauthClientSecret` ' + + 'kernel backend: cannot supply both `token` and `oauthClientId`/`oauthClientSecret`/`oauthJwtKeyFile` ' + "on the same connection. Pick one: 'access-token' (PAT) uses `token`; " + "'databricks-oauth' uses the OAuth fields.", ); diff --git a/tests/unit/DBSQLClient.test.ts b/tests/unit/DBSQLClient.test.ts index 312cf603..eba6fd1f 100644 --- a/tests/unit/DBSQLClient.test.ts +++ b/tests/unit/DBSQLClient.test.ts @@ -284,6 +284,57 @@ describe('DBSQLClient.connect', () => { } }); + it('useKernel: true with an OAuth flow installs NO auth provider (kernel owns auth; no eager browser flow)', async () => { + const client = new DBSQLClient(); + + // `useKernel` + `databricks-oauth` (U2M: no secret, no token). The kernel + // owns the full auth lifecycle here, so `connect()` must NOT build the + // connector's own OAuth provider (which would eagerly open a browser / + // run the token exchange at connect() time via a telemetry client). The + // authProvider is assigned before the backend connects, so it is set even + // though the subsequent KernelBackend connect() rejects (absent native + // binding in CI / no live workspace). + const kernelOAuthOptions = { + ...connectOptions, + token: undefined, + authType: 'databricks-oauth', + useKernel: true, + } as any; + + try { + await client.connect(kernelOAuthOptions); + } catch (error) { + if (error instanceof AssertionError || !(error instanceof Error)) { + throw error; + } + // Expected: KernelBackend connect() rejects (native binding absent / no + // live workspace). The contract under test is the authProvider decision, + // which happened before the throw. + } + + expect(client['authProvider']).to.be.undefined; + }); + + it('useKernel: true with a token installs a PAT-only PlainHttpAuthentication provider', async () => { + const client = new DBSQLClient(); + + // `useKernel` + a PAT: the connector hands the kernel a minimal PAT + // provider (for the telemetry / feature-flag clients) rather than + // undefined, and still must NOT build an OAuth provider. + const kernelPatOptions = { ...connectOptions, token: 'dapiXXXX', useKernel: true } as any; + + try { + await client.connect(kernelPatOptions); + } catch (error) { + if (error instanceof AssertionError || !(error instanceof Error)) { + throw error; + } + // Expected: KernelBackend connect() rejects (native binding absent). + } + + expect(client['authProvider']).to.be.instanceOf(PlainHttpAuthentication); + }); + it('populates config.customHeaders with org-id parsed from ?o= (SPOG)', async () => { const client = new DBSQLClient(); await client.connect({ ...connectOptions, path: '/sql/1.0/warehouses/abc?o=12345678901234' }); diff --git a/tests/unit/kernel/auth-m2m-jwt.test.ts b/tests/unit/kernel/auth-m2m-jwt.test.ts index 7c8ae216..5557b783 100644 --- a/tests/unit/kernel/auth-m2m-jwt.test.ts +++ b/tests/unit/kernel/auth-m2m-jwt.test.ts @@ -105,6 +105,22 @@ describe('KernelAuth — OAuth M2M JWT private-key auth flow', () => { ); }); + it('rejects a PAT `token` supplied alongside `oauthJwtKeyFile` (ambiguous)', () => { + // A JWT key under the PAT path (authType access-token) would otherwise be + // silently dropped; the PAT-branch ambiguity guard must reject it, just as + // it does for oauthClientId / oauthClientSecret. + expect(() => + buildKernelConnectionOptions({ + host: 'example.azuredatabricks.net', + path: '/sql/1.0/warehouses/abc', + authType: 'access-token', + token: 'dapiXXXX', + oauthJwtKeyFile: '/keys/jwt.pem', + oauthJwtKid: 'kid-1', + } as ConnectionOptions), + ).to.throw(HiveDriverError, /both `token` and .*`oauthJwtKeyFile`/); + }); + it('rejects persistence on the JWT M2M path', () => { expect(() => buildKernelConnectionOptions({ From 7b2110a288be6b30eb401fbf182d342da3e0d255 Mon Sep 17 00:00:00 2001 From: Rahul Singhal Date: Thu, 20 Aug 2026 20:05:13 +0000 Subject: [PATCH 3/4] test(kernel): cover tokenUrl forwarding on the shared-secret M2M branch Addresses PR #504 review (peco-review-bot Low): the token_url parity fix threaded tokenUrl through the OAuthM2m (shared-secret) branch, but only the JWT path had a tokenUrl test. Add M2M cases asserting tokenUrl forwards when supplied and is absent otherwise, mirroring the JWT tests and guarding the conditional spread at KernelAuth.ts against a future refactor. Co-authored-by: Isaac Signed-off-by: Rahul Singhal --- tests/unit/kernel/auth-m2m.test.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/unit/kernel/auth-m2m.test.ts b/tests/unit/kernel/auth-m2m.test.ts index 7b55bcb2..023ff951 100644 --- a/tests/unit/kernel/auth-m2m.test.ts +++ b/tests/unit/kernel/auth-m2m.test.ts @@ -67,6 +67,36 @@ describe('KernelAuth + KernelBackend — OAuth M2M auth flow', () => { expect((native as { oauthScopes?: string[] }).oauthScopes).to.deep.equal(['sql', 'offline_access']); }); + it('forwards a caller-supplied tokenUrl on the shared-secret M2M branch', () => { + // tokenUrl is auth-method-agnostic (matches JDBC's OAuth2ConnAuthTokenEndpoint): + // it applies to shared-secret M2M as well as the JWT path, pointing the + // client-credentials grant at an external IdP token endpoint. + const native = buildKernelConnectionOptions({ + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + authType: 'databricks-oauth', + oauthClientId: 'client-uuid', + oauthClientSecret: 'dose-fake-secret', + tokenUrl: 'https://login.microsoftonline.com/tenant/oauth2/v2.0/token', + } as ConnectionOptions); + expect(native.authMode).to.equal('OAuthM2m'); + expect((native as { tokenUrl?: string }).tokenUrl).to.equal( + 'https://login.microsoftonline.com/tenant/oauth2/v2.0/token', + ); + }); + + it('omits tokenUrl on the shared-secret M2M branch when not supplied', () => { + const native = buildKernelConnectionOptions({ + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + authType: 'databricks-oauth', + oauthClientId: 'client-uuid', + oauthClientSecret: 'dose-fake-secret', + }); + expect(native.authMode).to.equal('OAuthM2m'); + expect(native).to.not.have.property('tokenUrl'); + }); + it('prepends `/` to the path on the M2M branch too', () => { const opts: ConnectionOptions = { host: 'example.cloud.databricks.com', From 6d21d06b67c013b742a6d56420654b602da3ac12 Mon Sep 17 00:00:00 2001 From: Rahul Singhal Date: Thu, 20 Aug 2026 20:50:59 +0000 Subject: [PATCH 4/4] fix(kernel): gate JWT telemetry label on useKernel; warn on dropped authProvider Addresses PR #504 review (peco-review-bot, 2 Low): - mapAuthType keyed the `oauth-m2m-jwt` label purely off `oauthJwtKeyFile` and ran unconditionally. Since oauthJwtKeyFile is a kernel-only internal option, a Thrift-path connection that set it (and would actually run the U2M browser flow) got mislabeled. Gate the JWT label on `useKernel` so it reflects the backend that honors the field (bot F1). - On the useKernel path the deprecated custom `authProvider` arg was silently discarded (the kernel owns auth via the native binding, so a JS-side provider can't be plumbed through). Log a warning instead of dropping it silently so callers can diagnose it (bot F2). Adds mapAuthType tests (kernel vs Thrift JWT labeling) and a test asserting the authProvider-dropped warning. Co-authored-by: Isaac Signed-off-by: Rahul Singhal --- lib/DBSQLClient.ts | 21 +++++++++++-- tests/unit/DBSQLClient.test.ts | 56 ++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/lib/DBSQLClient.ts b/lib/DBSQLClient.ts index d537fb27..a063ca06 100644 --- a/lib/DBSQLClient.ts +++ b/lib/DBSQLClient.ts @@ -501,9 +501,12 @@ export default class DBSQLClient extends EventEmitter implements IDBSQLClient, I // JWT private-key M2M (kernel-only) presents no `oauthClientSecret`, // so without this check it would misreport as `external-browser` // (U2M) — the opposite of its machine-to-machine nature. The field - // lives on the internal options surface (see InternalConnectionOptions). - const { oauthJwtKeyFile } = options as ConnectionOptions & InternalConnectionOptions; - if (oauthJwtKeyFile !== undefined) { + // lives on the internal options surface (see InternalConnectionOptions) + // and is only honored on the kernel path; gate the label on `useKernel` + // so a Thrift-path connection (which has no JWT branch and would run + // the U2M browser flow) isn't mislabeled `oauth-m2m-jwt`. + const { oauthJwtKeyFile, useKernel } = options as ConnectionOptions & InternalConnectionOptions; + if (useKernel && oauthJwtKeyFile !== undefined) { return 'oauth-m2m-jwt'; } return options.oauthClientSecret === undefined ? 'external-browser' : 'oauth-m2m'; @@ -744,6 +747,18 @@ export default class DBSQLClient extends EventEmitter implements IDBSQLClient, I // hand over only a minimal PAT provider when a `token` is present, and // `undefined` otherwise. Mirrors Python's use_kernel auth-provider handling. if (internalOptions.useKernel) { + // The kernel owns auth via the native binding, so a JS-side custom + // `authProvider` (deprecated arg) genuinely can't be plumbed through. + // Warn rather than drop it silently, so a caller who passes one alongside + // `useKernel` can diagnose why their provider isn't used. + if (authProvider) { + this.logger.log( + LogLevel.warn, + 'DBSQLClient: a custom authProvider was supplied with useKernel; it is ignored because the ' + + 'kernel backend owns authentication via the native binding. Configure auth through the ' + + 'connection options (token / OAuth fields) instead.', + ); + } const { token } = options as { token?: string }; this.authProvider = typeof token === 'string' && token.length > 0 diff --git a/tests/unit/DBSQLClient.test.ts b/tests/unit/DBSQLClient.test.ts index eba6fd1f..80d76caa 100644 --- a/tests/unit/DBSQLClient.test.ts +++ b/tests/unit/DBSQLClient.test.ts @@ -335,6 +335,32 @@ describe('DBSQLClient.connect', () => { expect(client['authProvider']).to.be.instanceOf(PlainHttpAuthentication); }); + it('useKernel: true warns when a custom authProvider is supplied (it cannot be plumbed through)', async () => { + const client = new DBSQLClient(); + const logSpy = sinon.spy((client as any).logger, 'log'); + + // The kernel owns auth via the native binding, so a JS-side authProvider + // is ignored — but the drop must be warned, not silent. + const kernelOptions = { ...connectOptions, token: 'dapiXXXX', useKernel: true } as any; + + try { + await client.connect(kernelOptions, new AuthProviderStub()); + } catch (error) { + if (error instanceof AssertionError || !(error instanceof Error)) { + throw error; + } + // Expected: KernelBackend connect() rejects (native binding absent). The + // warning is emitted before the backend connects. + } + + const warned = logSpy + .getCalls() + .some((c) => c.args[0] === LogLevel.warn && /custom authProvider was supplied with useKernel/.test(c.args[1])); + expect(warned).to.be.true; + + logSpy.restore(); + }); + it('populates config.customHeaders with org-id parsed from ?o= (SPOG)', async () => { const client = new DBSQLClient(); await client.connect({ ...connectOptions, path: '/sql/1.0/warehouses/abc?o=12345678901234' }); @@ -348,6 +374,36 @@ describe('DBSQLClient.connect', () => { }); }); +describe('DBSQLClient.mapAuthType (telemetry authType)', () => { + it('labels databricks-oauth + oauthJwtKeyFile as oauth-m2m-jwt ONLY on the kernel path', () => { + const client = new DBSQLClient(); + + const kernelJwt = { + ...connectOptions, + token: undefined, + authType: 'databricks-oauth', + oauthJwtKeyFile: '/keys/jwt.pem', + useKernel: true, + } as any; + expect(client['mapAuthType'](kernelJwt)).to.equal('oauth-m2m-jwt'); + }); + + it('does NOT label a Thrift-path connection oauth-m2m-jwt even if oauthJwtKeyFile is set (no useKernel)', () => { + const client = new DBSQLClient(); + + // oauthJwtKeyFile is a kernel-only internal option; on the Thrift path a + // no-secret OAuth connection actually runs U2M (external-browser), so the + // label must reflect that rather than mislabeling it oauth-m2m-jwt. + const thriftJwt = { + ...connectOptions, + token: undefined, + authType: 'databricks-oauth', + oauthJwtKeyFile: '/keys/jwt.pem', + } as any; + expect(client['mapAuthType'](thriftJwt)).to.equal('external-browser'); + }); +}); + describe('DBSQLClient.openSession', () => { it('should successfully open session', async () => { const { client } = makeStubbedClient();