@@ -16,6 +16,7 @@ import { ConnectionOptions } from '../contracts/IDBSQLClient';
1616import { InternalConnectionOptions } from '../contracts/InternalConnectionOptions' ;
1717import AuthenticationError from '../errors/AuthenticationError' ;
1818import HiveDriverError from '../errors/HiveDriverError' ;
19+ import { buildUserAgentString } from '../utils' ;
1920
2021/**
2122 * Default local listener port for the U2M authorization-code callback.
@@ -115,10 +116,37 @@ export interface SeaTlsOptions {
115116 checkServerCertificate ?: boolean ;
116117 /** PEM-encoded CA bytes to add to the trust store. */
117118 customCaCert ?: Buffer ;
119+ /**
120+ * PEM-encoded client certificate for mutual TLS (kernel
121+ * `TlsConfig::client_cert_pem`). Paired with {@link clientKeyPem} —
122+ * `buildSeaTlsOptions` rejects supplying only one before the FFI hop.
123+ * The napi shape takes a `Buffer`; the public surface also accepts a
124+ * PEM string, normalised here.
125+ */
126+ clientCertPem ?: Buffer ;
127+ /**
128+ * PEM-encoded private key for the mTLS client certificate (kernel
129+ * `TlsConfig::client_key_pem`). Paired with {@link clientCertPem}.
130+ */
131+ clientKeyPem ?: Buffer ;
132+ }
133+
134+ /**
135+ * HTTP options shared across all auth-mode variants. Mirrors the napi
136+ * binding's `ConnectionOptions.customHeaders` (kernel
137+ * `HttpConfig::custom_headers`).
138+ *
139+ * Carries the extra request headers the SEA path sends on every request:
140+ * the caller's `customHeaders` plus the composed `User-Agent` (the kernel
141+ * appends a `User-Agent` entry to its base UA rather than replacing it).
142+ */
143+ export interface SeaHttpOptions {
144+ customHeaders ?: Record < string , string > ;
118145}
119146
120147export type SeaNativeConnectionOptions = SeaSessionDefaults &
121148 SeaTlsOptions &
149+ SeaHttpOptions &
122150 (
123151 | {
124152 hostName : string ;
@@ -168,24 +196,71 @@ export function isBlankOrReserved(s: string): boolean {
168196const MAX_U32 = 0xffffffff ;
169197
170198/**
171- * Normalise the public TLS options (`checkServerCertificate` /
172- * `customCaCert`) into the napi shape.
199+ * Normalise a PEM input (`string` or `Buffer`) accepted on the public
200+ * surface into the `Buffer` the napi shape requires. Does a light,
201+ * ordered BEGIN…END sanity check so a truncated/headerless blob (or a
202+ * stray page that merely contains the literals out of order, e.g. a
203+ * proxy-intercept page) is rejected here rather than surfacing as an
204+ * opaque kernel TLS error. The bytes are NOT fully parsed in JS — that
205+ * is deferred to the kernel, which returns a meaningful error on a
206+ * malformed PEM/key.
207+ *
208+ * `kind` selects the expected block: `'certificate'` matches a
209+ * `CERTIFICATE` block; `'private key'` matches any `… PRIVATE KEY` block
210+ * (PKCS#8 `PRIVATE KEY`, PKCS#1 `RSA PRIVATE KEY`, SEC1 `EC PRIVATE KEY`).
211+ *
212+ * Throws `HiveDriverError` when the value is empty or (for strings)
213+ * lacks the expected PEM header.
214+ */
215+ function normalizePemBytes ( value : Buffer | string , optionName : string , kind : 'certificate' | 'private key' ) : Buffer {
216+ if ( typeof value === 'string' ) {
217+ const re =
218+ kind === 'certificate'
219+ ? / - - - - - B E G I N C E R T I F I C A T E - - - - - [ \s \S ] + ?- - - - - E N D C E R T I F I C A T E - - - - - /
220+ : / - - - - - B E G I N [ A - Z 0 - 9 ] * P R I V A T E K E Y - - - - - [ \s \S ] + ?- - - - - E N D [ A - Z 0 - 9 ] * P R I V A T E K E Y - - - - - / ;
221+ if ( ! re . test ( value ) ) {
222+ const expected =
223+ kind === 'certificate'
224+ ? "a '-----BEGIN CERTIFICATE-----' … '-----END CERTIFICATE-----' block"
225+ : "a 'BEGIN … PRIVATE KEY' / 'END … PRIVATE KEY' PEM block (PKCS#8, PKCS#1, or SEC1)" ;
226+ throw new HiveDriverError (
227+ `SEA backend: \`${ optionName } \` string does not look like a PEM ${ kind } (expected ${ expected } ). ` +
228+ 'Pass PEM text or a Buffer of PEM bytes.' ,
229+ ) ;
230+ }
231+ return Buffer . from ( value , 'utf8' ) ;
232+ }
233+ if ( Buffer . isBuffer ( value ) ) {
234+ if ( value . length === 0 ) {
235+ throw new HiveDriverError ( `SEA backend: \`${ optionName } \` Buffer is empty.` ) ;
236+ }
237+ return value ;
238+ }
239+ throw new HiveDriverError ( `SEA backend: \`${ optionName } \` must be a PEM string or a Buffer.` ) ;
240+ }
241+
242+ /**
243+ * Normalise the public TLS options into the napi shape.
173244 *
174245 * - `checkServerCertificate` passes through verbatim (only when set; an
175246 * absent value leaves the kernel default, which is secure — verify on).
176- * - `customCaCert` accepts a PEM string or `Buffer` on the public
177- * surface; we convert a string to a `Buffer` here and do a light PEM
178- * sanity check. The bytes are NOT parsed in JS — the kernel returns a
179- * meaningful error if the PEM is malformed.
247+ * - `customCaCert` accepts a PEM string or `Buffer`; normalised to a
248+ * `Buffer` via {@link normalizePemBytes}.
249+ * - `clientCertPem` / `clientKeyPem` carry the mutual-TLS client identity.
250+ * They must be supplied **together** — supplying only one is rejected
251+ * here with an actionable error (rather than waiting for the kernel's
252+ * `InvalidArgument` at `openSession`). Each accepts a PEM string or
253+ * `Buffer`, normalised the same way.
180254 *
181- * Throws `HiveDriverError` when `customCaCert` is supplied but empty or
182- * (for strings) lacks a PEM certificate header .
255+ * Throws `HiveDriverError` when a cert/key is empty, mis-typed, lacks the
256+ * expected PEM header, or when only one half of the mTLS pair is set .
183257 */
184258export function buildSeaTlsOptions ( options : ConnectionOptions ) : SeaTlsOptions {
185259 // Read the SEA-only fields through the purpose-built internal options type
186260 // rather than an ad-hoc inline cast, so the shape can't silently drift from
187261 // its declaration and a typo'd key fails to compile.
188- const { checkServerCertificate, customCaCert } = options as ConnectionOptions & InternalConnectionOptions ;
262+ const { checkServerCertificate, customCaCert, clientCertPem, clientKeyPem } = options as ConnectionOptions &
263+ InternalConnectionOptions ;
189264
190265 const tls : SeaTlsOptions = { } ;
191266
@@ -194,31 +269,79 @@ export function buildSeaTlsOptions(options: ConnectionOptions): SeaTlsOptions {
194269 }
195270
196271 if ( customCaCert !== undefined ) {
197- if ( typeof customCaCert === 'string' ) {
198- // Light PEM sanity check — require a well-ordered BEGIN…END block so a
199- // truncated/headerless cert (or a stray page that merely contains both
200- // literals out of order, e.g. a proxy-intercept page) is rejected here
201- // rather than surfacing as an opaque kernel TLS error. Ordered match, not
202- // two independent substring checks. Full parsing is deferred to the kernel.
203- if ( ! / - - - - - B E G I N C E R T I F I C A T E - - - - - [ \s \S ] + ?- - - - - E N D C E R T I F I C A T E - - - - - / . test ( customCaCert ) ) {
204- throw new HiveDriverError (
205- 'SEA backend: `customCaCert` string does not look like a PEM certificate ' +
206- "(expected a '-----BEGIN CERTIFICATE-----' … '-----END CERTIFICATE-----' block). " +
207- 'Pass PEM text or a Buffer of PEM bytes.' ,
208- ) ;
209- }
210- tls . customCaCert = Buffer . from ( customCaCert , 'utf8' ) ;
211- } else if ( Buffer . isBuffer ( customCaCert ) ) {
212- if ( customCaCert . length === 0 ) {
213- throw new HiveDriverError ( 'SEA backend: `customCaCert` Buffer is empty.' ) ;
272+ tls . customCaCert = normalizePemBytes ( customCaCert , 'customCaCert' , 'certificate' ) ;
273+ }
274+
275+ // mTLS client identity. Enforce both-or-neither up front so a caller who
276+ // sets only one gets a clear message naming the missing half, instead of
277+ // the kernel's generic `InvalidArgument` after the FFI hop.
278+ const hasCert = clientCertPem !== undefined ;
279+ const hasKey = clientKeyPem !== undefined ;
280+ if ( hasCert !== hasKey ) {
281+ throw new HiveDriverError (
282+ 'SEA backend: mutual TLS requires both `clientCertPem` and `clientKeyPem`; only ' +
283+ `\`${ hasCert ? 'clientCertPem' : 'clientKeyPem' } \` was supplied. ` +
284+ `Provide the matching ${ hasCert ? 'private key (`clientKeyPem`)' : 'certificate (`clientCertPem`)' } , ` +
285+ 'or omit both.' ,
286+ ) ;
287+ }
288+ if ( hasCert && hasKey ) {
289+ tls . clientCertPem = normalizePemBytes ( clientCertPem as Buffer | string , 'clientCertPem' , 'certificate' ) ;
290+ tls . clientKeyPem = normalizePemBytes ( clientKeyPem as Buffer | string , 'clientKeyPem' , 'private key' ) ;
291+ }
292+
293+ return tls ;
294+ }
295+
296+ /**
297+ * Build the napi HTTP options (`customHeaders`) from the public
298+ * `customHeaders` map and `userAgentEntry`.
299+ *
300+ * Mirrors the Python connector's `Session.__init__`, which composes a
301+ * single connector `User-Agent` and **unconditionally** appends it after
302+ * the caller's headers:
303+ *
304+ * base_headers = [("User-Agent", self.useragent_header)]
305+ * all_headers = (http_headers or []) + base_headers
306+ *
307+ * So the connector's own `User-Agent` is **always** emitted and is
308+ * authoritative — a caller cannot suppress or replace it via
309+ * `customHeaders` (Python appends `base_headers` last; in the node driver,
310+ * which sends a header map, "appended last" collapses to "the connector UA
311+ * wins", and any caller-supplied `User-Agent`, whatever its casing, is
312+ * dropped to avoid emitting two `User-Agent` keys). The value is composed
313+ * via the same `buildUserAgentString` the Thrift path uses, so the SEA UA
314+ * carries the identical `NodejsDatabricksSqlConnector/...` identity (with
315+ * the optional `userAgentEntry` folded in). The kernel then *appends* this
316+ * to its own base UA, preserving the `DatabricksJDBCDriverOSS/...` token
317+ * the SEA server keys on for result disposition.
318+ *
319+ * All other `customHeaders` entries pass through unchanged. The kernel
320+ * additionally drops the reserved `Authorization` / `x-databricks-org-id`
321+ * names, so those can't be overridden here.
322+ */
323+ export function buildSeaHttpOptions ( options : ConnectionOptions ) : SeaHttpOptions {
324+ const { customHeaders, userAgentEntry } = options ;
325+
326+ const headers : Record < string , string > = { } ;
327+ if ( customHeaders ) {
328+ for ( const [ name , value ] of Object . entries ( customHeaders ) ) {
329+ // Skip any caller-supplied User-Agent (any casing) — the connector
330+ // sets its own composed, authoritative User-Agent below, matching the
331+ // Python connector which appends its `("User-Agent", …)` last.
332+ if ( name . toLowerCase ( ) === 'user-agent' ) {
333+ continue ;
214334 }
215- tls . customCaCert = customCaCert ;
216- } else {
217- throw new HiveDriverError ( 'SEA backend: `customCaCert` must be a PEM string or a Buffer.' ) ;
335+ headers [ name ] = value ;
218336 }
219337 }
220338
221- return tls ;
339+ // Always emit the connector's composed User-Agent, regardless of whether
340+ // `userAgentEntry` or a caller `customHeaders` User-Agent was supplied —
341+ // exactly the Python connector's unconditional `base_headers` append.
342+ headers [ 'User-Agent' ] = buildUserAgentString ( userAgentEntry ) ;
343+
344+ return { customHeaders : headers } ;
222345}
223346
224347/**
@@ -282,7 +405,8 @@ export function buildSeaConnectionOptions(options: ConnectionOptions): SeaNative
282405 httpPath : string ;
283406 intervalsAsString : boolean ;
284407 maxConnections ?: number ;
285- } & SeaTlsOptions = {
408+ } & SeaTlsOptions &
409+ SeaHttpOptions = {
286410 hostName : options . host ,
287411 httpPath : prependSlash ( options . path ) ,
288412 // Match the NodeJS Thrift driver, which surfaces INTERVAL columns as
@@ -292,9 +416,12 @@ export function buildSeaConnectionOptions(options: ConnectionOptions): SeaNative
292416 // (native Arrow) — they already decode identically to Thrift via the
293417 // shared Arrow converter, so `complexTypesAsJson` is not forced on.
294418 intervalsAsString : true ,
295- // TLS knobs (server-cert verification toggle + custom CA). Validated and
296- // normalised (string PEM → Buffer) here so the napi shape only sees a Buffer.
419+ // TLS knobs (server-cert verification toggle + custom CA + mTLS client
420+ // identity). Validated and normalised (string PEM → Buffer) here so the
421+ // napi shape only sees a Buffer.
297422 ...buildSeaTlsOptions ( options ) ,
423+ // HTTP headers (caller `customHeaders` + composed `User-Agent`).
424+ ...buildSeaHttpOptions ( options ) ,
298425 } ;
299426
300427 // SEA-only pool sizing; read via cast to match how this function reads the
0 commit comments