Skip to content

Commit 50f436e

Browse files
committed
sea-auth: address PR #379 review (F1–F10)
- F1: fix the fake-binding casts in auth-pat.test.ts so the unit suite type-checks (nyc registers ts-node/register with full type-checking — the bad casts were failing the whole unit-test CI job). Cast through the binding's own member types. - F2: the PAT e2e test couldn't compile (useSEA excess-property) and was orphaned under tests/integration (run by no CI script, outside the lint glob). Cast useSEA as ConnectionOptions & InternalConnectionOptions and move it to tests/e2e/sea/ (wired by `npm run e2e` + linted); drop the orphaned tests/integration/.mocharc.js. - F3: surface the real server-issued session id from the kernel Connection.sessionId getter instead of a process-local synthetic counter, so DBSQLSession's logged id correlates with kernel/server logs. - F4: only build the Thrift auth/connection providers on the Thrift path; the SEA path never reads them, so this stops validating the PAT twice and constructing a throwaway OAuth provider for an OAuth+useSEA call. - F5: derive SeaNativeConnectionOptions via Pick<SeaConnectionOptions,…> instead of re-declaring it, so a kernel field rename fails to compile rather than silently drifting. - F6: reject whitespace/control chars in the PAT (parity with the Python driver; the kernel HeaderValue already blocks CR/LF/NUL). - F7: extract the duplicated prependSlash into lib/utils/prependSlash.ts. - F8: lazy-load the native binding (resolve on first use, not in the constructor) so constructing SeaBackend never throws on a platform without the optional .node — connect()'s clearer validation runs first. - F9: thread the client logger into SeaBackend and log backend selection + session open/close (token excluded). - F10: document that SEA connect() does no network round-trip. Verified: tsc clean (0 errors, was 4), 13/13 unit tests, lib lint clean. Co-authored-by: Isaac
1 parent bfaa2cc commit 50f436e

7 files changed

Lines changed: 124 additions & 65 deletions

File tree

lib/DBSQLClient.ts

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,7 @@ import IDBSQLLogger, { LogLevel } from './contracts/IDBSQLLogger';
3232
import DBSQLLogger from './DBSQLLogger';
3333
import CloseableCollection from './utils/CloseableCollection';
3434
import IConnectionProvider from './connection/contracts/IConnectionProvider';
35-
36-
function prependSlash(str: string): string {
37-
if (str.length > 0 && str.charAt(0) !== '/') {
38-
return `/${str}`;
39-
}
40-
return str;
41-
}
35+
import prependSlash from './utils/prependSlash';
4236

4337
export type ThriftLibrary = Pick<typeof thrift, 'createClient'>;
4438

@@ -234,20 +228,26 @@ export default class DBSQLClient extends EventEmitter implements IDBSQLClient, I
234228
this.config.userAgentEntry = options.userAgentEntry;
235229
}
236230

237-
this.authProvider = this.createAuthProvider(options, authProvider);
238-
239-
this.connectionProvider = this.createConnectionProvider(options);
240-
241231
// M0: `useSEA` is consumed via a non-exported internal-options cast so it
242232
// doesn't ship in the public `.d.ts`. Mirrors Python's `kwargs.get("use_sea")`
243233
// pattern (see databricks-sql-python/src/databricks/sql/session.py).
244234
const internalOptions = options as ConnectionOptions & InternalConnectionOptions;
245-
this.backend = internalOptions.useSEA
246-
? new SeaBackend()
247-
: new ThriftBackend({
248-
context: this,
249-
onConnectionEvent: (event, payload) => this.forwardConnectionEvent(event, payload),
250-
});
235+
236+
if (internalOptions.useSEA) {
237+
// The SEA backend authenticates inside the native binding; the
238+
// Thrift auth/connection providers are never read on this path, so
239+
// we don't build them (avoids validating the PAT twice and
240+
// constructing a throwaway OAuth provider for an OAuth+useSEA call).
241+
this.logger.log(LogLevel.info, 'Connecting via the SEA (native) backend');
242+
this.backend = new SeaBackend(undefined, this.logger);
243+
} else {
244+
this.authProvider = this.createAuthProvider(options, authProvider);
245+
this.connectionProvider = this.createConnectionProvider(options);
246+
this.backend = new ThriftBackend({
247+
context: this,
248+
onConnectionEvent: (event, payload) => this.forwardConnectionEvent(event, payload),
249+
});
250+
}
251251

