@@ -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,72 @@ 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+ * The SEA path always emits a `User-Agent` so it identifies itself on the
301+ * wire (the kernel *appends* it to its base UA, preserving the
302+ * `DatabricksJDBCDriverOSS/...` token the SEA server keys on for result
303+ * disposition). Precedence for that header:
304+ * 1. `userAgentEntry` (the dedicated knob) — composed via the same
305+ * `buildUserAgentString` the Thrift path uses, so the SEA UA carries
306+ * the identical `NodejsDatabricksSqlConnector/...` identity.
307+ * 2. otherwise a `User-Agent` set directly in `customHeaders` is kept.
308+ * 3. otherwise the base connector UA (`buildUserAgentString(undefined)`).
309+ *
310+ * All other `customHeaders` entries pass through unchanged. Note the
311+ * kernel additionally drops the reserved `Authorization` /
312+ * `x-databricks-org-id` names, so those can't be overridden here.
313+ */
314+ export function buildSeaHttpOptions ( options : ConnectionOptions ) : SeaHttpOptions {
315+ const { customHeaders, userAgentEntry } = options ;
316+
317+ const headers : Record < string , string > = { } ;
318+ let callerUserAgentKey : string | undefined ;
319+ if ( customHeaders ) {
320+ for ( const [ name , value ] of Object . entries ( customHeaders ) ) {
321+ headers [ name ] = value ;
322+ if ( name . toLowerCase ( ) === 'user-agent' ) {
323+ callerUserAgentKey = name ;
214324 }
215- tls . customCaCert = customCaCert ;
216- } else {
217- throw new HiveDriverError ( 'SEA backend: `customCaCert` must be a PEM string or a Buffer.' ) ;
218325 }
219326 }
220327
221- return tls ;
328+ if ( userAgentEntry !== undefined || callerUserAgentKey === undefined ) {
329+ // Drop any caller-supplied User-Agent (whatever its casing) before
330+ // setting the canonical one, so we never emit two User-Agent keys.
331+ if ( callerUserAgentKey !== undefined ) {
332+ delete headers [ callerUserAgentKey ] ;
333+ }
334+ headers [ 'User-Agent' ] = buildUserAgentString ( userAgentEntry ) ;
335+ }
336+
337+ return Object . keys ( headers ) . length > 0 ? { customHeaders : headers } : { } ;
222338}
223339
224340/**
@@ -282,7 +398,8 @@ export function buildSeaConnectionOptions(options: ConnectionOptions): SeaNative
282398 httpPath : string ;
283399 intervalsAsString : boolean ;
284400 maxConnections ?: number ;
285- } & SeaTlsOptions = {
401+ } & SeaTlsOptions &
402+ SeaHttpOptions = {
286403 hostName : options . host ,
287404 httpPath : prependSlash ( options . path ) ,
288405 // Match the NodeJS Thrift driver, which surfaces INTERVAL columns as
@@ -292,9 +409,12 @@ export function buildSeaConnectionOptions(options: ConnectionOptions): SeaNative
292409 // (native Arrow) — they already decode identically to Thrift via the
293410 // shared Arrow converter, so `complexTypesAsJson` is not forced on.
294411 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.
412+ // TLS knobs (server-cert verification toggle + custom CA + mTLS client
413+ // identity). Validated and normalised (string PEM → Buffer) here so the
414+ // napi shape only sees a Buffer.
297415 ...buildSeaTlsOptions ( options ) ,
416+ // HTTP headers (caller `customHeaders` + composed `User-Agent`).
417+ ...buildSeaHttpOptions ( options ) ,
298418 } ;
299419
300420 // SEA-only pool sizing; read via cast to match how this function reads the
0 commit comments