diff --git a/CONNECTION_PARAMETERS.md b/CONNECTION_PARAMETERS.md index 30853562..6c135dd4 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 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 @@ -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/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..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 7cf99afa..45cde7de 100644 --- a/lib/kernel/KernelAuth.ts +++ b/lib/kernel/KernelAuth.ts @@ -61,6 +61,10 @@ 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, 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 * `#[napi(string_enum)]` without an explicit case option emits the @@ -212,10 +216,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; @@ -443,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, 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. @@ -556,7 +573,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 @@ -621,6 +639,26 @@ export function buildKernelConnectionOptions(options: ConnectionOptions): Kernel return { ...base, authMode: 'Pat', token }; } + if (authType === 'static-token') { + const { staticToken, federationClientId } = options as { + staticToken?: string; + 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'`.", + ); + } + if (oauth.oauthClientId !== undefined || oauth.oauthClientSecret !== undefined) { + throw new HiveDriverError( + 'kernel backend: cannot supply `staticToken` alongside `oauthClientId`/`oauthClientSecret` ' + + 'on the same connection. Pick one auth mode.', + ); + } + base.identityFederationClientId = federationClientId || undefined; + return { ...base, authMode: 'Pat', token: staticToken }; + } + if (authType === 'databricks-oauth') { if ((options as { token?: string }).token !== undefined) { throw new HiveDriverError( @@ -694,7 +732,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/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-pat.test.ts b/tests/unit/kernel/auth-pat.test.ts index 5304298c..7d8b7b25 100644 --- a/tests/unit/kernel/auth-pat.test.ts +++ b/tests/unit/kernel/auth-pat.test.ts @@ -111,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..256dc24a --- /dev/null +++ b/tests/unit/kernel/auth-static-token.test.ts @@ -0,0 +1,98 @@ +// 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'; +import HiveDriverError from '../../../lib/errors/HiveDriverError'; + +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', + identityFederationClientId: undefined, + }); + }); + + 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', + authType: 'static-token', + staticToken: 'header.payload.signature', + enableTokenFederation, + federationClientId: 'federation-client', + }); + + expect(native.identityFederationClientId).to.equal('federation-client'); + } + }); + + 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); + } + } + }); + + 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`/); + } + }); + + it('rejects conflicting OAuth credentials', () => { + for (const conflicting of [{ 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/); + } + }); +});