252252
await this.backend.connect(options);
253253

lib/sea/SeaAuth.ts

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -15,26 +15,17 @@
1515
import { ConnectionOptions } from '../contracts/IDBSQLClient';
1616
import AuthenticationError from '../errors/AuthenticationError';
1717
import HiveDriverError from '../errors/HiveDriverError';
18+
import prependSlash from '../utils/prependSlash';
19+
import { SeaConnectionOptions } from './SeaNativeLoader';
1820

1921
/**
20-
* Shape consumed by the napi-binding's `openSession()` (see
21-
* `native/sea/index.d.ts`). M0 supports PAT only — `token` is required.
22-
*
23-
* Mirrors `ConnectionOptions` in the binding's `.d.ts`; declared locally
24-
* to avoid coupling the JS-side adapter to the auto-generated TS file.
22+
* Shape consumed by the napi-binding's `openSession()`. M0 sends only the
23+
* PAT triple, so we `Pick` those fields off the binding's generated
24+
* `ConnectionOptions` (re-exported as `SeaConnectionOptions`) rather than
25+
* re-declaring them — if the kernel renames `hostName`/`httpPath`/`token`
26+
* this stops compiling instead of silently drifting.
2527
*/
26-
export interface SeaNativeConnectionOptions {
27-
hostName: string;
28-
httpPath: string;
29-
token: string;
30-
}
31-
32-
function prependSlash(str: string): string {
33-
if (str.length > 0 && str.charAt(0) !== '/') {
34-
return `/${str}`;
35-
}
36-
return str;
37-
}
28+
export type SeaNativeConnectionOptions = Pick<SeaConnectionOptions, 'hostName' | 'httpPath' | 'token'>;
3829

3930
/**
4031
* Validate that the user-supplied `ConnectionOptions` describe a PAT auth
@@ -74,6 +65,17 @@ export function buildSeaConnectionOptions(options: ConnectionOptions): SeaNative
7465
'SEA backend: a non-empty PAT must be supplied via `token` when using `authType: \'access-token\'`.',
7566
);
7667
}
68+
// Reject whitespace / control characters in the PAT. The kernel's
69+
// reqwest `HeaderValue` already hard-rejects CR/LF/NUL at build time so
70+
// this isn't a header-injection fix — it's parity with the Python
71+
// driver (auth_bridge.py rejects `[\x00-\x20\x7f]`) and catches
72+
// copy-paste whitespace before a confusing downstream failure.
73+
// eslint-disable-next-line no-control-regex
74+
if (/[\x00-\x20\x7f]/.test(token)) {
75+
throw new AuthenticationError(
76+
'SEA backend: the PAT supplied via `token` must not contain whitespace or control characters.',
77+
);
78+
}
7779

7880
return {
7981
hostName: options.host,

lib/sea/SeaBackend.ts

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
import Status from '../dto/Status';
3232
import InfoValue from '../dto/InfoValue';
3333
import HiveDriverError from '../errors/HiveDriverError';
34+
import IDBSQLLogger, { LogLevel } from '../contracts/IDBSQLLogger';
3435
import { getSeaNative, SeaNativeBinding } from './SeaNativeLoader';
3536
import { buildSeaConnectionOptions, SeaNativeConnectionOptions } from './SeaAuth';
3637

@@ -44,6 +45,8 @@ const NOT_IMPLEMENTED_SESSION =
4445
* leak into every call site.
4546
*/
4647
interface NativeConnection {
48+
/** Server-issued session id (kernel `Connection.sessionId` getter). */
49+
readonly sessionId: string;
4750
close(): Promise<void>;
4851
}
4952

