Skip to content

Commit a97b96e

Browse files
committed
sea-auth-u2m: round-4 fixup — restore M2M-with-bad-secret class, strip envelope sentinel, trim RetryError doc
- NF3-2 (HIGH): when oauthClientId is set and oauthClientSecret is blank/reserved, raise AuthenticationError (M2M intent) instead of routing to U2M which then raises HiveDriverError. The round-3 NF-N3 fix over-applied — U2M routing only kicks in when BOTH id and secret are blank/absent. - NF3-3 (MEDIUM): on corrupted-envelope JSON.parse failure, strip the internal __databricks_error__: sentinel from the message before returning to the caller. - NF3-6 (LOW): trim RetryError mention from KernelMetadata.errorCode doc-comments; no kernel ErrorCode currently maps to RetryError. Deferred per team-lead disposition: NF3-1 (kernel RequestTokenError sub-classification — Phase 2 kernel work), NF3-4 (e2e kernel-error-code assertion — blocked on NF3-1), NF3-5 (path-dep checksum — resolves when kernel publishes), NF3-7 (looksReal double-neg — cosmetic), LE3-1..7 (Phase 2 decoder polish). Co-authored-by: Isaac
1 parent 21916fd commit a97b96e

5 files changed

Lines changed: 101 additions & 49 deletions

File tree

lib/sea/SeaAuth.ts

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -182,20 +182,26 @@ export function buildSeaConnectionOptions(options: ConnectionOptions): SeaNative
182182
}
183183

