From 753609db3f411c2ae65127fbfa3dc2f8758a4cba Mon Sep 17 00:00:00 2001 From: Vu Anh Phung Date: Mon, 17 Aug 2026 18:34:11 +0000 Subject: [PATCH 1/7] feat(kernel): forward identity federation client ID --- KERNEL_REV | 2 +- lib/contracts/IDBSQLClient.ts | 4 ++++ lib/kernel/KernelAuth.ts | 20 +++++++++++++++++++- native/kernel/index.d.ts | 11 ++++++++--- tests/unit/kernel/auth-m2m.test.ts | 15 +++++++++++++++ tests/unit/kernel/auth-pat.test.ts | 22 ++++++++++++++++++++++ tests/unit/kernel/auth-u2m.test.ts | 11 +++++++++++ 7 files changed, 80 insertions(+), 5 deletions(-) diff --git a/KERNEL_REV b/KERNEL_REV index 7dd91996..95cfce81 100644 --- a/KERNEL_REV +++ b/KERNEL_REV @@ -1 +1 @@ -0d46716c466897148dfc1d2976ff03bdf097998c +eff8950428f4e6cc9975c663ec919f334962f7d0 diff --git a/lib/contracts/IDBSQLClient.ts b/lib/contracts/IDBSQLClient.ts index bbaa4c69..88bcb980 100644 --- a/lib/contracts/IDBSQLClient.ts +++ b/lib/contracts/IDBSQLClient.ts @@ -14,6 +14,8 @@ type AuthOptions = | { authType?: 'access-token'; token: string; + /** Kernel backend: selects mandatory SP-wide Workload Identity Federation. */ + identityFederationClientId?: string; } | { authType: 'databricks-oauth'; @@ -26,6 +28,8 @@ 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; + /** Kernel backend: selects mandatory SP-wide Workload Identity Federation. */ + identityFederationClientId?: string; } | { authType: 'custom'; diff --git a/lib/kernel/KernelAuth.ts b/lib/kernel/KernelAuth.ts index 7cf99afa..ee6aedc7 100644 --- a/lib/kernel/KernelAuth.ts +++ b/lib/kernel/KernelAuth.ts @@ -61,6 +61,9 @@ const DEFAULT_OAUTH_CLIENT_ID = 'databricks-sql-connector'; * everything else (client_id, scopes, callback timeout, * token_url_override) uses kernel defaults. * + * A non-empty `identityFederationClientId` selects mandatory SP-wide + * workload-identity token exchange for every auth mode. + * * The `authMode` string literals MUST match the napi-emitted `AuthMode` * variant names verbatim (`'Pat'`, `'OAuthM2m'`, `'OAuthU2m'` — napi-rs's * `#[napi(string_enum)]` without an explicit case option emits the @@ -212,10 +215,19 @@ export interface KernelProxyOptions { }; } +export interface KernelFederationOptions { + /** + * SP-wide Workload Identity Federation client id. Omitted selects BYOT / + * account-wide WIF. + */ + identityFederationClientId?: string; +} + export type KernelNativeConnectionOptions = KernelSessionDefaults & KernelTlsOptions & KernelHttpOptions & KernelProxyOptions & + KernelFederationOptions & ( | { hostName: string; @@ -556,7 +568,8 @@ export function buildKernelConnectionOptions(options: ConnectionOptions): Kernel maxConnections?: number; } & KernelTlsOptions & KernelHttpOptions & - KernelProxyOptions = { + KernelProxyOptions & + KernelFederationOptions = { hostName: options.host, httpPath: prependSlash(options.path), // Match the NodeJS Thrift driver, which surfaces INTERVAL columns as @@ -576,6 +589,11 @@ export function buildKernelConnectionOptions(options: ConnectionOptions): Kernel ...buildKernelProxyOptions(options), }; + const { identityFederationClientId } = options as { identityFederationClientId?: string }; + if (identityFederationClientId) { + base.identityFederationClientId = identityFederationClientId; + } + // kernel-only pool sizing; read via cast to match how this function reads the // other kernel-specific options (TLS) — they live on the internal options // surface, not the published public `ConnectionOptions` `.d.ts`. diff --git a/native/kernel/index.d.ts b/native/kernel/index.d.ts index 0b042121..f401c31e 100644 --- a/native/kernel/index.d.ts +++ b/native/kernel/index.d.ts @@ -159,7 +159,7 @@ export interface ProxyInput { * - `Pat` — `token` required. * - `OAuthM2m` — `oauthClientId` + `oauthClientSecret` required. * - `OAuthU2m` — `oauthClientId` / `oauthRedirectPort` optional - * (defaults to the `databricks-sql-connector` client on port 8020). + * (defaults to the `databricks-sql-connector` client on port 8030). * * Catalog / schema / sessionConf are applied once at session creation * and remain in effect for every statement run on the resulting @@ -197,14 +197,19 @@ export interface ConnectionOptions { oauthClientSecret?: string /** * Localhost callback port for the [`AuthMode::OAuthU2m`] browser - * flow. Omitted ⇒ kernel default (8020). + * flow. Omitted ⇒ kernel default (8030). */ oauthRedirectPort?: number /** * OAuth scopes override (M2M / U2M). Omitted ⇒ kernel defaults - * (`["all-apis"]` for M2M; `["all-apis", "offline_access"]` for U2M). + * (`["all-apis"]` for M2M; `["sql", "offline_access"]` for U2M). */ oauthScopes?: Array + /** + * SP-wide Workload Identity Federation client id used during mandatory + * token exchange. Omitted selects BYOT / account-wide WIF. + */ + identityFederationClientId?: string /** * Default catalog for statements executed on this session. * Routed through the kernel's `DefaultOpts` and onto the SEA diff --git a/tests/unit/kernel/auth-m2m.test.ts b/tests/unit/kernel/auth-m2m.test.ts index 7b55bcb2..7df6b6d3 100644 --- a/tests/unit/kernel/auth-m2m.test.ts +++ b/tests/unit/kernel/auth-m2m.test.ts @@ -43,6 +43,19 @@ describe('KernelAuth + KernelBackend — OAuth M2M auth flow', () => { }); }); + it('forwards a federation client id on M2M auth', () => { + const native = buildKernelConnectionOptions({ + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + authType: 'databricks-oauth', + oauthClientId: 'client-uuid', + oauthClientSecret: 'dose-fake-secret', + identityFederationClientId: 'federation-client', + }); + + expect(native.identityFederationClientId).to.equal('federation-client'); + }); + it('defaults M2M oauthScopes to all-apis (Thrift + kernel parity)', () => { const native = buildKernelConnectionOptions({ host: 'example.cloud.databricks.com', @@ -190,6 +203,7 @@ describe('KernelAuth + KernelBackend — OAuth M2M auth flow', () => { authType: 'databricks-oauth', oauthClientId: 'client-uuid', oauthClientSecret: 'dose-fake-secret', + identityFederationClientId: 'federation-client', }); const session = await backend.openSession({}); @@ -207,6 +221,7 @@ describe('KernelAuth + KernelBackend — OAuth M2M auth flow', () => { oauthClientId: 'client-uuid', oauthClientSecret: 'dose-fake-secret', oauthScopes: ['all-apis'], + identityFederationClientId: 'federation-client', }); await session.close(); diff --git a/tests/unit/kernel/auth-pat.test.ts b/tests/unit/kernel/auth-pat.test.ts index 5304298c..9fac0088 100644 --- a/tests/unit/kernel/auth-pat.test.ts +++ b/tests/unit/kernel/auth-pat.test.ts @@ -53,6 +53,28 @@ describe('KernelAuth — PAT auth options builder', () => { } }); + it('forwards a federation client id on PAT auth', () => { + const native = buildKernelConnectionOptions({ + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + token: 'dapi-fake-pat', + identityFederationClientId: 'federation-client', + }); + + expect(native.identityFederationClientId).to.equal('federation-client'); + }); + + it('omits an empty federation client id', () => { + const native = buildKernelConnectionOptions({ + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + token: 'dapi-fake-pat', + identityFederationClientId: '', + }); + + expect(native).not.to.have.property('identityFederationClientId'); + }); + it('prepends `/` to a path missing the leading slash', () => { const opts: ConnectionOptions = { host: 'example.cloud.databricks.com', diff --git a/tests/unit/kernel/auth-u2m.test.ts b/tests/unit/kernel/auth-u2m.test.ts index c21493d5..3ca8b4cc 100644 --- a/tests/unit/kernel/auth-u2m.test.ts +++ b/tests/unit/kernel/auth-u2m.test.ts @@ -40,6 +40,17 @@ describe('KernelAuth + KernelBackend — OAuth U2M auth flow', () => { }); }); + it('forwards a federation client id on U2M auth', () => { + const native = buildKernelConnectionOptions({ + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + authType: 'databricks-oauth', + identityFederationClientId: 'federation-client', + }); + + expect(native.identityFederationClientId).to.equal('federation-client'); + }); + it('defaults U2M oauthScopes to Thrift parity (sql offline_access)', () => { const native = buildKernelConnectionOptions({ host: 'example.cloud.databricks.com', From f26244c7cf934e4780363293e380a9f745952a4b Mon Sep 17 00:00:00 2001 From: Vu Anh Phung Date: Wed, 19 Aug 2026 20:17:52 +0000 Subject: [PATCH 2/7] feat(kernel): support static token federation --- CONNECTION_PARAMETERS.md | 23 +++--- lib/contracts/IDBSQLClient.ts | 4 - lib/kernel/KernelAuth.ts | 35 ++++++-- lib/kernel/KernelBackend.ts | 11 ++- tests/unit/kernel/auth-m2m.test.ts | 15 ---- tests/unit/kernel/auth-pat.test.ts | 26 +----- tests/unit/kernel/auth-static-token.test.ts | 91 +++++++++++++++++++++ tests/unit/kernel/auth-u2m.test.ts | 11 --- 8 files changed, 136 insertions(+), 80 deletions(-) create mode 100644 tests/unit/kernel/auth-static-token.test.ts diff --git a/CONNECTION_PARAMETERS.md b/CONNECTION_PARAMETERS.md index 30853562..dd2f6011 100644 --- a/CONNECTION_PARAMETERS.md +++ b/CONNECTION_PARAMETERS.md @@ -55,16 +55,16 @@ column. ## Authentication -| Option | Type | Thrift | Kernel | Default Value | Note | -| ---------------------------------------------- | -------------------------------------------------------------------------- | :------: | :------: | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `authType` — supported on both backends | `'access-token'` \| `'databricks-oauth'` | ✅ | ✅ | `'access-token'` | The two auth modes both backends accept. `access-token` uses `token` (PAT) and is the default when `authType` is omitted. `databricks-oauth` covers M2M (`oauthClientId` + `oauthClientSecret`; kernel runs OIDC discovery + client-credentials internally) and U2M (browser; no secret — kernel U2M differs slightly, see the OAuth sub-option rows below). | -| `authType` — Thrift-only | `'custom'` \| `'token-provider'` \| `'external-token'` \| `'static-token'` | ✅ | ❌ | — | **Thrift-only.** `custom` (`provider: IAuthentication`), `token-provider` (`tokenProvider: ITokenProvider`), `external-token` (`getToken: TokenCallback`), `static-token` (`staticToken`). The kernel throws `unsupported auth mode` for all four — it supports only the two modes above. | -| `oauthScopes` | `Array` | ❌ | ✅ | U2M `['sql','offline_access']`, M2M `['all-apis']` | **Thrift ignores `oauthScopes`** — `createAuthProvider` never threads it into `DatabricksOAuth`, so `authenticate()` always falls back to `defaultOAuthScopes` (`['sql','offline_access']`). Only the kernel honors a custom `oauthScopes`; its defaults happen to match Thrift's fallback. | -| `oauthClientId` (U2M) | `string` | ✅ | ✅ | napi default `client_id` when absent | The kernel adapter (`buildKernelConnectionOptions`) forwards a custom `oauthClientId` verbatim on the U2M arm; when it is absent the napi binding applies its own default `client_id`. Whether the native binding then honors or rejects a custom id is not observable from this repo — the TypeScript layer neither hardcodes an id nor rejects one. | -| `oauthClientId` + no secret | `string` | ✅ (U2M) | ✅ (U2M) | — | **Parity.** The kernel keys flow selection off `oauthClientSecret` presence exactly like Thrift, so `oauthClientId` + no secret routes to **U2M** (with the id forwarded) — it does **not** throw an M2M "secret required" error. | -| `azureTenantId` / `useDatabricksOAuthInAzure` | `string` / `boolean` | ✅ | ❌ | — | **Thrift-only.** Kernel rejects Azure-direct (Entra) OAuth; workspace-OIDC discovery covers Azure workspaces without it. | -| `persistence` (custom OAuth token store) | `OAuthPersistence` | ✅ | ❌ | — | **Thrift-only.** Kernel throws; it auto-persists U2M tokens to `~/.config/databricks-sql-kernel/oauth/` and does not cache M2M. | -| `enableTokenFederation` / `federationClientId` | `boolean` / `string` | ✅ | ❌ | `false` / — | **Thrift-only** (available on the token-provider / external-token / static-token arms, none of which the kernel supports). | +| Option | Type | Thrift | Kernel | Default Value | Note | +| ---------------------------------------------- | ------------------------------------------------------------ | :------: | :------: | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `authType` — supported on both backends | `'access-token'` \| `'databricks-oauth'` \| `'static-token'` | ✅ | ✅ | `'access-token'` | `access-token` uses `token` (PAT) and is the default when `authType` is omitted. `static-token` uses `staticToken`; the kernel maps it to its native bearer-token mode. `databricks-oauth` covers M2M (`oauthClientId` + `oauthClientSecret`) and U2M (browser; no secret). | +| `authType` — Thrift-only | `'custom'` \| `'token-provider'` \| `'external-token'` | ✅ | ❌ | — | **Thrift-only.** `custom` uses `provider: IAuthentication`, `token-provider` uses `tokenProvider: ITokenProvider`, and `external-token` uses `getToken: TokenCallback`. The kernel throws `unsupported auth mode` for these modes. | +| `oauthScopes` | `Array` | ❌ | ✅ | U2M `['sql','offline_access']`, M2M `['all-apis']` | **Thrift ignores `oauthScopes`** — `createAuthProvider` never threads it into `DatabricksOAuth`, so `authenticate()` always falls back to `defaultOAuthScopes` (`['sql','offline_access']`). Only the kernel honors a custom `oauthScopes`; its defaults happen to match Thrift's fallback. | +| `oauthClientId` (U2M) | `string` | ✅ | ✅ | napi default `client_id` when absent | The kernel adapter (`buildKernelConnectionOptions`) forwards a custom `oauthClientId` verbatim on the U2M arm; when it is absent the napi binding applies its own default `client_id`. Whether the native binding then honors or rejects a custom id is not observable from this repo — the TypeScript layer neither hardcodes an id nor rejects one. | +| `oauthClientId` + no secret | `string` | ✅ (U2M) | ✅ (U2M) | — | **Parity.** The kernel keys flow selection off `oauthClientSecret` presence exactly like Thrift, so `oauthClientId` + no secret routes to **U2M** (with the id forwarded) — it does **not** throw an M2M "secret required" error. | +| `azureTenantId` / `useDatabricksOAuthInAzure` | `string` / `boolean` | ✅ | ❌ | — | **Thrift-only.** Kernel rejects Azure-direct (Entra) OAuth; workspace-OIDC discovery covers Azure workspaces without it. | +| `persistence` (custom OAuth token store) | `OAuthPersistence` | ✅ | ❌ | — | **Thrift-only.** Kernel throws; it auto-persists U2M tokens to `~/.config/databricks-sql-kernel/oauth/` and does not cache M2M. | +| `enableTokenFederation` / `federationClientId` | `boolean` / `string` | ✅ | ⚠️ | `false` / — | On the kernel backend these options are honored only for `static-token`; `federationClientId` is forwarded only when `enableTokenFederation` is `true`. The Thrift backend also supports them for `token-provider` and `external-token`. | ## HTTP client, proxy, retries @@ -161,8 +161,7 @@ backend, so they are read regardless of `useKernel`. Defaults are sourced from 1. `enableMetricViewMetadata` — auto-injected for both backends in `DBSQLClient.openSession`, but the conf key is likely dropped by the kernel's session-conf allowlist, so it has no effect on the kernel path. -2. Auth types `custom`, `token-provider`, `external-token`, `static-token`, - plus `enableTokenFederation` / `federationClientId`. +2. Auth types `custom`, `token-provider`, and `external-token`. 3. `azureTenantId` / `useDatabricksOAuthInAzure` (Azure-direct OAuth). 4. `persistence` (custom OAuth token store). 5. SOCKS proxies. diff --git a/lib/contracts/IDBSQLClient.ts b/lib/contracts/IDBSQLClient.ts index 88bcb980..bbaa4c69 100644 --- a/lib/contracts/IDBSQLClient.ts +++ b/lib/contracts/IDBSQLClient.ts @@ -14,8 +14,6 @@ type AuthOptions = | { authType?: 'access-token'; token: string; - /** Kernel backend: selects mandatory SP-wide Workload Identity Federation. */ - identityFederationClientId?: string; } | { authType: 'databricks-oauth'; @@ -28,8 +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; - /** Kernel backend: selects mandatory SP-wide Workload Identity Federation. */ - identityFederationClientId?: string; } | { authType: 'custom'; diff --git a/lib/kernel/KernelAuth.ts b/lib/kernel/KernelAuth.ts index ee6aedc7..8e4b4123 100644 --- a/lib/kernel/KernelAuth.ts +++ b/lib/kernel/KernelAuth.ts @@ -61,8 +61,9 @@ const DEFAULT_OAUTH_CLIENT_ID = 'databricks-sql-connector'; * everything else (client_id, scopes, callback timeout, * token_url_override) uses kernel defaults. * - * A non-empty `identityFederationClientId` selects mandatory SP-wide - * workload-identity token exchange for every auth mode. + * `static-token` reuses the native PAT bearer-token mode. When its + * `enableTokenFederation` option is true, a non-empty `federationClientId` + * is forwarded under the native name `identityFederationClientId`. * * The `authMode` string literals MUST match the napi-emitted `AuthMode` * variant names verbatim (`'Pat'`, `'OAuthM2m'`, `'OAuthU2m'` — napi-rs's @@ -455,6 +456,10 @@ export function buildKernelHttpOptions(options: ConnectionOptions): KernelHttpOp * - PAT: `authType: 'access-token'` (or undefined, which already means * PAT throughout the existing driver — see * `DBSQLClient.createAuthProvider`). + * - Static token: `authType: 'static-token'` + `staticToken`. The token is + * forwarded through the native PAT bearer-token mode. Optional SP-wide + * token federation is enabled by `enableTokenFederation` and selected by + * `federationClientId`. * - OAuth M2M: `authType: 'databricks-oauth'` + `oauthClientId` + * `oauthClientSecret`. Kernel handles OIDC discovery, client_credentials * exchange, and re-auth on expiry internally. @@ -589,11 +594,6 @@ export function buildKernelConnectionOptions(options: ConnectionOptions): Kernel ...buildKernelProxyOptions(options), }; - const { identityFederationClientId } = options as { identityFederationClientId?: string }; - if (identityFederationClientId) { - base.identityFederationClientId = identityFederationClientId; - } - // kernel-only pool sizing; read via cast to match how this function reads the // other kernel-specific options (TLS) — they live on the internal options // surface, not the published public `ConnectionOptions` `.d.ts`. @@ -639,6 +639,25 @@ export function buildKernelConnectionOptions(options: ConnectionOptions): Kernel return { ...base, authMode: 'Pat', token }; } + if (authType === 'static-token') { + const { staticToken, enableTokenFederation, federationClientId } = options as { + staticToken?: string; + enableTokenFederation?: boolean; + federationClientId?: string; + }; + if (typeof staticToken !== 'string' || isBlankOrReserved(staticToken)) { + throw new AuthenticationError( + "kernel backend: a non-empty token must be supplied via `staticToken` when using `authType: 'static-token'`.", + ); + } + return { + ...base, + authMode: 'Pat', + token: staticToken, + ...(enableTokenFederation && federationClientId ? { identityFederationClientId: federationClientId } : {}), + }; + } + if (authType === 'databricks-oauth') { if ((options as { token?: string }).token !== undefined) { throw new HiveDriverError( @@ -712,7 +731,7 @@ export function buildKernelConnectionOptions(options: ConnectionOptions): Kernel throw new HiveDriverError( `kernel backend: unsupported auth mode '${authType}'. ` + - "Supported modes on the kernel backend today: 'access-token' (PAT) and 'databricks-oauth' " + + "Supported modes on the kernel backend today: 'access-token' (PAT), 'static-token', and 'databricks-oauth' " + '(M2M with oauthClientId+oauthClientSecret, or U2M with neither).', ); } diff --git a/lib/kernel/KernelBackend.ts b/lib/kernel/KernelBackend.ts index 221e7beb..2b98a64b 100644 --- a/lib/kernel/KernelBackend.ts +++ b/lib/kernel/KernelBackend.ts @@ -47,10 +47,10 @@ export interface KernelBackendOptions { * kernel-backed implementation of `IBackend`. * * **M0 dispatch model:** the napi binding's `openSession()` already - * builds a kernel `Session` from PAT + hostname + httpPath, so there is + * builds a kernel `Session` from auth options + hostname + httpPath, so there is * no "connect" round-trip before `openSession` — `connect()` only - * captures the `ConnectionOptions` and validates that PAT auth is in - * use. The actual session open happens inside `openSession()`. + * captures and validates the `ConnectionOptions`. The actual session open + * happens inside `openSession()`. * * **Auth validation:** delegates to `buildKernelConnectionOptions` from * `KernelAuth`, which mirrors the existing DBSQLClient validation pattern @@ -84,9 +84,8 @@ export default class KernelBackend implements IBackend { } public async connect(options: ConnectionOptions): Promise { - // Validate PAT auth + capture the napi-binding option shape. - // Any non-PAT mode (or a missing/empty token) throws here, before - // we ever touch the native binding. + // Validate auth + capture the napi-binding option shape before touching + // the native binding. // Forward the driver's retry config to the kernel, which owns the retry // loop on the kernel path. This keeps kernel and Thrift governed by one retry // config (the same `ClientConfig` knobs the Thrift `HttpRetryPolicy` reads), diff --git a/tests/unit/kernel/auth-m2m.test.ts b/tests/unit/kernel/auth-m2m.test.ts index 7df6b6d3..7b55bcb2 100644 --- a/tests/unit/kernel/auth-m2m.test.ts +++ b/tests/unit/kernel/auth-m2m.test.ts @@ -43,19 +43,6 @@ describe('KernelAuth + KernelBackend — OAuth M2M auth flow', () => { }); }); - it('forwards a federation client id on M2M auth', () => { - const native = buildKernelConnectionOptions({ - host: 'example.cloud.databricks.com', - path: '/sql/1.0/warehouses/abc', - authType: 'databricks-oauth', - oauthClientId: 'client-uuid', - oauthClientSecret: 'dose-fake-secret', - identityFederationClientId: 'federation-client', - }); - - expect(native.identityFederationClientId).to.equal('federation-client'); - }); - it('defaults M2M oauthScopes to all-apis (Thrift + kernel parity)', () => { const native = buildKernelConnectionOptions({ host: 'example.cloud.databricks.com', @@ -203,7 +190,6 @@ describe('KernelAuth + KernelBackend — OAuth M2M auth flow', () => { authType: 'databricks-oauth', oauthClientId: 'client-uuid', oauthClientSecret: 'dose-fake-secret', - identityFederationClientId: 'federation-client', }); const session = await backend.openSession({}); @@ -221,7 +207,6 @@ describe('KernelAuth + KernelBackend — OAuth M2M auth flow', () => { oauthClientId: 'client-uuid', oauthClientSecret: 'dose-fake-secret', oauthScopes: ['all-apis'], - identityFederationClientId: 'federation-client', }); await session.close(); diff --git a/tests/unit/kernel/auth-pat.test.ts b/tests/unit/kernel/auth-pat.test.ts index 9fac0088..7d8b7b25 100644 --- a/tests/unit/kernel/auth-pat.test.ts +++ b/tests/unit/kernel/auth-pat.test.ts @@ -53,28 +53,6 @@ describe('KernelAuth — PAT auth options builder', () => { } }); - it('forwards a federation client id on PAT auth', () => { - const native = buildKernelConnectionOptions({ - host: 'example.cloud.databricks.com', - path: '/sql/1.0/warehouses/abc', - token: 'dapi-fake-pat', - identityFederationClientId: 'federation-client', - }); - - expect(native.identityFederationClientId).to.equal('federation-client'); - }); - - it('omits an empty federation client id', () => { - const native = buildKernelConnectionOptions({ - host: 'example.cloud.databricks.com', - path: '/sql/1.0/warehouses/abc', - token: 'dapi-fake-pat', - identityFederationClientId: '', - }); - - expect(native).not.to.have.property('identityFederationClientId'); - }); - it('prepends `/` to a path missing the leading slash', () => { const opts: ConnectionOptions = { host: 'example.cloud.databricks.com', @@ -133,8 +111,8 @@ describe('KernelAuth — PAT auth options builder', () => { ); }); - it('rejects external-token, static-token, and custom auth modes', () => { - const authTypes = ['external-token', 'static-token', 'custom'] as const; + it('rejects external-token and custom auth modes', () => { + const authTypes = ['external-token', 'custom'] as const; for (const authType of authTypes) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const opts = { diff --git a/tests/unit/kernel/auth-static-token.test.ts b/tests/unit/kernel/auth-static-token.test.ts new file mode 100644 index 00000000..708fb69b --- /dev/null +++ b/tests/unit/kernel/auth-static-token.test.ts @@ -0,0 +1,91 @@ +// 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 expectNativeConnectionOptions from './_helpers/nativeOptions'; +import { buildKernelConnectionOptions } from '../../../lib/kernel/KernelAuth'; +import AuthenticationError from '../../../lib/errors/AuthenticationError'; + +describe('KernelAuth — static-token auth options builder', () => { + it('maps a static token to the native bearer-token mode', () => { + const native = buildKernelConnectionOptions({ + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + authType: 'static-token', + staticToken: 'header.payload.signature', + }); + + expectNativeConnectionOptions(native, { + hostName: 'example.cloud.databricks.com', + httpPath: '/sql/1.0/warehouses/abc', + intervalsAsString: true, + authMode: 'Pat', + token: 'header.payload.signature', + }); + }); + + it('forwards federationClientId when token federation is enabled', () => { + const native = buildKernelConnectionOptions({ + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + authType: 'static-token', + staticToken: 'header.payload.signature', + enableTokenFederation: true, + federationClientId: 'federation-client', + }); + + expect(native.identityFederationClientId).to.equal('federation-client'); + }); + + it('does not forward federationClientId when token federation is disabled', () => { + for (const enableTokenFederation of [undefined, false]) { + const native = buildKernelConnectionOptions({ + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + authType: 'static-token', + staticToken: 'header.payload.signature', + enableTokenFederation, + federationClientId: 'federation-client', + }); + + expect(native).not.to.have.property('identityFederationClientId'); + } + }); + + it('omits an empty federationClientId', () => { + const native = buildKernelConnectionOptions({ + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + authType: 'static-token', + staticToken: 'header.payload.signature', + enableTokenFederation: true, + federationClientId: '', + }); + + expect(native).not.to.have.property('identityFederationClientId'); + }); + + it('rejects a missing or blank static token', () => { + for (const staticToken of [undefined, '', ' ', 'undefined', 'null']) { + expect(() => + buildKernelConnectionOptions({ + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + authType: 'static-token', + staticToken, + } as any), + ).to.throw(AuthenticationError, /non-empty token.*`staticToken`/); + } + }); +}); diff --git a/tests/unit/kernel/auth-u2m.test.ts b/tests/unit/kernel/auth-u2m.test.ts index 3ca8b4cc..c21493d5 100644 --- a/tests/unit/kernel/auth-u2m.test.ts +++ b/tests/unit/kernel/auth-u2m.test.ts @@ -40,17 +40,6 @@ describe('KernelAuth + KernelBackend — OAuth U2M auth flow', () => { }); }); - it('forwards a federation client id on U2M auth', () => { - const native = buildKernelConnectionOptions({ - host: 'example.cloud.databricks.com', - path: '/sql/1.0/warehouses/abc', - authType: 'databricks-oauth', - identityFederationClientId: 'federation-client', - }); - - expect(native.identityFederationClientId).to.equal('federation-client'); - }); - it('defaults U2M oauthScopes to Thrift parity (sql offline_access)', () => { const native = buildKernelConnectionOptions({ host: 'example.cloud.databricks.com', From b2016b7d41b8d470506b6327eee10289ee85811a Mon Sep 17 00:00:00 2001 From: Vu Anh Phung Date: Wed, 19 Aug 2026 21:36:15 +0000 Subject: [PATCH 3/7] fix(kernel): reject conflicting static token auth --- lib/kernel/KernelAuth.ts | 9 ++++++++- tests/unit/kernel/auth-static-token.test.ts | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/lib/kernel/KernelAuth.ts b/lib/kernel/KernelAuth.ts index 8e4b4123..7024e2b1 100644 --- a/lib/kernel/KernelAuth.ts +++ b/lib/kernel/KernelAuth.ts @@ -640,8 +640,9 @@ export function buildKernelConnectionOptions(options: ConnectionOptions): Kernel } if (authType === 'static-token') { - const { staticToken, enableTokenFederation, federationClientId } = options as { + const { staticToken, token, enableTokenFederation, federationClientId } = options as { staticToken?: string; + token?: string; enableTokenFederation?: boolean; federationClientId?: string; }; @@ -650,6 +651,12 @@ export function buildKernelConnectionOptions(options: ConnectionOptions): Kernel "kernel backend: a non-empty token must be supplied via `staticToken` when using `authType: 'static-token'`.", ); } + if (token !== undefined || oauth.oauthClientId !== undefined || oauth.oauthClientSecret !== undefined) { + throw new HiveDriverError( + 'kernel backend: cannot supply `staticToken` alongside `token` or ' + + '`oauthClientId`/`oauthClientSecret` on the same connection. Pick one auth mode.', + ); + } return { ...base, authMode: 'Pat', diff --git a/tests/unit/kernel/auth-static-token.test.ts b/tests/unit/kernel/auth-static-token.test.ts index 708fb69b..21e65f87 100644 --- a/tests/unit/kernel/auth-static-token.test.ts +++ b/tests/unit/kernel/auth-static-token.test.ts @@ -16,6 +16,7 @@ import { expect } from 'chai'; import expectNativeConnectionOptions from './_helpers/nativeOptions'; import { buildKernelConnectionOptions } from '../../../lib/kernel/KernelAuth'; import AuthenticationError from '../../../lib/errors/AuthenticationError'; +import HiveDriverError from '../../../lib/errors/HiveDriverError'; describe('KernelAuth — static-token auth options builder', () => { it('maps a static token to the native bearer-token mode', () => { @@ -88,4 +89,22 @@ describe('KernelAuth — static-token auth options builder', () => { ).to.throw(AuthenticationError, /non-empty token.*`staticToken`/); } }); + + it('rejects conflicting token and OAuth credentials', () => { + for (const conflicting of [ + { token: 'dapi-pat' }, + { oauthClientId: 'oauth-client' }, + { oauthClientSecret: 'oauth-secret' }, + ]) { + expect(() => + buildKernelConnectionOptions({ + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + authType: 'static-token', + staticToken: 'header.payload.signature', + ...conflicting, + } as any), + ).to.throw(HiveDriverError, /cannot supply `staticToken` alongside/); + } + }); }); From 376cc0e5eab47edd44be6380b677c79c77b9ff79 Mon Sep 17 00:00:00 2001 From: Vu Anh Phung Date: Wed, 19 Aug 2026 21:42:14 +0000 Subject: [PATCH 4/7] test(kernel): narrow static token conflict coverage --- tests/unit/kernel/auth-static-token.test.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/unit/kernel/auth-static-token.test.ts b/tests/unit/kernel/auth-static-token.test.ts index 21e65f87..733c9369 100644 --- a/tests/unit/kernel/auth-static-token.test.ts +++ b/tests/unit/kernel/auth-static-token.test.ts @@ -90,12 +90,8 @@ describe('KernelAuth — static-token auth options builder', () => { } }); - it('rejects conflicting token and OAuth credentials', () => { - for (const conflicting of [ - { token: 'dapi-pat' }, - { oauthClientId: 'oauth-client' }, - { oauthClientSecret: 'oauth-secret' }, - ]) { + it('rejects conflicting OAuth credentials', () => { + for (const conflicting of [{ oauthClientId: 'oauth-client' }, { oauthClientSecret: 'oauth-secret' }]) { expect(() => buildKernelConnectionOptions({ host: 'example.cloud.databricks.com', From b23baa673ea04ed1b6a255e7cac2cb3eff0d3079 Mon Sep 17 00:00:00 2001 From: Vu Anh Phung Date: Wed, 19 Aug 2026 21:43:16 +0000 Subject: [PATCH 5/7] fix(kernel): narrow static token ambiguity guard --- lib/kernel/KernelAuth.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/lib/kernel/KernelAuth.ts b/lib/kernel/KernelAuth.ts index 7024e2b1..c45c658d 100644 --- a/lib/kernel/KernelAuth.ts +++ b/lib/kernel/KernelAuth.ts @@ -640,9 +640,8 @@ export function buildKernelConnectionOptions(options: ConnectionOptions): Kernel } if (authType === 'static-token') { - const { staticToken, token, enableTokenFederation, federationClientId } = options as { + const { staticToken, enableTokenFederation, federationClientId } = options as { staticToken?: string; - token?: string; enableTokenFederation?: boolean; federationClientId?: string; }; @@ -651,10 +650,10 @@ export function buildKernelConnectionOptions(options: ConnectionOptions): Kernel "kernel backend: a non-empty token must be supplied via `staticToken` when using `authType: 'static-token'`.", ); } - if (token !== undefined || oauth.oauthClientId !== undefined || oauth.oauthClientSecret !== undefined) { + if (oauth.oauthClientId !== undefined || oauth.oauthClientSecret !== undefined) { throw new HiveDriverError( - 'kernel backend: cannot supply `staticToken` alongside `token` or ' + - '`oauthClientId`/`oauthClientSecret` on the same connection. Pick one auth mode.', + 'kernel backend: cannot supply `staticToken` alongside `oauthClientId`/`oauthClientSecret` ' + + 'on the same connection. Pick one auth mode.', ); } return { From 1f66bf1d987a5de8ef47789eda9d096662fea5a8 Mon Sep 17 00:00:00 2001 From: Vu Anh Phung Date: Wed, 19 Aug 2026 22:14:19 +0000 Subject: [PATCH 6/7] fix(kernel): preserve account-wide federation intent --- CONNECTION_PARAMETERS.md | 2 +- lib/kernel/KernelAuth.ts | 10 ++++------ tests/unit/kernel/auth-static-token.test.ts | 22 +++++++++++---------- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/CONNECTION_PARAMETERS.md b/CONNECTION_PARAMETERS.md index dd2f6011..757101ba 100644 --- a/CONNECTION_PARAMETERS.md +++ b/CONNECTION_PARAMETERS.md @@ -64,7 +64,7 @@ column. | `oauthClientId` + no secret | `string` | ✅ (U2M) | ✅ (U2M) | — | **Parity.** The kernel keys flow selection off `oauthClientSecret` presence exactly like Thrift, so `oauthClientId` + no secret routes to **U2M** (with the id forwarded) — it does **not** throw an M2M "secret required" error. | | `azureTenantId` / `useDatabricksOAuthInAzure` | `string` / `boolean` | ✅ | ❌ | — | **Thrift-only.** Kernel rejects Azure-direct (Entra) OAuth; workspace-OIDC discovery covers Azure workspaces without it. | | `persistence` (custom OAuth token store) | `OAuthPersistence` | ✅ | ❌ | — | **Thrift-only.** Kernel throws; it auto-persists U2M tokens to `~/.config/databricks-sql-kernel/oauth/` and does not cache M2M. | -| `enableTokenFederation` / `federationClientId` | `boolean` / `string` | ✅ | ⚠️ | `false` / — | On the kernel backend these options are honored only for `static-token`; `federationClientId` is forwarded only when `enableTokenFederation` is `true`. The Thrift backend also supports them for `token-provider` and `external-token`. | +| `enableTokenFederation` / `federationClientId` | `boolean` / `string` | ✅ | ⚠️ | `false` / — | On the kernel backend these options are honored only for `static-token`. When enabled, an omitted or empty client ID selects account-wide WIF; a non-empty ID selects SP-wide WIF. The Thrift backend also supports them for `token-provider` and `external-token`. | ## HTTP client, proxy, retries diff --git a/lib/kernel/KernelAuth.ts b/lib/kernel/KernelAuth.ts index c45c658d..70a0c2b1 100644 --- a/lib/kernel/KernelAuth.ts +++ b/lib/kernel/KernelAuth.ts @@ -656,12 +656,10 @@ export function buildKernelConnectionOptions(options: ConnectionOptions): Kernel 'on the same connection. Pick one auth mode.', ); } - return { - ...base, - authMode: 'Pat', - token: staticToken, - ...(enableTokenFederation && federationClientId ? { identityFederationClientId: federationClientId } : {}), - }; + if (enableTokenFederation) { + base.identityFederationClientId = federationClientId || undefined; + } + return { ...base, authMode: 'Pat', token: staticToken }; } if (authType === 'databricks-oauth') { diff --git a/tests/unit/kernel/auth-static-token.test.ts b/tests/unit/kernel/auth-static-token.test.ts index 733c9369..1c889ca8 100644 --- a/tests/unit/kernel/auth-static-token.test.ts +++ b/tests/unit/kernel/auth-static-token.test.ts @@ -64,17 +64,19 @@ describe('KernelAuth — static-token auth options builder', () => { } }); - it('omits an empty federationClientId', () => { - const native = buildKernelConnectionOptions({ - host: 'example.cloud.databricks.com', - path: '/sql/1.0/warehouses/abc', - authType: 'static-token', - staticToken: 'header.payload.signature', - enableTokenFederation: true, - federationClientId: '', - }); + it('selects account-wide federation when enabled without a client id', () => { + for (const federationClientId of [undefined, '']) { + const native = buildKernelConnectionOptions({ + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + authType: 'static-token', + staticToken: 'header.payload.signature', + enableTokenFederation: true, + federationClientId, + }); - expect(native).not.to.have.property('identityFederationClientId'); + expect(native).to.have.property('identityFederationClientId', undefined); + } }); it('rejects a missing or blank static token', () => { From 7e5c2684708517d68bf800138c4085def93952fe Mon Sep 17 00:00:00 2001 From: Vu Anh Phung Date: Thu, 20 Aug 2026 00:25:08 +0000 Subject: [PATCH 7/7] fix(kernel): document mandatory static token federation --- CONNECTION_PARAMETERS.md | 2 +- lib/contracts/IDBSQLClient.ts | 2 + lib/kernel/KernelAuth.ts | 19 ++++----- tests/unit/kernel/auth-static-token.test.ts | 44 ++++++++------------- 4 files changed, 28 insertions(+), 39 deletions(-) diff --git a/CONNECTION_PARAMETERS.md b/CONNECTION_PARAMETERS.md index 757101ba..6c135dd4 100644 --- a/CONNECTION_PARAMETERS.md +++ b/CONNECTION_PARAMETERS.md @@ -64,7 +64,7 @@ column. | `oauthClientId` + no secret | `string` | ✅ (U2M) | ✅ (U2M) | — | **Parity.** The kernel keys flow selection off `oauthClientSecret` presence exactly like Thrift, so `oauthClientId` + no secret routes to **U2M** (with the id forwarded) — it does **not** throw an M2M "secret required" error. | | `azureTenantId` / `useDatabricksOAuthInAzure` | `string` / `boolean` | ✅ | ❌ | — | **Thrift-only.** Kernel rejects Azure-direct (Entra) OAuth; workspace-OIDC discovery covers Azure workspaces without it. | | `persistence` (custom OAuth token store) | `OAuthPersistence` | ✅ | ❌ | — | **Thrift-only.** Kernel throws; it auto-persists U2M tokens to `~/.config/databricks-sql-kernel/oauth/` and does not cache M2M. | -| `enableTokenFederation` / `federationClientId` | `boolean` / `string` | ✅ | ⚠️ | `false` / — | On the kernel backend these options are honored only for `static-token`. When enabled, an omitted or empty client ID selects account-wide WIF; a non-empty ID selects SP-wide WIF. The Thrift backend also supports them for `token-provider` and `external-token`. | +| `enableTokenFederation` / `federationClientId` | `boolean` / `string` | ✅ | ⚠️ | `false` / — | On the kernel backend these options apply only to `static-token`. Federation is always enabled, so `enableTokenFederation` is ignored; an omitted or empty client ID selects account-wide WIF and a non-empty ID selects SP-wide WIF. Thrift honors the boolean and also supports these options for `token-provider` and `external-token`. | ## HTTP client, proxy, retries diff --git a/lib/contracts/IDBSQLClient.ts b/lib/contracts/IDBSQLClient.ts index bbaa4c69..761ebafd 100644 --- a/lib/contracts/IDBSQLClient.ts +++ b/lib/contracts/IDBSQLClient.ts @@ -46,7 +46,9 @@ type AuthOptions = | { authType: 'static-token'; staticToken: string; + /** Ignored by the kernel backend, where token federation is always enabled. */ enableTokenFederation?: boolean; + /** Selects SP-wide federation; omitted selects account-wide federation. */ federationClientId?: string; }; diff --git a/lib/kernel/KernelAuth.ts b/lib/kernel/KernelAuth.ts index 70a0c2b1..45cde7de 100644 --- a/lib/kernel/KernelAuth.ts +++ b/lib/kernel/KernelAuth.ts @@ -61,9 +61,9 @@ const DEFAULT_OAUTH_CLIENT_ID = 'databricks-sql-connector'; * everything else (client_id, scopes, callback timeout, * token_url_override) uses kernel defaults. * - * `static-token` reuses the native PAT bearer-token mode. When its - * `enableTokenFederation` option is true, a non-empty `federationClientId` - * is forwarded under the native name `identityFederationClientId`. + * `static-token` reuses the native PAT bearer-token mode, where federation is + * always enabled. `enableTokenFederation` is ignored; a non-empty + * `federationClientId` selects SP-wide WIF and omission selects account-wide. * * The `authMode` string literals MUST match the napi-emitted `AuthMode` * variant names verbatim (`'Pat'`, `'OAuthM2m'`, `'OAuthU2m'` — napi-rs's @@ -457,9 +457,9 @@ export function buildKernelHttpOptions(options: ConnectionOptions): KernelHttpOp * PAT throughout the existing driver — see * `DBSQLClient.createAuthProvider`). * - Static token: `authType: 'static-token'` + `staticToken`. The token is - * forwarded through the native PAT bearer-token mode. Optional SP-wide - * token federation is enabled by `enableTokenFederation` and selected by - * `federationClientId`. + * forwarded through the native PAT bearer-token mode, where federation is + * always enabled. `federationClientId` selects SP-wide WIF; omission + * selects account-wide WIF. `enableTokenFederation` is ignored. * - OAuth M2M: `authType: 'databricks-oauth'` + `oauthClientId` + * `oauthClientSecret`. Kernel handles OIDC discovery, client_credentials * exchange, and re-auth on expiry internally. @@ -640,9 +640,8 @@ export function buildKernelConnectionOptions(options: ConnectionOptions): Kernel } if (authType === 'static-token') { - const { staticToken, enableTokenFederation, federationClientId } = options as { + const { staticToken, federationClientId } = options as { staticToken?: string; - enableTokenFederation?: boolean; federationClientId?: string; }; if (typeof staticToken !== 'string' || isBlankOrReserved(staticToken)) { @@ -656,9 +655,7 @@ export function buildKernelConnectionOptions(options: ConnectionOptions): Kernel 'on the same connection. Pick one auth mode.', ); } - if (enableTokenFederation) { - base.identityFederationClientId = federationClientId || undefined; - } + base.identityFederationClientId = federationClientId || undefined; return { ...base, authMode: 'Pat', token: staticToken }; } diff --git a/tests/unit/kernel/auth-static-token.test.ts b/tests/unit/kernel/auth-static-token.test.ts index 1c889ca8..256dc24a 100644 --- a/tests/unit/kernel/auth-static-token.test.ts +++ b/tests/unit/kernel/auth-static-token.test.ts @@ -33,24 +33,12 @@ describe('KernelAuth — static-token auth options builder', () => { intervalsAsString: true, authMode: 'Pat', token: 'header.payload.signature', + identityFederationClientId: undefined, }); }); - it('forwards federationClientId when token federation is enabled', () => { - const native = buildKernelConnectionOptions({ - host: 'example.cloud.databricks.com', - path: '/sql/1.0/warehouses/abc', - authType: 'static-token', - staticToken: 'header.payload.signature', - enableTokenFederation: true, - federationClientId: 'federation-client', - }); - - expect(native.identityFederationClientId).to.equal('federation-client'); - }); - - it('does not forward federationClientId when token federation is disabled', () => { - for (const enableTokenFederation of [undefined, false]) { + it('forwards federationClientId regardless of enableTokenFederation', () => { + for (const enableTokenFederation of [undefined, false, true]) { const native = buildKernelConnectionOptions({ host: 'example.cloud.databricks.com', path: '/sql/1.0/warehouses/abc', @@ -60,22 +48,24 @@ describe('KernelAuth — static-token auth options builder', () => { federationClientId: 'federation-client', }); - expect(native).not.to.have.property('identityFederationClientId'); + expect(native.identityFederationClientId).to.equal('federation-client'); } }); - it('selects account-wide federation when enabled without a client id', () => { - for (const federationClientId of [undefined, '']) { - const native = buildKernelConnectionOptions({ - host: 'example.cloud.databricks.com', - path: '/sql/1.0/warehouses/abc', - authType: 'static-token', - staticToken: 'header.payload.signature', - enableTokenFederation: true, - federationClientId, - }); + it('selects account-wide federation without a client id regardless of enableTokenFederation', () => { + for (const enableTokenFederation of [undefined, false, true]) { + for (const federationClientId of [undefined, '']) { + const native = buildKernelConnectionOptions({ + host: 'example.cloud.databricks.com', + path: '/sql/1.0/warehouses/abc', + authType: 'static-token', + staticToken: 'header.payload.signature', + enableTokenFederation, + federationClientId, + }); - expect(native).to.have.property('identityFederationClientId', undefined); + expect(native).to.have.property('identityFederationClientId', undefined); + } } });