@@ -54,21 +57,22 @@ interface NativeConnection {
5457
* subset required to round-trip a connect-open-close cycle. Every other
5558
* method throws a clear "not implemented in M0" `HiveDriverError`.
5659
*
57-
* The `id` field is currently a synthetic counter-based string; the kernel
58-
* exposes a real session-id through a follow-on getter that
59-
* `sea-execution` will wire through.
60+
* `id` is the server-issued session id read straight off the kernel
61+
* `Connection` (its `sessionId` getter, readable even after close()), so
62+
* the value logged by `DBSQLSession` correlates with kernel / server logs
63+
* rather than being a process-local synthetic counter.
6064
*/
6165
export class SeaSessionBackend implements ISessionBackend {
62-
private static seq = 0;
63-
6466
public readonly id: string;
6567

6668
private readonly connection: NativeConnection;
6769

68-
constructor(connection: NativeConnection) {
70+
private readonly logger?: IDBSQLLogger;
71+
72+
constructor(connection: NativeConnection, logger?: IDBSQLLogger) {
6973
this.connection = connection;
70-
SeaSessionBackend.seq += 1;
71-
this.id = `sea-session-${SeaSessionBackend.seq}`;
74+
this.logger = logger;
75+
this.id = connection.sessionId;
7276
}
7377

7478
/* eslint-disable @typescript-eslint/no-unused-vars */
@@ -121,6 +125,7 @@ export class SeaSessionBackend implements ISessionBackend {
121125
/* eslint-enable @typescript-eslint/no-unused-vars */
122126

123127
public async close(): Promise<Status> {
128+
this.logger?.log(LogLevel.debug, `SEA session closing with id: ${this.id}`);
124129
await this.connection.close();
125130
return Status.success();
126131
}
@@ -140,16 +145,36 @@ export class SeaSessionBackend implements ISessionBackend {
140145
export default class SeaBackend implements IBackend {
141146
private nativeOptions?: SeaNativeConnectionOptions;
142147

143-
private readonly native: SeaNativeBinding;
148+
private readonly injectedNative?: SeaNativeBinding;
149+
150+
private cachedNative?: SeaNativeBinding;
151+
152+
private readonly logger?: IDBSQLLogger;
144153

145-
constructor(native: SeaNativeBinding = getSeaNative()) {
146-
this.native = native;
154+
// `native` is injectable (tests pass a fake); production leaves it
155+
// undefined and the binding is resolved lazily on first use so that
156+
// constructing a SeaBackend never throws on a platform without the
157+
// optional `.node` — the clearer auth/option validation in connect()
158+
// runs first.
159+
constructor(native?: SeaNativeBinding, logger?: IDBSQLLogger) {
160+
this.injectedNative = native;
161+
this.logger = logger;
162+
}
163+
164+
private get native(): SeaNativeBinding {
165+
if (!this.cachedNative) {
166+
this.cachedNative = this.injectedNative ?? getSeaNative();
167+
}
168+
return this.cachedNative;
147169
}
148170

149171
public async connect(options: ConnectionOptions): Promise<void> {
150-
// Validate PAT auth + capture the napi-binding option shape.
151-
// Any non-PAT mode (or a missing token) throws here, before we ever
152-
// touch the native binding.
172+
// Validate PAT auth + capture the napi-binding option shape. Any
173+
// non-PAT mode (or a missing token) throws here, before we ever touch
174+
// the native binding. NOTE: unlike Thrift, this performs no network
175+
// round-trip — the session is opened lazily in openSession(), so a
176+
// resolved connect() does not by itself prove the endpoint is
177+
// reachable or the credential is valid.
153178
this.nativeOptions = buildSeaConnectionOptions(options);
154179
}
155180

@@ -159,7 +184,9 @@ export default class SeaBackend implements IBackend {
159184
throw new HiveDriverError('SeaBackend: connect() must be called before openSession().');
160185
}
161186
const connection = (await this.native.openSession(this.nativeOptions)) as NativeConnection;
162-
return new SeaSessionBackend(connection);
187+
const session = new SeaSessionBackend(connection, this.logger);
188+
this.logger?.log(LogLevel.info, `SEA session opened with id: ${session.id}`);
189+
return session;
163190
}
164191

165192
public async close(): Promise<void> {

lib/utils/prependSlash.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Copyright (c) 2026 Databricks, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
/**
16+
* Normalise an HTTP path to a leading-slash form. Empty strings are left
17+
* untouched. Shared by the Thrift connect path (`DBSQLClient`) and the
18+
* SEA auth adapter (`SeaAuth`) so the two can't drift.
19+
*/
20+
export default function prependSlash(str: string): string {
21+
if (str.length > 0 && str.charAt(0) !== '/') {
22+
return `/${str}`;
23+
}
24+
return str;
25+
}

tests/integration/sea/auth-pat-e2e.test.ts renamed to tests/e2e/sea/auth-pat-e2e.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414

1515
import { expect } from 'chai';
1616
import { DBSQLClient } from '../../../lib';
17+
import { ConnectionOptions } from '../../../lib/contracts/IDBSQLClient';
18+
import { InternalConnectionOptions } from '../../../lib/contracts/InternalConnectionOptions';
1719

1820
/**
1921
* sea-auth M0 end-to-end:
@@ -58,8 +60,11 @@ describe('sea-auth e2e — PAT through DBSQLClient ↔ SeaBackend ↔ napi bindi
5860
host: host as string,
5961
path: path as string,
6062
token: token as string,
63+
// `useSEA` is an internal opt-in (InternalConnectionOptions), not a
64+
// public ConnectionOptions field — cast exactly as DBSQLClient.connect
65+
// does internally so the literal passes excess-property checking.
6166
useSEA: true,
62-
});
67+
} as ConnectionOptions & InternalConnectionOptions);
6368
expect(connected).to.equal(client);
6469

6570
const session = await client.openSession();

tests/integration/.mocharc.js

Lines changed: 0 additions & 11 deletions
This file was deleted.

tests/unit/sea/auth-pat.test.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ function makeFakeBinding() {
2929
const calls: Array<{ method: string; args: unknown[] }> = [];
3030

3131
const fakeConnection = {
32+
// Mirrors the kernel `Connection.sessionId` getter; SeaSessionBackend
33+
// surfaces this as its `id`.
34+
sessionId: '01ef-fake-session-id',
3235
async executeStatement() {
3336
throw new Error('not used in this test');
3437
},
@@ -43,10 +46,17 @@ function makeFakeBinding() {
4346
},
4447
async openSession(opts: { hostName: string; httpPath: string; token: string }) {
4548
calls.push({ method: 'openSession', args: [opts] });
46-
return fakeConnection as unknown;
49+
// Cast through the binding's own member types: `SeaNativeBinding` is
50+
// `typeof import('../../native/sea')`, so `openSession`'s resolved
51+
// return type is the napi `Connection`. A bare `as unknown` stops
52+
// short of that and fails to satisfy the annotation.
53+
return fakeConnection as unknown as Awaited<ReturnType<SeaNativeBinding['openSession']>>;
4754
},
48-
Connection: function FakeConnection() {} as unknown as Function,
49-
Statement: function FakeStatement() {} as unknown as Function,
55+
// `Connection`/`Statement` are exported as type aliases in
56+
// SeaNativeLoader, so `typeof Connection` is illegal (TS2693); index
57+
// the binding type instead to get the napi class constructor type.
58+
Connection: function FakeConnection() {} as unknown as SeaNativeBinding['Connection'],
59+
Statement: function FakeStatement() {} as unknown as SeaNativeBinding['Statement'],
5060
};
5161

5262
return { binding, calls };
@@ -167,7 +177,8 @@ describe('SeaAuth + SeaBackend — PAT auth flow', () => {
167177

168178
const session = await backend.openSession({});
169179
expect(session).to.exist;
170-
expect(session.id).to.match(/^sea-session-\d+$/);
180+
// id is the real server-issued session id (kernel `sessionId`).
181+
expect(session.id).to.equal('01ef-fake-session-id');
171182

172183
expect(calls).to.have.lengthOf(1);
173184
expect(calls[0].method).to.equal('openSession');

0 commit comments

Comments
 (0)