184184
// Flow selector mirrors thrift's `DBSQLClient.createAuthProvider`
185-
// (`DBSQLClient.ts:143`): `oauthClientSecret defined ? M2M : U2M`.
186-
// Blank or buggy-shell-export secrets route to U2M (rather than
187-
// M2M-with-bad-secret) so the error message correctly points the user
188-
// at the right flow. `oauthClientSecret: process.env.MY_SECRET || ''`
189-
// is a common shape; routing it to the M2M arm would surface an
190-
// M2M-specific error that never mentions U2M.
185+
// (`DBSQLClient.ts:143`): presence of `oauthClientId` indicates M2M
186+
// intent, otherwise U2M. Routing decision is based on `oauthClientId`
187+
// (the "do I have an id?" signal) rather than the secret, so a
188+
// user who set an id but typoed/forgot the secret gets the M2M
189+
// "secret is required" error instead of a U2M error that hides
190+
// their actual intent. The U2M arm still defends against an id
191+
// sneaking through (e.g. caller bypasses shape inference).
192+
const idIsBlank =
193+
oauth.oauthClientId === undefined ||
194+
(typeof oauth.oauthClientId === 'string' && isBlankOrReserved(oauth.oauthClientId));
191195
const secretIsBlank =
192-
typeof oauth.oauthClientSecret === 'string' && isBlankOrReserved(oauth.oauthClientSecret);
193-
if (oauth.oauthClientSecret === undefined || secretIsBlank) {
194-
// U2M.
196+
oauth.oauthClientSecret === undefined ||
197+
(typeof oauth.oauthClientSecret === 'string' && isBlankOrReserved(oauth.oauthClientSecret));
198+
199+
if (idIsBlank && secretIsBlank) {
200+
// U2M — neither id nor secret supplied.
195201
if (oauth.oauthClientId !== undefined) {
202+
// Defense-in-depth: id was set but blank/reserved literal.
196203
// The kernel hardcodes `client_id = "databricks-cli"` for U2M;
197-
// there's no JS-side override knob. Silently dropping a
198-
// user-supplied id would hide that the kernel ignored it.
204+
// there's no JS-side override knob.
199205
throw new HiveDriverError(
200206
'SEA backend: `oauthClientId` is not supported on the OAuth U2M flow; ' +
201207
"the kernel uses the built-in 'databricks-cli' client. " +

lib/sea/SeaErrorMapping.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,12 @@ export type KernelErrorCode =
5656
* `__databricks_error__:` envelope (per `native/sea/src/error.rs:50-89`).
5757
*
5858
* `errorCode` is namespaced under `kernelMetadata` rather than placed at
59-
* the top level because two existing JS-side error classes
60-
* (`OperationStateError`, `RetryError`) already declare a top-level
59+
* the top level because `OperationStateError` already declares a top-level
6160
* `errorCode: enum` field, and `DBSQLOperation.ts:209` switches on it
6261
* (`err.errorCode === OperationStateErrorCode.Canceled`). Top-level
6362
* defineProperty would clobber that enum with a kernel string and break
64-
* cancel/close detection.
63+
* cancel/close detection. (`RetryError.errorCode` is the same shape and
64+
* is reserved here for future kernel→`RetryError` mappings.)
6565
*/
6666
export interface KernelMetadata {
6767
errorCode?: string;
@@ -76,7 +76,7 @@ export interface KernelMetadata {
7676
* exposed at the top level (no collision in the existing driver error
7777
* tree); the remaining envelope fields live under a `kernelMetadata`
7878
* namespace to avoid clobbering pre-existing `errorCode` semantics on
79-
* `OperationStateError` / `RetryError`.
79+
* `OperationStateError` (and, reserved for future use, `RetryError`).
8080
*/
8181
export interface ErrorWithSqlState extends Error {
8282
sqlState?: string;
@@ -210,9 +210,10 @@ function buildKernelMetadata(parsed: Record<string, unknown>): KernelMetadata {
210210
* through {@link mapKernelErrorToJsError}, and attach the remaining
211211
* envelope fields under a single non-enumerable `kernelMetadata`
212212
* namespace. Namespacing avoids the collision with
213-
* `OperationStateError.errorCode` (an enum) and `RetryError.errorCode`
214-
* (an enum), each of which is already switched on at the JS layer
215-
* (see `DBSQLOperation.ts:209`).
213+
* `OperationStateError.errorCode` (an enum already switched on at the
214+
* JS layer — see `DBSQLOperation.ts:209`). `RetryError.errorCode`
215+
* shares the shape and is reserved for future kernel→`RetryError`
216+
* mappings.
216217
* - Binding-side error (e.g. `napi::Error::new(InvalidArg, "openSession:
217218
* \`token\` is required for the requested auth mode")` produced by
218219
* the binding's own validation): returned unchanged. These don't
@@ -237,8 +238,12 @@ export function decodeNapiKernelError(err: unknown): Error {
237238
try {
238239
parsed = JSON.parse(jsonStr);
239240
} catch {
240-
// Corrupted envelope — surface the raw message rather than
241-
// silently dropping the original error.
241+
// Corrupted envelope — surface the raw post-sentinel payload rather
242+
// than silently dropping the original error. Strip the internal
243+
// `__databricks_error__:` prefix; it's a binding/JS-side framing
244+
// marker, not user-actionable, and leaking it makes the message
245+
// confusing to operators triaging a malformed kernel response.
246+
err.message = jsonStr;
242247
return err;
243248
}
244249

tests/unit/sea/auth-edge-cases.test.ts

Lines changed: 48 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -76,13 +76,13 @@ describe('SeaAuth — edge cases (input validation + ambiguity)', () => {
7676
}
7777
});
7878

79-
// Post round-3 NF-N3: a blank/reserved-literal `oauthClientSecret`
80-
// routes the connection to the U2M arm rather than rejecting on
81-
// the M2M arm. When `oauthClientId` is ALSO set, the U2M arm's
82-
// dedicated "not supported on U2M" rejection fires — which is more
83-
// actionable than the M2M "secret must be non-empty" message
84-
// because it tells the user the U2M flow exists and how to use it.
85-
it('routes mixed-case reserved-literal oauthClientSecret to U2M; rejects with U2M-id error', () => {
79+
// Round-4 NF3-2: presence of `oauthClientId` signals M2M intent.
80+
// A blank/reserved-literal `oauthClientSecret` is then a missing-secret
81+
// typo, not a request to fall back to U2M. Surface the M2M "secret
82+
// required" AuthenticationError so the user fixes the real problem
83+
// rather than swap class to a HiveDriverError pointing at a flow
84+
// they didn't intend to use.
85+
it('rejects mixed-case reserved-literal oauthClientSecret with AuthenticationError when id is set', () => {
8686
const opts: ConnectionOptions = {
8787
host: 'example.cloud.databricks.com',
8888
path: '/sql/1.0/warehouses/abc',
@@ -92,8 +92,8 @@ describe('SeaAuth — edge cases (input validation + ambiguity)', () => {
9292
};
9393

9494
expect(() => buildSeaConnectionOptions(opts)).to.throw(
95-
HiveDriverError,
96-
/oauthClientId.*not supported on the OAuth U2M flow/,
95+
AuthenticationError,
96+
/oauthClientSecret.*non-empty.*OAuth M2M/,
9797
);
9898
});
9999

@@ -112,7 +112,7 @@ describe('SeaAuth — edge cases (input validation + ambiguity)', () => {
112112
);
113113
});
114114

115-
it('routes whitespace-only oauthClientSecret to U2M; with oauthClientId set, rejects U2M+id', () => {
115+
it('rejects whitespace-only oauthClientSecret with AuthenticationError when oauthClientId is set (M2M intent)', () => {
116116
const opts: ConnectionOptions = {
117117
host: 'example.cloud.databricks.com',
118118
path: '/sql/1.0/warehouses/abc',
@@ -122,8 +122,8 @@ describe('SeaAuth — edge cases (input validation + ambiguity)', () => {
122122
};
123123

124124
expect(() => buildSeaConnectionOptions(opts)).to.throw(
125-
HiveDriverError,
126-
/oauthClientId.*not supported on the OAuth U2M flow/,
125+
AuthenticationError,
126+
/oauthClientSecret.*non-empty.*OAuth M2M/,
127127
);
128128
});
129129

@@ -142,7 +142,7 @@ describe('SeaAuth — edge cases (input validation + ambiguity)', () => {
142142
);
143143
});
144144

145-
it('routes literal "undefined" as oauthClientSecret to U2M; with oauthClientId set, rejects U2M+id', () => {
145+
it('rejects literal "undefined" as oauthClientSecret with AuthenticationError when id is set (M2M intent)', () => {
146146
const opts: ConnectionOptions = {
147147
host: 'example.cloud.databricks.com',
148148
path: '/sql/1.0/warehouses/abc',
@@ -152,10 +152,37 @@ describe('SeaAuth — edge cases (input validation + ambiguity)', () => {
152152
};
153153

154154
expect(() => buildSeaConnectionOptions(opts)).to.throw(
155-
HiveDriverError,
156-
/oauthClientId.*not supported on the OAuth U2M flow/,
155+
AuthenticationError,
156+
/oauthClientSecret.*non-empty.*OAuth M2M/,
157157
);
158158
});
159+
160+
// Round-4 NF3-2: pin the exact class — must be `AuthenticationError`,
161+
// not the bare `HiveDriverError` superclass. The round-3 NF-N3 fix
162+
// swapped this silently by routing M2M-with-empty-secret through the
163+
// U2M arm, which raised a plain `HiveDriverError`. Guard against that
164+
// regression by pinning the constructor name (since
165+
// `AuthenticationError extends HiveDriverError`, `instanceof` alone
166+
// can't distinguish the two).
167+
it('M2M-with-empty-secret throws AuthenticationError, not bare HiveDriverError (class pin)', () => {
168+
const opts: ConnectionOptions = {
169+
host: 'example.cloud.databricks.com',
170+
path: '/sql/1.0/warehouses/abc',
171+
authType: 'databricks-oauth',
172+
oauthClientId: 'x',
173+
oauthClientSecret: '',
174+
};
175+
176+
let caught: unknown;
177+
try {
178+
buildSeaConnectionOptions(opts);
179+
} catch (e) {
180+
caught = e;
181+
}
182+
expect(caught).to.be.instanceOf(AuthenticationError);
183+
expect((caught as Error).constructor.name).to.equal('AuthenticationError');
184+
expect((caught as Error).message).to.match(/oauthClientSecret.*non-empty.*OAuth M2M/);
185+
});
159186
});
160187

161188
describe('ambiguous credentials are rejected', () => {
@@ -399,7 +426,7 @@ describe('SeaBackend — kernel error envelope decoding (DA-F1)', () => {
399426
expect((caught as Error).message).to.match(/`token` is required/);
400427
});
401428

402-
it('falls back to original Error for a corrupted envelope', async () => {
429+
it('falls back to original Error for a corrupted envelope, stripping the internal sentinel', async () => {
403430
const binding = bindingRejectingWith('not valid json');
404431
const backend = new SeaBackend(binding);
405432
await backend.connect(validConnectArgs);
@@ -414,6 +441,11 @@ describe('SeaBackend — kernel error envelope decoding (DA-F1)', () => {
414441
// the original Error so the operator sees the raw payload.
415442
expect(caught).to.be.instanceOf(Error);
416443
expect((caught as Error).message).to.contain('not valid json');
444+
// Round-4 NF3-3: the `__databricks_error__:` prefix is an internal
445+
// JS<->binding framing marker; it must not leak to the user-facing
446+
// message even on the corrupted-envelope fallback path.
447+
expect((caught as Error).message).to.not.match(/^__databricks_error__:/);
448+
expect((caught as Error).message).to.equal('not valid json');
417449
});
418450

419451
// NF-4 / NF-N1: preserve the 5 optional kernel envelope fields on the

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

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ describe('SeaAuth + SeaBackend — OAuth M2M auth flow', () => {
8383
);
8484
});
8585

86-
it('routes empty oauthClientSecret to the U2M arm (round-3 NF-N3), where oauthClientId being set then rejects', () => {
86+
it('rejects empty oauthClientSecret with AuthenticationError when oauthClientId is set (M2M intent)', () => {
8787
const opts: ConnectionOptions = {
8888
host: 'example.cloud.databricks.com',
8989
path: '/sql/1.0/warehouses/abc',
@@ -92,11 +92,13 @@ describe('SeaAuth + SeaBackend — OAuth M2M auth flow', () => {
9292
oauthClientSecret: '',
9393
};
9494

95-
// Blank secret → U2M arm; oauthClientId set on U2M then raises
96-
// the dedicated "not supported on U2M" error.
95+
// Presence of `oauthClientId` signals M2M intent; an empty secret
96+
// is a typo/missing-env, not a request to fall back to U2M.
97+
// Surface the M2M "secret required" error so the user knows the
98+
// real problem instead of getting routed to a different flow.
9799
expect(() => buildSeaConnectionOptions(opts)).to.throw(
98-
HiveDriverError,
99-
/oauthClientId.*not supported on the OAuth U2M flow/,
100+
AuthenticationError,
101+
/oauthClientSecret.*non-empty.*OAuth M2M/,
100102
);
101103
});
102104

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

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { expect } from 'chai';
1616
import SeaBackend from '../../../lib/sea/SeaBackend';
1717
import { buildSeaConnectionOptions } from '../../../lib/sea/SeaAuth';
1818
import { ConnectionOptions } from '../../../lib/contracts/IDBSQLClient';
19+
import AuthenticationError from '../../../lib/errors/AuthenticationError';
1920
import HiveDriverError from '../../../lib/errors/HiveDriverError';
2021
import { makeFakeBinding } from './_helpers/fakeBinding';
2122

@@ -37,12 +38,18 @@ describe('SeaAuth + SeaBackend — OAuth U2M auth flow', () => {
3738
});
3839
});
3940

40-
it('rejects oauthClientId on the U2M path with a clear "not supported" error', () => {
41-
// The kernel hardcodes `client_id = "databricks-cli"` for U2M; there's
42-
// no JS-side override knob. Silently dropping a user-supplied id would
43-
// hide that the kernel ignored it, so we surface the limitation
44-
// explicitly. Earlier revisions of this code silently dropped — flipped
45-
// to raise based on devils-advocate-auth-u2m-1 round-1 (NF-2).
41+
it('rejects oauthClientId without oauthClientSecret as M2M-with-missing-secret', () => {
42+
// Round-4 NF3-2: presence of `oauthClientId` signals M2M intent.
43+
// Routing now keys off the id (the "do I have an id?" signal),
44+
// not the secret. A caller who supplies id but no secret gets the
45+
// M2M "secret is required" error — the actionable message for the
46+
// real problem (typo'd env var, forgot to export it, etc.).
47+
//
48+
// The U2M arm still has a defense-in-depth rejection of a stray
49+
// `oauthClientId` (the kernel hardcodes `databricks-cli` for U2M);
50+
// see [NF-2 / round-1 history]. That defense fires only when
51+
// BOTH id and secret are blank — the M2M arm's stricter checks
52+
// catch this typical caller-error shape first.
4653
const opts: ConnectionOptions = {
4754
host: 'example.cloud.databricks.com',
4855
path: '/sql/1.0/warehouses/abc',
@@ -51,8 +58,8 @@ describe('SeaAuth + SeaBackend — OAuth U2M auth flow', () => {
5158
};
5259

5360
expect(() => buildSeaConnectionOptions(opts)).to.throw(
54-
HiveDriverError,
55-
/oauthClientId.*not supported on the OAuth U2M flow/,
61+
AuthenticationError,
62+
/oauthClientSecret.*non-empty.*OAuth M2M/,
5663
);
5764
});
5865

0 commit comments

Comments
 (0)