Skip to content

Commit 68dbb3d

Browse files
committed
[SEA-NodeJS] Kernel backend: mTLS, custom HTTP headers & User-Agent
Wire the SEA/kernel path's remaining TLS-adjacent connection options through to the napi binding: - mTLS client identity: `clientCertPem` / `clientKeyPem` (PEM string or Buffer) on the internal SEA options, normalised to Buffers and routed to the kernel `TlsConfig::client_cert_pem` / `client_key_pem`. Enforces both-or-neither up front with an actionable error. - Custom HTTP headers: the public `customHeaders` map is forwarded to the kernel `HttpConfig::custom_headers`. - User-Agent: `userAgentEntry` is composed via the same `buildUserAgentString` the Thrift path uses and sent as a `User-Agent` header, which the kernel appends to its base UA (preserving the result-disposition gating token). userAgentEntry wins over a customHeaders User-Agent; otherwise a caller-set one is kept, else the base connector UA is always emitted. Adds `buildSeaHttpOptions`, extends `buildSeaTlsOptions`/`SeaTlsOptions`, and factors PEM normalisation into a shared helper. Bumps KERNEL_REV and regenerates `native/sea/index.d.ts` for the new napi fields. Unit tests cover mTLS pairing/validation, header pass-through, and UA precedence. Depends on the kernel napi change exposing clientCertPem / clientKeyPem / customHeaders; KERNEL_REV must be repointed to that commit once merged. Co-authored-by: Isaac Signed-off-by: Madhavendra Rathore <madhavendra.rathore@databricks.com>
1 parent 4b9e16e commit 68dbb3d

5 files changed

Lines changed: 329 additions & 35 deletions

File tree

KERNEL_REV

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
8bedaabf69f5bce5a957a8775f29dbb8dbdd2e71
1+
4c2b7d71a4fb04eff236ec095c5b3b9e64bdd9f0

lib/contracts/InternalConnectionOptions.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,22 @@ export interface InternalConnectionOptions {
4141
* @internal SEA path only.
4242
*/
4343
customCaCert?: Buffer | string;
44+
45+
/**
46+
* SEA-only: PEM-encoded client certificate (string or `Buffer`) for
47+
* mutual TLS (mTLS). Must be supplied together with `clientKeyPem`; a
48+
* leaf cert optionally followed by its intermediate chain is accepted.
49+
* Mirrors the Python connector's `_tls_client_cert_file`.
50+
* @internal SEA path only.
51+
*/
52+
clientCertPem?: Buffer | string;
53+
54+
/**
55+
* SEA-only: PEM-encoded private key (string or `Buffer`) for the mTLS
56+
* client certificate. Must be supplied together with `clientCertPem`.
57+
* For portability supply a PKCS#8 key (`BEGIN PRIVATE KEY`). Mirrors the
58+
* Python connector's `_tls_client_cert_key_file`.
59+
* @internal SEA path only.
60+
*/
61+
clientKeyPem?: Buffer | string;
4462
}

lib/sea/SeaAuth.ts

Lines changed: 153 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { ConnectionOptions } from '../contracts/IDBSQLClient';
1616
import { InternalConnectionOptions } from '../contracts/InternalConnectionOptions';
1717
import AuthenticationError from '../errors/AuthenticationError';
1818
import 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

120147
export type SeaNativeConnectionOptions = SeaSessionDefaults &
121148
SeaTlsOptions &
149+
SeaHttpOptions &
122150
(
123151
| {
124152
hostName: string;
@@ -168,24 +196,71 @@ export function isBlankOrReserved(s: string): boolean {
168196
const 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+
? /-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/
220+
: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]+?-----END [A-Z0-9 ]*PRIVATE KEY-----/;
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
*/
184258
export 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 (!/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/.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

native/sea/index.d.ts

Lines changed: 40 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)